diff --git a/lib/auth/auth.go b/lib/auth/auth.go index 8c75b768c6e..d96705cae68 100644 --- a/lib/auth/auth.go +++ b/lib/auth/auth.go @@ -122,6 +122,7 @@ import ( "github.com/gravitational/teleport/lib/join/gcp" "github.com/gravitational/teleport/lib/join/githubactions" "github.com/gravitational/teleport/lib/join/gitlab" + "github.com/gravitational/teleport/lib/join/spacelift" "github.com/gravitational/teleport/lib/join/terraformcloud" "github.com/gravitational/teleport/lib/join/tpmjoin" kubetoken "github.com/gravitational/teleport/lib/kube/token" @@ -138,7 +139,6 @@ import ( "github.com/gravitational/teleport/lib/services" "github.com/gravitational/teleport/lib/services/local" "github.com/gravitational/teleport/lib/services/readonly" - "github.com/gravitational/teleport/lib/spacelift" "github.com/gravitational/teleport/lib/sshca" "github.com/gravitational/teleport/lib/sshutils" "github.com/gravitational/teleport/lib/tlsca" @@ -1276,7 +1276,7 @@ type Server struct { // spaceliftIDTokenValidator allows ID tokens from Spacelift to be validated // by the auth server. It can be overridden for the purpose of tests. - spaceliftIDTokenValidator spaceliftIDTokenValidator + spaceliftIDTokenValidator spacelift.Validator // gitlabIDTokenValidator allows ID tokens from GitLab CI to be validated by // the auth server. It can be overridden for the purpose of tests. diff --git a/lib/auth/bot_test.go b/lib/auth/bot_test.go index a7f47491013..b773cc09989 100644 --- a/lib/auth/bot_test.go +++ b/lib/auth/bot_test.go @@ -27,6 +27,7 @@ import ( "encoding/base64" "encoding/json" "encoding/pem" + "errors" "fmt" "io" "net/http" @@ -78,6 +79,8 @@ import ( "github.com/gravitational/teleport/lib/utils/log/logtest" ) +var errMockInvalidToken = errors.New("invalid token") + func renewBotCerts( ctx context.Context, srv *authtest.TLSServer, diff --git a/lib/auth/export_test.go b/lib/auth/export_test.go index aef3a4a4231..e0d295d2fd7 100644 --- a/lib/auth/export_test.go +++ b/lib/auth/export_test.go @@ -199,10 +199,6 @@ func (a *Server) SetK8sTokenReviewValidator(validator k8sTokenReviewValidator) { a.k8sTokenReviewValidator = validator } -func (a *Server) SetSpaceliftIDTokenValidator(validator spaceliftIDTokenValidator) { - a.spaceliftIDTokenValidator = validator -} - func (a *Server) SetCreateBoundKeypairValidator(validator boundkeypair.CreateBoundKeypairValidator) { a.createBoundKeypairValidator = validator } diff --git a/lib/auth/join/join.go b/lib/auth/join/join.go index 26c23542cba..4b3648bcb38 100644 --- a/lib/auth/join/join.go +++ b/lib/auth/join/join.go @@ -56,10 +56,10 @@ import ( "github.com/gravitational/teleport/lib/join/circleci" "github.com/gravitational/teleport/lib/join/githubactions" "github.com/gravitational/teleport/lib/join/gitlab" + "github.com/gravitational/teleport/lib/join/spacelift" "github.com/gravitational/teleport/lib/join/terraformcloud" "github.com/gravitational/teleport/lib/jwt" kubetoken "github.com/gravitational/teleport/lib/kube/token" - "github.com/gravitational/teleport/lib/spacelift" "github.com/gravitational/teleport/lib/tlsca" "github.com/gravitational/teleport/lib/tpm" "github.com/gravitational/teleport/lib/utils" @@ -361,9 +361,11 @@ func Register(ctx context.Context, params RegisterParams) (result *RegisterResul } } case types.JoinMethodSpacelift: - params.IDToken, err = spacelift.NewIDTokenSource(os.Getenv).GetIDToken() - if err != nil { - return nil, trace.Wrap(err) + if params.IDToken == "" { + params.IDToken, err = spacelift.NewIDTokenSource(os.Getenv).GetIDToken() + if err != nil { + return nil, trace.Wrap(err) + } } case types.JoinMethodTerraformCloud: if params.IDToken == "" { diff --git a/lib/auth/join_spacelift.go b/lib/auth/join_spacelift.go index 3a2360c6036..891808fd176 100644 --- a/lib/auth/join_spacelift.go +++ b/lib/auth/join_spacelift.go @@ -20,20 +20,23 @@ package auth import ( "context" - "fmt" "github.com/gravitational/trace" "github.com/gravitational/teleport/api/types" - "github.com/gravitational/teleport/lib/join/joinutils" - "github.com/gravitational/teleport/lib/modules" - "github.com/gravitational/teleport/lib/spacelift" + "github.com/gravitational/teleport/lib/join/spacelift" ) -type spaceliftIDTokenValidator interface { - Validate( - ctx context.Context, domain string, token string, - ) (*spacelift.IDTokenClaims, error) +// GetSpaceliftIDTokenValidator returns the server's currently configured +// Spacelift OIDC token validator. +func (a *Server) GetSpaceliftIDTokenValidator() spacelift.Validator { + return a.spaceliftIDTokenValidator +} + +// SetSpaceliftIDTokenValidator sets the current Spacelift OIDC token validator, +// used in tests. +func (a *Server) SetSpaceliftIDTokenValidator(validator spacelift.Validator) { + a.spaceliftIDTokenValidator = validator } func (a *Server) checkSpaceliftJoinRequest( @@ -41,75 +44,13 @@ func (a *Server) checkSpaceliftJoinRequest( req *types.RegisterUsingTokenRequest, pt types.ProvisionToken, ) (*spacelift.IDTokenClaims, error) { - if req.IDToken == "" { - return nil, trace.BadParameter("id_token not provided for spacelift join request") - } - token, ok := pt.(*types.ProvisionTokenV2) - if !ok { - return nil, trace.BadParameter("spacelift join method only supports ProvisionTokenV2, '%T' was provided", pt) - } + claims, err := spacelift.CheckIDToken(ctx, &spacelift.CheckIDTokenParams{ + ProvisionToken: pt, + IDToken: []byte(req.IDToken), + Validator: a.spaceliftIDTokenValidator, + }) - if modules.GetModules().BuildType() != modules.BuildEnterprise { - return nil, fmt.Errorf( - "spacelift joining: %w", - ErrRequiresEnterprise, - ) - } - - claims, err := a.spaceliftIDTokenValidator.Validate( - ctx, token.Spec.Spacelift.Hostname, req.IDToken, - ) - if err != nil { - return nil, trace.Wrap(err) - } - - a.logger.InfoContext(ctx, "Spacelift run trying to join cluster", - "claims", claims, - "token", pt.GetName(), - ) - - return claims, trace.Wrap(checkSpaceliftAllowRules(token, claims)) -} - -func checkSpaceliftAllowRules(token *types.ProvisionTokenV2, claims *spacelift.IDTokenClaims) error { - globCheck := func(want string, got string) (bool, error) { - if token.Spec.Spacelift.EnableGlobMatching { - return joinutils.GlobMatchAllowEmptyPattern(want, got) - } - if want == "" { - return true, nil - } - return want == got, nil - } - - // If a single rule passes, accept the IDToken - for i, rule := range token.Spec.Spacelift.Allow { - // Please consider keeping these field validators in the same order they - // are defined within the ProvisionTokenSpecV2Spacelift proto spec. - spaceIDMatch, err := globCheck(rule.SpaceID, claims.SpaceID) - if err != nil { - return trace.Wrap(err, "evaluating rule (%d) space_id glob match", i) - } - if !spaceIDMatch { - continue - } - callerIDMatch, err := globCheck(rule.CallerID, claims.CallerID) - if err != nil { - return trace.Wrap(err, "evaluating rule (%d) caller_id glob match", i) - } - if !callerIDMatch { - continue - } - if rule.CallerType != "" && claims.CallerType != rule.CallerType { - continue - } - if rule.Scope != "" && claims.Scope != rule.Scope { - continue - } - - // All provided rules met. - return nil - } - - return trace.AccessDenied("id token claims did not match any allow rules") + // Attempt to return any claims along with the error, used to improve audit + // logging on failed join attempts. + return claims, trace.Wrap(err) } diff --git a/lib/auth/join_spacelift_test.go b/lib/join/join_spacelift_test.go similarity index 82% rename from lib/auth/join_spacelift_test.go rename to lib/join/join_spacelift_test.go index 94a702e7544..16a721cf9b0 100644 --- a/lib/auth/join_spacelift_test.go +++ b/lib/join/join_spacelift_test.go @@ -16,29 +16,28 @@ * along with this program. If not, see . */ -package auth_test +package join_test import ( "context" - "errors" "fmt" "testing" "time" "github.com/gravitational/trace" + "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "github.com/gravitational/teleport/api/types" - "github.com/gravitational/teleport/lib/auth" "github.com/gravitational/teleport/lib/auth/authtest" + "github.com/gravitational/teleport/lib/auth/state" "github.com/gravitational/teleport/lib/auth/testauthority" + "github.com/gravitational/teleport/lib/join/joinclient" + "github.com/gravitational/teleport/lib/join/spacelift" "github.com/gravitational/teleport/lib/modules" "github.com/gravitational/teleport/lib/modules/modulestest" - "github.com/gravitational/teleport/lib/spacelift" ) -var errMockInvalidToken = errors.New("invalid token") - type mockSpaceliftTokenValidator struct { tokens map[string]spacelift.IDTokenClaims } @@ -58,7 +57,7 @@ func (m *mockSpaceliftTokenValidator) Validate( return &claims, nil } -func TestAuth_RegisterUsingToken_Spacelift(t *testing.T) { +func TestJoinSpacelift(t *testing.T) { validIDToken := "test.fake.jwt" idTokenValidator := &mockSpaceliftTokenValidator{ tokens: map[string]spacelift.IDTokenClaims{ @@ -73,18 +72,19 @@ func TestAuth_RegisterUsingToken_Spacelift(t *testing.T) { }, }, } - var withTokenValidator auth.ServerOption = func(server *auth.Server) error { - server.SetSpaceliftIDTokenValidator(idTokenValidator) - return nil - } ctx := t.Context() - p, err := newTestPack(ctx, testPackOptions{ - DataDir: t.TempDir(), - MutateAuth: withTokenValidator, + + authServer, err := authtest.NewTestServer(authtest.ServerConfig{ + Auth: authtest.AuthServerConfig{ + Dir: t.TempDir(), + }, }) require.NoError(t, err) - auth := p.a + t.Cleanup(func() { assert.NoError(t, authServer.Shutdown(t.Context())) }) + auth := authServer.Auth() + + auth.SetSpaceliftIDTokenValidator(idTokenValidator) // helper for creating RegisterUsingTokenRequest sshPrivateKey, sshPublicKey, err := testauthority.New().GenerateKeyPair() @@ -142,7 +142,7 @@ func TestAuth_RegisterUsingToken_Spacelift(t *testing.T) { assertError: require.NoError, }, { - name: "success with glob", + name: "success-with-glob", setEnterprise: true, tokenSpec: types.ProvisionTokenSpecV2{ JoinMethod: types.JoinMethodSpacelift, @@ -162,7 +162,7 @@ func TestAuth_RegisterUsingToken_Spacelift(t *testing.T) { assertError: require.NoError, }, { - name: "fail with glob", + name: "fail-with-glob", setEnterprise: true, tokenSpec: types.ProvisionTokenSpecV2{ JoinMethod: types.JoinMethodSpacelift, @@ -181,7 +181,7 @@ func TestAuth_RegisterUsingToken_Spacelift(t *testing.T) { assertError: allowRulesNotMatched, }, { - name: "fail with disabled glob", + name: "fail-with-disabled-glob", setEnterprise: true, tokenSpec: types.ProvisionTokenSpecV2{ JoinMethod: types.JoinMethodSpacelift, @@ -201,7 +201,7 @@ func TestAuth_RegisterUsingToken_Spacelift(t *testing.T) { assertError: allowRulesNotMatched, }, { - name: "missing enterprise", + name: "missing-enterprise", setEnterprise: false, tokenSpec: types.ProvisionTokenSpecV2{ JoinMethod: types.JoinMethodSpacelift, @@ -219,7 +219,7 @@ func TestAuth_RegisterUsingToken_Spacelift(t *testing.T) { }, }, { - name: "multiple allow rules", + name: "multiple-allow-rules", setEnterprise: true, tokenSpec: types.ProvisionTokenSpecV2{ JoinMethod: types.JoinMethodSpacelift, @@ -238,7 +238,7 @@ func TestAuth_RegisterUsingToken_Spacelift(t *testing.T) { assertError: require.NoError, }, { - name: "incorrect space_id", + name: "incorrect-space_id", setEnterprise: true, tokenSpec: types.ProvisionTokenSpecV2{ JoinMethod: types.JoinMethodSpacelift, @@ -256,7 +256,7 @@ func TestAuth_RegisterUsingToken_Spacelift(t *testing.T) { assertError: allowRulesNotMatched, }, { - name: "incorrect caller_id", + name: "incorrect-caller_id", setEnterprise: true, tokenSpec: types.ProvisionTokenSpecV2{ JoinMethod: types.JoinMethodSpacelift, @@ -274,7 +274,7 @@ func TestAuth_RegisterUsingToken_Spacelift(t *testing.T) { assertError: allowRulesNotMatched, }, { - name: "incorrect caller_type", + name: "incorrect-caller_type", setEnterprise: true, tokenSpec: types.ProvisionTokenSpecV2{ JoinMethod: types.JoinMethodSpacelift, @@ -292,7 +292,7 @@ func TestAuth_RegisterUsingToken_Spacelift(t *testing.T) { assertError: allowRulesNotMatched, }, { - name: "incorrect scope", + name: "incorrect-scope", setEnterprise: true, tokenSpec: types.ProvisionTokenSpecV2{ JoinMethod: types.JoinMethodSpacelift, @@ -310,7 +310,7 @@ func TestAuth_RegisterUsingToken_Spacelift(t *testing.T) { assertError: allowRulesNotMatched, }, { - name: "invalid token", + name: "invalid-token", setEnterprise: true, tokenSpec: types.ProvisionTokenSpecV2{ JoinMethod: types.JoinMethodSpacelift, @@ -324,7 +324,7 @@ func TestAuth_RegisterUsingToken_Spacelift(t *testing.T) { }, request: newRequest("some other token"), assertError: func(t require.TestingT, err error, i ...any) { - require.ErrorIs(t, err, errMockInvalidToken) + require.ErrorContains(t, err, "invalid token") }, }, } @@ -344,8 +344,42 @@ func TestAuth_RegisterUsingToken_Spacelift(t *testing.T) { require.NoError(t, auth.CreateToken(ctx, token)) tt.request.Token = tt.name - _, err = auth.RegisterUsingToken(ctx, tt.request) - tt.assertError(t, err) + nopClient, err := authServer.NewClient(authtest.TestNop()) + require.NoError(t, err) + + t.Run("legacy", func(t *testing.T) { + _, err = auth.RegisterUsingToken(ctx, tt.request) + tt.assertError(t, err) + }) + + t.Run("legacy joinclient", func(t *testing.T) { + _, err := joinclient.LegacyJoin(t.Context(), joinclient.JoinParams{ + Token: tt.request.Token, + JoinMethod: types.JoinMethodSpacelift, + ID: state.IdentityID{ + Role: tt.request.Role, + NodeName: "testnode", + HostUUID: tt.request.HostID, + }, + IDToken: tt.request.IDToken, + AuthClient: nopClient, + }) + tt.assertError(t, err) + }) + + t.Run("new joinclient", func(t *testing.T) { + _, err := joinclient.Join(t.Context(), joinclient.JoinParams{ + Token: tt.request.Token, + JoinMethod: types.JoinMethodSpacelift, + ID: state.IdentityID{ + Role: types.RoleInstance, // RoleNode is not allowed + NodeName: "testnode", + }, + IDToken: tt.request.IDToken, + AuthClient: nopClient, + }) + tt.assertError(t, err) + }) }) } } diff --git a/lib/join/joinclient/join.go b/lib/join/joinclient/join.go index 5c7de72180f..6c1020bfa47 100644 --- a/lib/join/joinclient/join.go +++ b/lib/join/joinclient/join.go @@ -42,6 +42,7 @@ import ( "github.com/gravitational/teleport/lib/join/gitlab" "github.com/gravitational/teleport/lib/join/internal/messages" "github.com/gravitational/teleport/lib/join/joinv1" + "github.com/gravitational/teleport/lib/join/spacelift" "github.com/gravitational/teleport/lib/join/terraformcloud" "github.com/gravitational/teleport/lib/utils/hostid" ) @@ -213,6 +214,7 @@ func joinWithClient(ctx context.Context, params JoinParams, client *joinv1.Clien types.JoinMethodGitLab, types.JoinMethodIAM, types.JoinMethodOracle, + types.JoinMethodSpacelift, types.JoinMethodTPM, types.JoinMethodTerraformCloud: joinMethod := string(params.JoinMethod) @@ -371,6 +373,15 @@ func joinWithMethod( } } + return oidcJoin(stream, joinParams, clientParams) + case types.JoinMethodSpacelift: + if joinParams.IDToken == "" { + joinParams.IDToken, err = spacelift.NewIDTokenSource(os.Getenv).GetIDToken() + if err != nil { + return nil, trace.Wrap(err) + } + } + return oidcJoin(stream, joinParams, clientParams) case types.JoinMethodTPM: return tpmJoin(ctx, stream, joinParams, clientParams) diff --git a/lib/join/server.go b/lib/join/server.go index e31306d1813..c2809d14be9 100644 --- a/lib/join/server.go +++ b/lib/join/server.go @@ -58,6 +58,7 @@ import ( "github.com/gravitational/teleport/lib/join/joinutils" "github.com/gravitational/teleport/lib/join/oraclejoin" "github.com/gravitational/teleport/lib/join/provision" + "github.com/gravitational/teleport/lib/join/spacelift" "github.com/gravitational/teleport/lib/join/terraformcloud" "github.com/gravitational/teleport/lib/join/tpmjoin" "github.com/gravitational/teleport/lib/scopes/joining" @@ -97,6 +98,7 @@ type AuthService interface { GetGHAIDTokenJWKSValidator() githubactions.GithubIDTokenJWKSValidator GetGitlabIDTokenValidator() gitlab.Validator GetTPMValidator() tpmjoin.TPMValidator + GetSpaceliftIDTokenValidator() spacelift.Validator GetTerraformIDTokenValidator() terraformcloud.Validator services.Presence } @@ -314,6 +316,8 @@ func (s *Server) handleJoinMethod( return s.handleOIDCJoin(stream, authCtx, clientInit, token, s.validateGithubToken) case types.JoinMethodGitLab: return s.handleOIDCJoin(stream, authCtx, clientInit, token, s.validateGitlabToken) + case types.JoinMethodSpacelift: + return s.handleOIDCJoin(stream, authCtx, clientInit, token, s.validateSpaceliftToken) case types.JoinMethodTPM: return s.handleTPMJoin(stream, authCtx, clientInit, token) case types.JoinMethodTerraformCloud: diff --git a/lib/join/server_spacelift.go b/lib/join/server_spacelift.go new file mode 100644 index 00000000000..628a195af14 --- /dev/null +++ b/lib/join/server_spacelift.go @@ -0,0 +1,53 @@ +/* + * Teleport + * Copyright (C) 2025 Gravitational, Inc. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +package join + +import ( + "context" + + "github.com/gravitational/trace" + + workloadidentityv1 "github.com/gravitational/teleport/api/gen/proto/go/teleport/workloadidentity/v1" + "github.com/gravitational/teleport/lib/join/provision" + "github.com/gravitational/teleport/lib/join/spacelift" +) + +func (a *Server) validateSpaceliftToken( + ctx context.Context, + pt provision.Token, + idToken []byte, +) (any, *workloadidentityv1.JoinAttrs, error) { + claims, err := spacelift.CheckIDToken(ctx, &spacelift.CheckIDTokenParams{ + ProvisionToken: pt, + IDToken: idToken, + Validator: a.cfg.AuthService.GetSpaceliftIDTokenValidator(), + }) + + // If possible, attach claims and workload ID attrs regardless of the error + // return. If the token fails to validate, these claims will ensure audit + // events remain useful. + var workloadIDAttrs *workloadidentityv1.JoinAttrs + if claims != nil { + workloadIDAttrs = &workloadidentityv1.JoinAttrs{ + Spacelift: claims.JoinAttrs(), + } + } + + return claims, workloadIDAttrs, trace.Wrap(err) +} diff --git a/lib/join/spacelift/spacelift.go b/lib/join/spacelift/spacelift.go new file mode 100644 index 00000000000..5d22c54c778 --- /dev/null +++ b/lib/join/spacelift/spacelift.go @@ -0,0 +1,187 @@ +/* + * Teleport + * Copyright (C) 2023 Gravitational, Inc. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +package spacelift + +import ( + "context" + + "github.com/gravitational/trace" + "github.com/zitadel/oidc/v3/pkg/oidc" + + "github.com/gravitational/teleport" + workloadidentityv1pb "github.com/gravitational/teleport/api/gen/proto/go/teleport/workloadidentity/v1" + "github.com/gravitational/teleport/api/types" + "github.com/gravitational/teleport/lib/join/joinutils" + "github.com/gravitational/teleport/lib/join/provision" + "github.com/gravitational/teleport/lib/modules" + "github.com/gravitational/teleport/lib/services" + logutils "github.com/gravitational/teleport/lib/utils/log" +) + +var log = logutils.NewPackageLogger(teleport.ComponentKey, "spacelift") + +type Validator interface { + Validate( + ctx context.Context, domain string, token string, + ) (*IDTokenClaims, error) +} + +// IDTokenClaims +// See the following for the structure: +// https://docs.spacelift.io/integrations/cloud-providers/oidc/#standard-claims +type IDTokenClaims struct { + oidc.TokenClaims + + // Sub provides some information about the Spacelift run that generated this + // token. + // space::(stack|module)::run_type::scope: + Sub string `json:"sub"` + // SpaceID is the ID of the space in which the run that owns the token was + // executed. + SpaceID string `json:"spaceId"` + // CallerType is the type of the caller, ie. the entity that owns the run - + // either stack or module. + CallerType string `json:"callerType"` + // CallerID is the ID of the caller, ie. the stack or module that generated + // the run. + CallerID string `json:"callerId"` + // RunType is the type of the run. + // (PROPOSED, TRACKED, TASK, TESTING or DESTROY) + RunType string `json:"runType"` + // RunID is the ID of the run that owns the token. + RunID string `json:"runId"` + // Scope is the scope of the token - either read or write. + Scope string `json:"scope"` +} + +func (c *IDTokenClaims) GetSubject() string { + return c.Sub +} + +// JoinAttrs returns the protobuf representation of the attested identity. +// This is used for auditing and for evaluation of WorkloadIdentity rules and +// templating. +func (c *IDTokenClaims) JoinAttrs() *workloadidentityv1pb.JoinAttrsSpacelift { + return &workloadidentityv1pb.JoinAttrsSpacelift{ + Sub: c.Sub, + SpaceId: c.SpaceID, + CallerType: c.CallerType, + CallerId: c.CallerID, + RunType: c.RunType, + RunId: c.RunID, + Scope: c.Scope, + } +} + +// CheckIDTokenParams are parameters used to validate Spacelift OIDC tokens. +type CheckIDTokenParams struct { + ProvisionToken provision.Token + IDToken []byte + Validator Validator +} + +func (p *CheckIDTokenParams) validate() error { + switch { + case p.ProvisionToken == nil: + return trace.BadParameter("ProvisionToken is required") + case len(p.IDToken) == 0: + return trace.BadParameter("IDToken is required") + case p.Validator == nil: + return trace.BadParameter("Validator is required") + } + return nil +} + +// CheckIDToken validates a Spacelift OIDC token, verifying both the validity of +// the OIDC token itself, as well as ensuring claims match any configured allow +// rules in the provided provision token. +func CheckIDToken( + ctx context.Context, + params *CheckIDTokenParams, +) (*IDTokenClaims, error) { + if err := params.validate(); err != nil { + return nil, trace.Wrap(err) + } + + token, ok := params.ProvisionToken.(*types.ProvisionTokenV2) + if !ok { + return nil, trace.BadParameter("spacelift join method only supports ProvisionTokenV2, '%T' was provided", params.ProvisionToken) + } + + if modules.GetModules().BuildType() != modules.BuildEnterprise { + return nil, trace.Wrap(services.ErrRequiresEnterprise, "spacelift joining") + } + + claims, err := params.Validator.Validate( + ctx, token.Spec.Spacelift.Hostname, string(params.IDToken), + ) + if err != nil { + return nil, trace.Wrap(err) + } + + log.InfoContext(ctx, "Spacelift run trying to join cluster", + "claims", claims, + "token", token.GetName(), + ) + + return claims, trace.Wrap(checkSpaceliftAllowRules(token, claims)) +} + +func checkSpaceliftAllowRules(token *types.ProvisionTokenV2, claims *IDTokenClaims) error { + globCheck := func(want string, got string) (bool, error) { + if token.Spec.Spacelift.EnableGlobMatching { + return joinutils.GlobMatchAllowEmptyPattern(want, got) + } + if want == "" { + return true, nil + } + return want == got, nil + } + + // If a single rule passes, accept the IDToken + for i, rule := range token.Spec.Spacelift.Allow { + // Please consider keeping these field validators in the same order they + // are defined within the ProvisionTokenSpecV2Spacelift proto spec. + spaceIDMatch, err := globCheck(rule.SpaceID, claims.SpaceID) + if err != nil { + return trace.Wrap(err, "evaluating rule (%d) space_id glob match", i) + } + if !spaceIDMatch { + continue + } + callerIDMatch, err := globCheck(rule.CallerID, claims.CallerID) + if err != nil { + return trace.Wrap(err, "evaluating rule (%d) caller_id glob match", i) + } + if !callerIDMatch { + continue + } + if rule.CallerType != "" && claims.CallerType != rule.CallerType { + continue + } + if rule.Scope != "" && claims.Scope != rule.Scope { + continue + } + + // All provided rules met. + return nil + } + + return trace.AccessDenied("id token claims did not match any allow rules") +} diff --git a/lib/spacelift/token_source.go b/lib/join/spacelift/token_source.go similarity index 100% rename from lib/spacelift/token_source.go rename to lib/join/spacelift/token_source.go diff --git a/lib/spacelift/token_source_test.go b/lib/join/spacelift/token_source_test.go similarity index 100% rename from lib/spacelift/token_source_test.go rename to lib/join/spacelift/token_source_test.go diff --git a/lib/spacelift/token_validator.go b/lib/join/spacelift/token_validator.go similarity index 100% rename from lib/spacelift/token_validator.go rename to lib/join/spacelift/token_validator.go diff --git a/lib/spacelift/token_validator_test.go b/lib/join/spacelift/token_validator_test.go similarity index 100% rename from lib/spacelift/token_validator_test.go rename to lib/join/spacelift/token_validator_test.go diff --git a/lib/spacelift/spacelift.go b/lib/spacelift/spacelift.go deleted file mode 100644 index a0ca76304f6..00000000000 --- a/lib/spacelift/spacelift.go +++ /dev/null @@ -1,72 +0,0 @@ -/* - * Teleport - * Copyright (C) 2023 Gravitational, Inc. - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License - * along with this program. If not, see . - */ - -package spacelift - -import ( - "github.com/zitadel/oidc/v3/pkg/oidc" - - workloadidentityv1pb "github.com/gravitational/teleport/api/gen/proto/go/teleport/workloadidentity/v1" -) - -// IDTokenClaims -// See the following for the structure: -// https://docs.spacelift.io/integrations/cloud-providers/oidc/#standard-claims -type IDTokenClaims struct { - oidc.TokenClaims - - // Sub provides some information about the Spacelift run that generated this - // token. - // space::(stack|module)::run_type::scope: - Sub string `json:"sub"` - // SpaceID is the ID of the space in which the run that owns the token was - // executed. - SpaceID string `json:"spaceId"` - // CallerType is the type of the caller, ie. the entity that owns the run - - // either stack or module. - CallerType string `json:"callerType"` - // CallerID is the ID of the caller, ie. the stack or module that generated - // the run. - CallerID string `json:"callerId"` - // RunType is the type of the run. - // (PROPOSED, TRACKED, TASK, TESTING or DESTROY) - RunType string `json:"runType"` - // RunID is the ID of the run that owns the token. - RunID string `json:"runId"` - // Scope is the scope of the token - either read or write. - Scope string `json:"scope"` -} - -func (c *IDTokenClaims) GetSubject() string { - return c.Sub -} - -// JoinAttrs returns the protobuf representation of the attested identity. -// This is used for auditing and for evaluation of WorkloadIdentity rules and -// templating. -func (c *IDTokenClaims) JoinAttrs() *workloadidentityv1pb.JoinAttrsSpacelift { - return &workloadidentityv1pb.JoinAttrsSpacelift{ - Sub: c.Sub, - SpaceId: c.SpaceID, - CallerType: c.CallerType, - CallerId: c.CallerID, - RunType: c.RunType, - RunId: c.RunID, - Scope: c.Scope, - } -} diff --git a/lib/utils/hostid/hostid.go b/lib/utils/hostid/hostid.go index e3fe6c88c06..ee941038ab7 100644 --- a/lib/utils/hostid/hostid.go +++ b/lib/utils/hostid/hostid.go @@ -74,6 +74,7 @@ func Generate(ctx context.Context, joinMethod types.JoinMethod) (string, error) types.JoinMethodAzure, types.JoinMethodGCP, types.JoinMethodTPM, + types.JoinMethodSpacelift, types.JoinMethodTerraformCloud, types.JoinMethodOracle, types.JoinMethodEnv0: