Add support for include_enterprise_slug for the github join method (#35860)

* Add support for `include_enterprise_slug` in `github` joining

* Correct assertiosn in github validator token test

* Remove `want` in TestIDTokenValidator_Validate

* Fix missing `should` in docs

Co-authored-by: Krzysztof Skrzętnicki <krzysztof.skrzetnicki@goteleport.com>

* Fix spelling of customize in docs

* Update operator CRDs

* Regen protos

* Apply suggestions from code review

Co-authored-by: Paul Gottschling <paul.gottschling@goteleport.com>

* Try both under same header

* Try H4

* Remove spurious period

* Use bold rather than heading

---------

Co-authored-by: Krzysztof Skrzętnicki <krzysztof.skrzetnicki@goteleport.com>
Co-authored-by: Paul Gottschling <paul.gottschling@goteleport.com>
This commit is contained in:
Noah Stride
2023-12-19 13:55:23 +00:00
committed by GitHub
co-authored by Krzysztof Skrzętnicki Paul Gottschling
parent 5d43dc5d5c
commit d653229ab9
12 changed files with 1827 additions and 1561 deletions
@@ -1292,6 +1292,17 @@ message ProvisionTokenSpecV2GitHub {
// include the scheme or a path. The instance must be accessible over HTTPS
// at this hostname and the certificate must be trusted by the Auth Server.
string EnterpriseServerHost = 2 [(gogoproto.jsontag) = "enterprise_server_host,omitempty"];
// EnterpriseSlug allows the slug of a GitHub Enterprise organisation to be
// included in the expected issuer of the OIDC tokens. This is for
// compatibility with the `include_enterprise_slug` option in GHE.
//
// This field should be set to the slug of your enterprise if this is enabled. If
// this is not enabled, then this field must be left empty. This field cannot
// be specified if `enterprise_server_host` is specified.
//
// See https://docs.github.com/en/enterprise-cloud@latest/actions/deployment/security-hardening-your-deployments/about-security-hardening-with-openid-connect#customizing-the-issuer-value-for-an-enterprise
// for more information about customised issuer values.
string EnterpriseSlug = 3 [(gogoproto.jsontag) = "enterprise_slug,omitempty"];
}
// ProvisionTokenSpecV2GitLab contains the GitLab-specific part of the
+3
View File
@@ -587,6 +587,9 @@ func (a *ProvisionTokenSpecV2GitHub) checkAndSetDefaults() error {
if strings.Contains(a.EnterpriseServerHost, "/") {
return trace.BadParameter("'spec.github.enterprise_server_host' should not contain the scheme or path")
}
if a.EnterpriseServerHost != "" && a.EnterpriseSlug != "" {
return trace.BadParameter("'spec.github.enterprise_server_host' and `spec.github.enterprise_slug` cannot both be set")
}
return nil
}
+22
View File
@@ -318,6 +318,28 @@ func TestProvisionTokenV2_CheckAndSetDefaults(t *testing.T) {
},
expectedErr: &trace.BadParameterError{},
},
{
desc: "github slug and ghes set",
token: &ProvisionTokenV2{
Metadata: Metadata{
Name: "test",
},
Spec: ProvisionTokenSpecV2{
Roles: []SystemRole{RoleNode},
JoinMethod: JoinMethodGitHub,
GitHub: &ProvisionTokenSpecV2GitHub{
EnterpriseServerHost: "example.com",
EnterpriseSlug: "slug",
Allow: []*ProvisionTokenSpecV2GitHub_Rule{
{
Sub: "foo",
},
},
},
},
},
expectedErr: &trace.BadParameterError{},
},
{
desc: "circleci valid",
token: &ProvisionTokenV2{
+1564 -1509
View File
File diff suppressed because it is too large Load Diff
@@ -22,6 +22,18 @@ spec:
# this value should be configured to the hostname of your GHES instance.
enterprise_server_host: ghes.example.com
# enterprise_slug allows the slug of a GitHub Enterprise organisation to be
# included in the expected issuer of the OIDC tokens. This is for
# compatibility with the include_enterprise_slug option in GHE.
#
# This field should be set to the slug of your Github Enterprise organization if this is enabled. If
# this is not enabled, then this field must be left empty. This field cannot
# be specified if `enterprise_server_host` is specified.
#
# See https://docs.github.com/en/enterprise-cloud@latest/actions/deployment/security-hardening-your-deployments/about-security-hardening-with-openid-connect#customizing-the-issuer-value-for-an-enterprise
# for more information about customized issuer values.
enterprise_slug: slug
# allow is an array of rule configurations for what GitHub Actions workflows
# should be allowed to join. All options configured within one allow entry
# must be satisfied for the GitHub Actions run to be allowed to join. Where
@@ -61,7 +61,9 @@ Replace `gravitational/example` with the name of the repository that `tbot`
will run within. You may also choose to change the name of the bot and token
to more accurately describe your use-case.
<Admonition type="note" title="Using GitHub Enterprise Server?">
<Admonition type="note" title="Using GitHub Enterprise?">
**Enterprise Server**
From Teleport 11.1.4, users with Teleport Enterprise are able to permit
workflows within GitHub Enterprise Server instances to authenticate using the
GitHub join method.
@@ -78,6 +80,22 @@ spec:
github:
enterprise_server_host: ghes.example.com
```
**Enterprise Cloud**
If you have enabled `include_enterprise_slug` in your GitHub Enterprise
Cloud configuration, you will need to set `spec.github.enterprise_slug` to
the slug of your GitHub Enterprise organization.
For example:
```yaml
spec:
github:
enterprise_slug: my-enterprise
```
Read more about `include_enterprise_slug` on the the GitHub guide to
[customizing the issuer value for an enterprise](https://docs.github.com/en/enterprise-cloud@latest/actions/deployment/security-hardening-your-deployments/about-security-hardening-with-openid-connect#customizing-the-issuer-value-for-an-enterprise).
</Admonition>
Once the resource file has been written, create the token with `tctl`:
@@ -181,6 +181,16 @@ spec:
must be accessible over HTTPS at this hostname and the certificate
must be trusted by the Auth Server.
type: string
enterprise_slug:
description: EnterpriseSlug allows the slug of a GitHub Enterprise
organisation to be included in the expected issuer of the OIDC
tokens. This is for compatibility with the `include_enterprise_slug`
option in GHE. This field should be set to the slug of your
enterprise if this is enabled. If this is not enabled, then
this field must be left empty. This field cannot be specified
if `enterprise_server_host` is specified. See https://docs.github.com/en/enterprise-cloud@latest/actions/deployment/security-hardening-your-deployments/about-security-hardening-with-openid-connect#customizing-the-issuer-value-for-an-enterprise
for more information about customised issuer values.
type: string
type: object
gitlab:
description: GitLab allows the configuration of options specific to
@@ -181,6 +181,16 @@ spec:
must be accessible over HTTPS at this hostname and the certificate
must be trusted by the Auth Server.
type: string
enterprise_slug:
description: EnterpriseSlug allows the slug of a GitHub Enterprise
organisation to be included in the expected issuer of the OIDC
tokens. This is for compatibility with the `include_enterprise_slug`
option in GHE. This field should be set to the slug of your
enterprise if this is enabled. If this is not enabled, then
this field must be left empty. This field cannot be specified
if `enterprise_server_host` is specified. See https://docs.github.com/en/enterprise-cloud@latest/actions/deployment/security-hardening-your-deployments/about-security-hardening-with-openid-connect#customizing-the-issuer-value-for-an-enterprise
for more information about customised issuer values.
type: string
type: object
gitlab:
description: GitLab allows the configuration of options specific to
+4 -3
View File
@@ -32,7 +32,7 @@ import (
type ghaIDTokenValidator interface {
Validate(
ctx context.Context, GHESHost string, token string,
ctx context.Context, GHESHost string, enterpriseSlug string, token string,
) (*githubactions.IDTokenClaims, error)
}
@@ -53,7 +53,8 @@ func (a *Server) checkGitHubJoinRequest(ctx context.Context, req *types.Register
// enterpriseOverride is a hostname to use instead of github.com when
// validating tokens. This allows GHES instances to be connected.
enterpriseOverride := token.Spec.GitHub.EnterpriseServerHost
if enterpriseOverride != "" {
enterpriseSlug := token.Spec.GitHub.EnterpriseSlug
if enterpriseOverride != "" || enterpriseSlug != "" {
if modules.GetModules().BuildType() != modules.BuildEnterprise {
return nil, fmt.Errorf(
"github enterprise server joining: %w",
@@ -63,7 +64,7 @@ func (a *Server) checkGitHubJoinRequest(ctx context.Context, req *types.Register
}
claims, err := a.ghaIDTokenValidator.Validate(
ctx, enterpriseOverride, req.IDToken,
ctx, enterpriseOverride, enterpriseSlug, req.IDToken,
)
if err != nil {
return nil, trace.Wrap(err)
+59 -11
View File
@@ -34,15 +34,18 @@ import (
)
type mockIDTokenValidator struct {
tokens map[string]githubactions.IDTokenClaims
lastCalledGHESHost string
tokens map[string]githubactions.IDTokenClaims
lastCalledGHESHost string
lastCalledEnterpriseSlug string
}
var errMockInvalidToken = errors.New("invalid token")
func (m *mockIDTokenValidator) Validate(_ context.Context, ghes string, token string) (*githubactions.IDTokenClaims, error) {
func (m *mockIDTokenValidator) Validate(
_ context.Context, ghes, enterpriseSlug, token string,
) (*githubactions.IDTokenClaims, error) {
m.lastCalledGHESHost = ghes
m.lastCalledEnterpriseSlug = enterpriseSlug
claims, ok := m.tokens[token]
if !ok {
return nil, errMockInvalidToken
@@ -51,6 +54,11 @@ func (m *mockIDTokenValidator) Validate(_ context.Context, ghes string, token st
return &claims, nil
}
func (m *mockIDTokenValidator) reset() {
m.lastCalledGHESHost = ""
m.lastCalledEnterpriseSlug = ""
}
func TestAuth_RegisterUsingToken_GHA(t *testing.T) {
validIDToken := "test.fake.jwt"
idTokenValidator := &mockIDTokenValidator{
@@ -149,6 +157,22 @@ func TestAuth_RegisterUsingToken_GHA(t *testing.T) {
assertError: require.NoError,
setEnterprise: true,
},
{
name: "enterprise slug",
tokenSpec: types.ProvisionTokenSpecV2{
JoinMethod: types.JoinMethodGitHub,
Roles: []types.SystemRole{types.RoleNode},
GitHub: &types.ProvisionTokenSpecV2GitHub{
EnterpriseSlug: "slug",
Allow: []*types.ProvisionTokenSpecV2GitHub_Rule{
allowRule(nil),
},
},
},
setEnterprise: true,
request: newRequest(validIDToken),
assertError: require.NoError,
},
{
name: "ghes override requires enterprise license",
tokenSpec: types.ProvisionTokenSpecV2{
@@ -166,6 +190,23 @@ func TestAuth_RegisterUsingToken_GHA(t *testing.T) {
require.ErrorIs(t, err, ErrRequiresEnterprise)
}),
},
{
name: "enterprise slug requires enterprise license",
tokenSpec: types.ProvisionTokenSpecV2{
JoinMethod: types.JoinMethodGitHub,
Roles: []types.SystemRole{types.RoleNode},
GitHub: &types.ProvisionTokenSpecV2GitHub{
EnterpriseSlug: "slug",
Allow: []*types.ProvisionTokenSpecV2GitHub_Rule{
allowRule(nil),
},
},
},
request: newRequest(validIDToken),
assertError: require.ErrorAssertionFunc(func(t require.TestingT, err error, i ...interface{}) {
require.ErrorIs(t, err, ErrRequiresEnterprise)
}),
},
{
name: "multiple allow rules",
tokenSpec: types.ProvisionTokenSpecV2{
@@ -314,6 +355,7 @@ func TestAuth_RegisterUsingToken_GHA(t *testing.T) {
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Cleanup(idTokenValidator.reset)
if tt.setEnterprise {
modules.SetTestModules(
t,
@@ -329,14 +371,20 @@ func TestAuth_RegisterUsingToken_GHA(t *testing.T) {
_, err = auth.RegisterUsingToken(ctx, tt.request)
tt.assertError(t, err)
if tt.tokenSpec.GitHub.EnterpriseServerHost != "" {
require.Equal(
t,
tt.tokenSpec.GitHub.EnterpriseServerHost,
idTokenValidator.lastCalledGHESHost,
)
if err != nil {
return
}
require.Equal(
t,
tt.tokenSpec.GitHub.EnterpriseServerHost,
idTokenValidator.lastCalledGHESHost,
)
require.Equal(
t,
tt.tokenSpec.GitHub.EnterpriseSlug,
idTokenValidator.lastCalledEnterpriseSlug,
)
})
}
}
+14 -4
View File
@@ -60,22 +60,32 @@ func NewIDTokenValidator(cfg IDTokenValidatorConfig) *IDTokenValidator {
}
}
func (id *IDTokenValidator) issuerURL(GHESHost string) string {
func (id *IDTokenValidator) issuerURL(
GHESHost string, enterpriseSlug string,
) string {
scheme := "https"
if id.insecure {
scheme = "http"
}
if GHESHost == "" {
return fmt.Sprintf("%s://%s", scheme, id.GitHubIssuerHost)
url := fmt.Sprintf("%s://%s", scheme, id.GitHubIssuerHost)
// Support custom enterprise slugs, as per:
// https://docs.github.com/en/enterprise-cloud@latest/actions/deployment/security-hardening-your-deployments/about-security-hardening-with-openid-connect#customizing-the-issuer-value-for-an-enterprise
if enterpriseSlug != "" {
url = fmt.Sprintf("%s/%s", url, enterpriseSlug)
}
return url
}
return fmt.Sprintf("%s://%s/_services/token", scheme, GHESHost)
}
func (id *IDTokenValidator) Validate(ctx context.Context, GHESHost string, token string) (*IDTokenClaims, error) {
func (id *IDTokenValidator) Validate(
ctx context.Context, GHESHost string, enterpriseSlug string, token string,
) (*IDTokenClaims, error) {
p, err := oidc.NewProvider(
ctx,
id.issuerURL(GHESHost),
id.issuerURL(GHESHost, enterpriseSlug),
)
if err != nil {
return nil, trace.Wrap(err)
+99 -33
View File
@@ -35,14 +35,15 @@ import (
)
type fakeIDP struct {
t *testing.T
signer jose.Signer
privateKey *rsa.PrivateKey
server *httptest.Server
ghesMode bool
t *testing.T
signer jose.Signer
privateKey *rsa.PrivateKey
server *httptest.Server
entepriseSlug string
ghesMode bool
}
func newFakeIDP(t *testing.T, ghesMode bool) *fakeIDP {
func newFakeIDP(t *testing.T, ghesMode bool, enterpriseSlug string) *fakeIDP {
privateKey, err := rsa.GenerateKey(rand.Reader, 2048)
require.NoError(t, err)
@@ -53,19 +54,20 @@ func newFakeIDP(t *testing.T, ghesMode bool) *fakeIDP {
require.NoError(t, err)
f := &fakeIDP{
signer: signer,
ghesMode: ghesMode,
privateKey: privateKey,
t: t,
signer: signer,
ghesMode: ghesMode,
privateKey: privateKey,
t: t,
entepriseSlug: enterpriseSlug,
}
providerMux := http.NewServeMux()
providerMux.HandleFunc(
f.pathPrefix()+"/.well-known/openid-configuration",
f.pathPostfix()+"/.well-known/openid-configuration",
f.handleOpenIDConfig,
)
providerMux.HandleFunc(
f.pathPrefix()+"/.well-known/jwks",
f.pathPostfix()+"/.well-known/jwks",
f.handleJWKSEndpoint,
)
@@ -75,17 +77,20 @@ func newFakeIDP(t *testing.T, ghesMode bool) *fakeIDP {
return f
}
func (f *fakeIDP) pathPrefix() string {
func (f *fakeIDP) pathPostfix() string {
if f.ghesMode {
// GHES instances serve the token related content on a prefix of the
// instance hostname.
return "/_services/token"
}
if f.entepriseSlug != "" {
return "/" + f.entepriseSlug
}
return ""
}
func (f *fakeIDP) issuer() string {
return f.server.URL + f.pathPrefix()
return f.server.URL + f.pathPostfix()
}
func (f *fakeIDP) handleOpenIDConfig(w http.ResponseWriter, r *http.Request) {
@@ -178,19 +183,23 @@ func (f *fakeIDP) issueToken(
func TestIDTokenValidator_Validate(t *testing.T) {
t.Parallel()
idp := newFakeIDP(t, false)
ghesIdp := newFakeIDP(t, true)
idp := newFakeIDP(t, false, "")
ghesIdp := newFakeIDP(t, true, "")
enterpriseSlugIDP := newFakeIDP(t, false, "slug")
tests := []struct {
name string
assertError require.ErrorAssertionFunc
want *IDTokenClaims
token string
ghesHost string
name string
assertError require.ErrorAssertionFunc
want *IDTokenClaims
token string
ghesHost string
defaultIDPHost string
enterpriseSlug string
}{
{
name: "success",
assertError: require.NoError,
name: "success",
assertError: require.NoError,
defaultIDPHost: idp.server.Listener.Addr().String(),
token: idp.issueToken(
t,
idp.issuer(),
@@ -208,6 +217,9 @@ func TestIDTokenValidator_Validate(t *testing.T) {
{
name: "success with ghes",
assertError: require.NoError,
// This is intentionally the plain IDP as the GHES Host should
// override it.
defaultIDPHost: idp.server.Listener.Addr().String(),
token: ghesIdp.issueToken(
t,
ghesIdp.issuer(),
@@ -224,8 +236,57 @@ func TestIDTokenValidator_Validate(t *testing.T) {
ghesHost: ghesIdp.server.Listener.Addr().String(),
},
{
name: "expired",
assertError: require.Error,
name: "success with slug",
assertError: require.NoError,
defaultIDPHost: enterpriseSlugIDP.server.Listener.Addr().String(),
token: enterpriseSlugIDP.issueToken(
t,
enterpriseSlugIDP.issuer(),
"teleport.cluster.local",
"octocat",
"repo:octo-org/octo-repo:environment:prod",
time.Now().Add(-5*time.Minute),
time.Now().Add(5*time.Minute),
),
enterpriseSlug: "slug",
want: &IDTokenClaims{
Actor: "octocat",
Sub: "repo:octo-org/octo-repo:environment:prod",
},
},
{
name: "fails if slugged jwt is used with non-slug idp",
assertError: require.Error,
defaultIDPHost: idp.server.Listener.Addr().String(),
token: enterpriseSlugIDP.issueToken(
t,
enterpriseSlugIDP.issuer(),
"teleport.cluster.local",
"octocat",
"repo:octo-org/octo-repo:environment:prod",
time.Now().Add(-5*time.Minute),
time.Now().Add(5*time.Minute),
),
},
{
name: "fails if non-slugged jwt is used with idp",
assertError: require.Error,
defaultIDPHost: enterpriseSlugIDP.server.Listener.Addr().String(),
token: idp.issueToken(
t,
idp.issuer(),
"teleport.cluster.local",
"octocat",
"repo:octo-org/octo-repo:environment:prod",
time.Now().Add(-5*time.Minute),
time.Now().Add(5*time.Minute),
),
enterpriseSlug: "slug",
},
{
name: "expired",
assertError: require.Error,
defaultIDPHost: idp.server.Listener.Addr().String(),
token: idp.issueToken(
t,
idp.issuer(),
@@ -237,8 +298,9 @@ func TestIDTokenValidator_Validate(t *testing.T) {
),
},
{
name: "future",
assertError: require.Error,
name: "future",
assertError: require.Error,
defaultIDPHost: idp.server.Listener.Addr().String(),
token: idp.issueToken(
t,
idp.issuer(),
@@ -248,8 +310,9 @@ func TestIDTokenValidator_Validate(t *testing.T) {
time.Now().Add(10*time.Minute), time.Now().Add(20*time.Minute)),
},
{
name: "invalid audience",
assertError: require.Error,
name: "invalid audience",
assertError: require.Error,
defaultIDPHost: idp.server.Listener.Addr().String(),
token: idp.issueToken(
t,
idp.issuer(),
@@ -259,8 +322,9 @@ func TestIDTokenValidator_Validate(t *testing.T) {
time.Now().Add(-5*time.Minute), time.Now().Add(5*time.Minute)),
},
{
name: "invalid issuer",
assertError: require.Error,
name: "invalid issuer",
assertError: require.Error,
defaultIDPHost: idp.server.Listener.Addr().String(),
token: idp.issueToken(
t,
"https://the.wrong.issuer",
@@ -275,11 +339,13 @@ func TestIDTokenValidator_Validate(t *testing.T) {
ctx := context.Background()
v := NewIDTokenValidator(IDTokenValidatorConfig{
Clock: clockwork.NewRealClock(),
GitHubIssuerHost: idp.server.Listener.Addr().String(),
GitHubIssuerHost: tt.defaultIDPHost,
insecure: true,
})
claims, err := v.Validate(ctx, tt.ghesHost, tt.token)
claims, err := v.Validate(
ctx, tt.ghesHost, tt.enterpriseSlug, tt.token,
)
tt.assertError(t, err)
require.Equal(t, tt.want, claims)
})