From 05de19a490c8c21dc7bd2e1ae558113d459f7024 Mon Sep 17 00:00:00 2001 From: Nick Marais Date: Wed, 23 Apr 2025 16:15:51 +0100 Subject: [PATCH 1/9] Return github-specific config from `GET /webapi/tokens` --- api/types/provisioning.go | 7 ++++ lib/web/join_tokens_test.go | 81 +++++++++++++++++++++++++++++++++++++ lib/web/ui/join_token.go | 7 ++++ 3 files changed, 95 insertions(+) diff --git a/api/types/provisioning.go b/api/types/provisioning.go index 9768e25ab863e..32589bc591b07 100644 --- a/api/types/provisioning.go +++ b/api/types/provisioning.go @@ -135,6 +135,8 @@ type ProvisionToken interface { SetAllowRules([]*TokenRule) // GetGCPRules will return the GCP rules within this token. GetGCPRules() *ProvisionTokenSpecV2GCP + // GetGithubRules will return the GitHub rules within this token. + GetGithubRules() *ProvisionTokenSpecV2GitHub // GetAWSIIDTTL returns the TTL of EC2 IIDs GetAWSIIDTTL() Duration // GetJoinMethod returns joining method that must be used with this token. @@ -437,6 +439,11 @@ func (p *ProvisionTokenV2) GetGCPRules() *ProvisionTokenSpecV2GCP { return p.Spec.GCP } +// GetGithubRules will return the GitHub rules within this token. +func (p *ProvisionTokenV2) GetGithubRules() *ProvisionTokenSpecV2GitHub { + return p.Spec.GitHub +} + // GetAWSIIDTTL returns the TTL of EC2 IIDs func (p *ProvisionTokenV2) GetAWSIIDTTL() Duration { return p.Spec.AWSIIDTTL diff --git a/lib/web/join_tokens_test.go b/lib/web/join_tokens_test.go index d47e2c1b137f3..8dbbd72bc4133 100644 --- a/lib/web/join_tokens_test.go +++ b/lib/web/join_tokens_test.go @@ -27,6 +27,7 @@ import ( "net/http" "net/url" "regexp" + "slices" "strconv" "testing" "time" @@ -268,6 +269,86 @@ func TestGetTokens(t *testing.T) { } } +func TestGetGithubTokens(t *testing.T) { + t.Parallel() + ctx := context.Background() + + username := "test-user@example.com" + expiry := time.Now().UTC().Add(30 * time.Minute) + + env := newWebPack(t, 1) + proxy := env.proxies[0] + pack := proxy.authPack(t, username, nil /* roles */) + + td := tokenData{ + name: "github-test-token", + expiry: expiry, + roles: types.SystemRoles{types.RoleBot}, + } + + token, err := types.NewProvisionTokenFromSpec(td.name, td.expiry, types.ProvisionTokenSpecV2{ + Roles: td.roles, + BotName: "test-bot", + JoinMethod: types.JoinMethodGitHub, + + GitHub: &types.ProvisionTokenSpecV2GitHub{ + EnterpriseServerHost: "github.example.com", + StaticJWKS: "{\"keys\":[]}", + Allow: []*types.ProvisionTokenSpecV2GitHub_Rule{ + { + Repository: "gravitational/teleport", + RepositoryOwner: "gravitational", + Sub: "test-sub", + Workflow: "test-workflow", + Environment: "test-environment", + Actor: "octocat", + Ref: "ref/heads/main", + RefType: "branch", + }, + }, + }, + }) + require.NoError(t, err) + err = env.server.Auth().CreateToken(ctx, token) + require.NoError(t, err) + + endpoint := pack.clt.Endpoint("webapi", "tokens") + re, err := pack.clt.Get(ctx, endpoint, url.Values{}) + require.NoError(t, err) + + resp := GetTokensResponse{} + require.NoError(t, json.Unmarshal(re.Bytes(), &resp)) + + require.Len(t, resp.Items, 2) // Including a static token + + githubTokenIndex := slices.IndexFunc(resp.Items, func(item ui.JoinToken) bool { return item.Method == types.JoinMethodGitHub }) + require.NotEqual(t, githubTokenIndex, -1) + require.Empty(t, cmp.Diff(resp.Items[githubTokenIndex], ui.JoinToken{ + ID: "github-test-token", + SafeName: "github-test-token", + BotName: "test-bot", + Expiry: expiry, + Roles: types.SystemRoles{"Bot"}, + Method: types.JoinMethodGitHub, + Github: &types.ProvisionTokenSpecV2GitHub{ + EnterpriseServerHost: "github.example.com", + StaticJWKS: "{\"keys\":[]}", + Allow: []*types.ProvisionTokenSpecV2GitHub_Rule{ + { + Repository: "gravitational/teleport", + RepositoryOwner: "gravitational", + Sub: "test-sub", + Workflow: "test-workflow", + Environment: "test-environment", + Actor: "octocat", + Ref: "ref/heads/main", + RefType: "branch", + }, + }, + }, + }, cmpopts.IgnoreFields(ui.JoinToken{}, "Content"))) +} + func TestDeleteToken(t *testing.T) { ctx := context.Background() username := "test-user@example.com" diff --git a/lib/web/ui/join_token.go b/lib/web/ui/join_token.go index be068482aa1ba..2319841375e22 100644 --- a/lib/web/ui/join_token.go +++ b/lib/web/ui/join_token.go @@ -47,6 +47,8 @@ type JoinToken struct { Allow []*types.TokenRule `json:"allow,omitempty"` // GCP allows the configuration of options specific to the "gcp" join method. GCP *types.ProvisionTokenSpecV2GCP `json:"gcp,omitempty"` + // GitHub-specific configuration for the join method. + Github *types.ProvisionTokenSpecV2GitHub `json:"github,omitempty"` // Content is resource yaml content. Content string `json:"content"` } @@ -71,6 +73,11 @@ func MakeJoinToken(token types.ProvisionToken) (*JoinToken, error) { if uiToken.Method == types.JoinMethodGCP { uiToken.GCP = token.GetGCPRules() } + + if uiToken.Method == types.JoinMethodGitHub { + uiToken.Github = token.GetGithubRules() + } + return uiToken, nil } From 0943a83b19c16640fe9ff7c58b2569c79fbbea1c Mon Sep 17 00:00:00 2001 From: Nick Marais Date: Fri, 25 Apr 2025 10:27:00 +0100 Subject: [PATCH 2/9] Support "token" in `/webapi/yaml/parse/:kind` --- lib/web/yaml.go | 24 ++++++++++ lib/web/yaml_test.go | 107 ++++++++++++++++++++++++++++++++++++------- 2 files changed, 115 insertions(+), 16 deletions(-) diff --git a/lib/web/yaml.go b/lib/web/yaml.go index ac0c49ef23770..db766099e608e 100644 --- a/lib/web/yaml.go +++ b/lib/web/yaml.go @@ -71,6 +71,14 @@ func (h *Handler) yamlParse(w http.ResponseWriter, r *http.Request, params httpr return yamlParseResponse{Resource: resource}, nil + case types.KindToken: + resource, err := yamlToProvisionToken(req.YAML) + if err != nil { + return nil, trace.Wrap(err) + } + + return yamlParseResponse{Resource: resource}, nil + default: return nil, trace.NotImplemented("parsing YAML for kind %q is not supported", kind) } @@ -148,3 +156,19 @@ func yamlToRole(yaml string) (types.Role, error) { return resource, nil } + +func yamlToProvisionToken(yaml string) (types.ProvisionToken, error) { + extractedRes, err := extractResource(yaml) + if err != nil { + return nil, trace.Wrap(err) + } + if extractedRes.Kind != types.KindToken { + return nil, trace.BadParameter("resource kind %q is invalid, only token is allowed", extractedRes.Kind) + } + resource, err := services.UnmarshalProvisionToken(extractedRes.Raw) + if err != nil { + return nil, trace.Wrap(err) + } + + return resource, nil +} diff --git a/lib/web/yaml_test.go b/lib/web/yaml_test.go index 6399ed8082ea3..8b5cc980da6db 100644 --- a/lib/web/yaml_test.go +++ b/lib/web/yaml_test.go @@ -23,6 +23,7 @@ import ( "encoding/json" "testing" + "github.com/google/go-cmp/cmp" "github.com/stretchr/testify/require" accessmonitoringrulesv1 "github.com/gravitational/teleport/api/gen/proto/go/teleport/accessmonitoringrules/v1" @@ -44,6 +45,28 @@ spec: version: v1 ` +const validTokenYaml = `kind: token +metadata: + name: test-name +spec: + github: + enterprise_server_host: test-server-host + static_jwks: test-twks + allow: + - actor: test-actor + environment: test-environment + ref: test-ref + ref_type: test-ref-type + repository: test-repository + repository_owner: test-owner + workflow: test-workflow + sub: test-sub + join_method: github + roles: + - Node +version: v2 +` + func getAccessMonitoringRuleResource() *accessmonitoringrulesv1.AccessMonitoringRule { return &accessmonitoringrulesv1.AccessMonitoringRule{ Kind: types.KindAccessMonitoringRule, @@ -62,6 +85,36 @@ func getAccessMonitoringRuleResource() *accessmonitoringrulesv1.AccessMonitoring } } +func getTokenResource() *types.ProvisionTokenV2 { + return &types.ProvisionTokenV2{ + Kind: types.KindToken, + Version: types.V2, + Metadata: types.Metadata{ + Name: "test-name", + }, + Spec: types.ProvisionTokenSpecV2{ + Roles: []types.SystemRole{types.RoleNode}, + JoinMethod: types.JoinMethodGitHub, + GitHub: &types.ProvisionTokenSpecV2GitHub{ + EnterpriseServerHost: "test-server-host", + StaticJWKS: "test-twks", + Allow: []*types.ProvisionTokenSpecV2GitHub_Rule{ + { + Sub: "test-sub", + Repository: "test-repository", + RepositoryOwner: "test-owner", + Workflow: "test-workflow", + Environment: "test-environment", + Actor: "test-actor", + Ref: "test-ref", + RefType: "test-ref-type", + }, + }, + }, + }, + } +} + func TestYAMLParse_Valid(t *testing.T) { t.Parallel() @@ -69,26 +122,48 @@ func TestYAMLParse_Valid(t *testing.T) { proxy := env.proxies[0] pack := proxy.authPack(t, "test@example.com", nil) - endpoint := pack.clt.Endpoint("webapi", "yaml", "parse", types.KindAccessMonitoringRule) - re, err := pack.clt.PostJSON(context.Background(), endpoint, yamlParseRequest{ - YAML: validAccessMonitoringRuleYaml, - }) - require.NoError(t, err) + testCases := []struct { + name string + kind string + yaml string + expected any + }{ + { + name: "AccessMonitoringRule", + kind: types.KindAccessMonitoringRule, + yaml: validAccessMonitoringRuleYaml, + expected: getAccessMonitoringRuleResource(), + }, + { + name: "Token", + kind: types.KindToken, + yaml: validTokenYaml, + expected: getTokenResource(), + }, + } - var endpointResp yamlParseResponse - require.NoError(t, json.Unmarshal(re.Bytes(), &endpointResp)) + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + endpoint := pack.clt.Endpoint("webapi", "yaml", "parse", tc.kind) + re, err := pack.clt.PostJSON(context.Background(), endpoint, yamlParseRequest{ + YAML: tc.yaml, + }) + require.NoError(t, err) - expectedResource := getAccessMonitoringRuleResource() + var endpointResp yamlParseResponse + require.NoError(t, json.Unmarshal(re.Bytes(), &endpointResp)) - // Can't cast a unmarshaled interface{} into the expected type, so - // we are transforming the expected type to the same type as the - // one we got as a response. - b, err := json.Marshal(yamlParseResponse{Resource: expectedResource}) - require.NoError(t, err) - var expectedResp yamlParseResponse - require.NoError(t, json.Unmarshal(b, &expectedResp)) + // Can't cast a unmarshaled interface{} into the expected type, so + // we are transforming the expected type to the same type as the + // one we got as a response. + b, err := json.Marshal(yamlParseResponse{Resource: tc.expected}) + require.NoError(t, err) + var expectedResp yamlParseResponse + require.NoError(t, json.Unmarshal(b, &expectedResp)) - require.Equal(t, expectedResp.Resource, endpointResp.Resource) + require.Empty(t, cmp.Diff(expectedResp.Resource, endpointResp.Resource)) + }) + } } func TestYAMLParse_Errors(t *testing.T) { From 413cde5489a08ee3f0c17fd3ac9a10a064bc43fc Mon Sep 17 00:00:00 2001 From: Nick Marais Date: Mon, 28 Apr 2025 15:05:36 +0100 Subject: [PATCH 3/9] Use empty time.Time for token expiry (`POST /webapi/tokens`) --- lib/web/join_tokens.go | 48 ++++++---- lib/web/join_tokens_test.go | 180 ++++++++++++++++++++++++++++++++++++ 2 files changed, 211 insertions(+), 17 deletions(-) diff --git a/lib/web/join_tokens.go b/lib/web/join_tokens.go index b517b1f9fda30..e7c852981537c 100644 --- a/lib/web/join_tokens.go +++ b/lib/web/join_tokens.go @@ -184,29 +184,43 @@ type upsertTokenHandleRequest struct { Name string `json:"name"` } -func (h *Handler) upsertTokenHandle(w http.ResponseWriter, r *http.Request, params httprouter.Params, ctx *SessionContext) (interface{}, error) { - // if using the PUT route, tokenId will be present - // in the X-Teleport-TokenName header - editing := r.Method == "PUT" - tokenId := r.Header.Get(HeaderTokenName) - if editing && tokenId == "" { - return nil, trace.BadParameter("requires a token name to edit") - } - +func (h *Handler) upsertTokenHandle(w http.ResponseWriter, r *http.Request, params httprouter.Params, ctx *SessionContext) (any, error) { var req upsertTokenHandleRequest if err := httplib.ReadResourceJSON(r, &req); err != nil { return nil, trace.Wrap(err) } - if editing && tokenId != req.Name { - return nil, trace.BadParameter("renaming tokens is not supported") + var tokenId string + if r.Method == "PUT" { + // if using the PUT route, tokenId will be present + // in the X-Teleport-TokenName header + tokenId = r.Header.Get(HeaderTokenName) + + if tokenId != "" && tokenId != req.Name { + return nil, trace.BadParameter("renaming tokens is not supported") + } + } else { + tokenId = req.Name + } + + clt, err := ctx.GetClient() + if err != nil { + return nil, trace.Wrap(err) + } + + var existingToken types.ProvisionToken + if tokenId != "" { + existingToken, err = clt.GetToken(r.Context(), tokenId) + if err != nil && !trace.IsNotFound(err) { + return nil, trace.Wrap(err) + } } var expires time.Time switch req.JoinMethod { - case types.JoinMethodGCP, types.JoinMethodIAM, types.JoinMethodOracle: - // IAM, GCP, and Oracle tokens should never expire. - expires = time.Now().UTC().AddDate(1000, 0, 0) + case types.JoinMethodGCP, types.JoinMethodIAM, types.JoinMethodOracle, types.JoinMethodGitHub: + // IAM, GCP, Oracle and GitHub tokens should never expire. + expires = time.Time{} default: // Set expires time to default node join token TTL. expires = time.Now().UTC().Add(defaults.NodeJoinTokenTTL) @@ -226,9 +240,9 @@ func (h *Handler) upsertTokenHandle(w http.ResponseWriter, r *http.Request, para return nil, trace.Wrap(err) } - clt, err := ctx.GetClient() - if err != nil { - return nil, trace.Wrap(err) + // If this is an edit, then overwrite the metadata to retain the existing fields + if existingToken != nil { + token.SetMetadata(existingToken.GetMetadata()) } err = clt.UpsertToken(r.Context(), token) diff --git a/lib/web/join_tokens_test.go b/lib/web/join_tokens_test.go index 8dbbd72bc4133..457c4a34feaf8 100644 --- a/lib/web/join_tokens_test.go +++ b/lib/web/join_tokens_test.go @@ -417,6 +417,186 @@ func TestDeleteToken(t *testing.T) { require.Empty(t, cmp.Diff(resp.Items, []ui.JoinToken{staticUIToken}, cmpopts.IgnoreFields(ui.JoinToken{}, "Content"))) } +func TestEditToken(t *testing.T) { + t.Parallel() + ctx := context.Background() + username := "test-user@example.com" + env := newWebPack(t, 1) + proxy := env.proxies[0] + pack := proxy.authPack(t, username, nil /* roles */) + + // Setup an existing token + spec := types.ProvisionTokenSpecV2{ + Roles: types.SystemRoles{types.RoleBot}, + BotName: "test-bot", + JoinMethod: types.JoinMethodGitHub, + + GitHub: &types.ProvisionTokenSpecV2GitHub{ + Allow: []*types.ProvisionTokenSpecV2GitHub_Rule{ + { + Repository: "gravitational/teleport", + }, + }, + }, + } + token, err := types.NewProvisionTokenFromSpec("github-test-token", time.Time{}, spec) + require.NoError(t, err) + token.SetLabels(map[string]string{ + "test-key": "test-value", + }) + err = env.server.Auth().CreateToken(ctx, token) + require.NoError(t, err) + + // Make a simple edit + spec.BotName = "test-bot_EDITED" + data := struct { + types.ProvisionTokenSpecV2 + Name string `json:"name"` + }{ + ProvisionTokenSpecV2: spec, + Name: "github-test-token", + } + endpointV1 := pack.clt.Endpoint("v1", "webapi", "tokens") + _, err = pack.clt.PostJSON(ctx, endpointV1, data) + require.NoError(t, err) + + // Fetch the token and compare + editedToken, err := env.server.Auth().GetToken(ctx, "github-test-token") + require.NoError(t, err) + require.Equal(t, "test-bot_EDITED", editedToken.GetBotName()) + require.Equal(t, map[string]string{ + "test-key": "test-value", + }, editedToken.GetMetadata().Labels) +} + +func TestCreateTokenExpiry(t *testing.T) { + t.Parallel() + ctx := context.Background() + username := "test-user@example.com" + env := newWebPack(t, 1) + proxy := env.proxies[0] + pack := proxy.authPack(t, username, nil /* roles */) + + for _, method := range types.JoinMethods { + t.Run(string(method), func(t *testing.T) { + // Skip enterprise-only methods + if method == types.JoinMethodTPM || method == types.JoinMethodSpacelift { + t.Skipf("Skipping %s, as it's enterprise-only", method) + } + + spec := types.ProvisionTokenSpecV2{ + Roles: []types.SystemRole{types.RoleNode}, + JoinMethod: method, + } + setMinimalConfigForMethod(&spec, method) + + var expectedExpiry time.Time + switch method { + case types.JoinMethodGCP, types.JoinMethodIAM, types.JoinMethodOracle, types.JoinMethodGitHub: + expectedExpiry = time.Time{} + default: + expectedExpiry = time.Now().UTC().Add(4 * time.Hour) + } + + endpointV1 := pack.clt.Endpoint("v1", "webapi", "tokens") + re, err := pack.clt.PostJSON(ctx, endpointV1, spec) + require.NoError(t, err) + + resp := nodeJoinToken{} + require.NoError(t, json.Unmarshal(re.Bytes(), &resp)) + require.Equal(t, method, resp.Method) + require.WithinDuration(t, expectedExpiry, resp.Expiry, 100*time.Millisecond) + }) + } +} + +func setMinimalConfigForMethod(spec *types.ProvisionTokenSpecV2, method types.JoinMethod) { + switch method { + case types.JoinMethodIAM, types.JoinMethodEC2: + spec.Allow = []*types.TokenRule{ + { + AWSAccount: "test-account", + }, + } + case types.JoinMethodAzure: + spec.Azure = &types.ProvisionTokenSpecV2Azure{ + Allow: []*types.ProvisionTokenSpecV2Azure_Rule{ + { + Subscription: "test-sub", + }, + }, + } + case types.JoinMethodBitbucket: + spec.Bitbucket = &types.ProvisionTokenSpecV2Bitbucket{ + Audience: "test-audience", + IdentityProviderURL: "test-identity-provider-url", + Allow: []*types.ProvisionTokenSpecV2Bitbucket_Rule{ + { + WorkspaceUUID: "test-workspace-uuid", + }, + }, + } + case types.JoinMethodOracle: + spec.Oracle = &types.ProvisionTokenSpecV2Oracle{ + Allow: []*types.ProvisionTokenSpecV2Oracle_Rule{ + { + Tenancy: "ocid1.tenancy.oc1..test", + }, + }, + } + case types.JoinMethodTerraformCloud: + spec.TerraformCloud = &types.ProvisionTokenSpecV2TerraformCloud{ + Allow: []*types.ProvisionTokenSpecV2TerraformCloud_Rule{ + { + OrganizationID: "test-org-id", + ProjectID: "test-proj-id", + }, + }, + } + case types.JoinMethodKubernetes: + spec.Kubernetes = &types.ProvisionTokenSpecV2Kubernetes{ + Allow: []*types.ProvisionTokenSpecV2Kubernetes_Rule{ + { + ServiceAccount: "test:service-account", + }, + }, + } + case types.JoinMethodGitLab: + spec.GitLab = &types.ProvisionTokenSpecV2GitLab{ + Allow: []*types.ProvisionTokenSpecV2GitLab_Rule{ + { + Sub: "test-sub", + }, + }, + } + case types.JoinMethodGitHub: + spec.GitHub = &types.ProvisionTokenSpecV2GitHub{ + Allow: []*types.ProvisionTokenSpecV2GitHub_Rule{ + { + Sub: "test-sub", + }, + }, + } + case types.JoinMethodGCP: + spec.GCP = &types.ProvisionTokenSpecV2GCP{ + Allow: []*types.ProvisionTokenSpecV2GCP_Rule{ + { + ProjectIDs: []string{"test-project-id"}, + }, + }, + } + case types.JoinMethodCircleCI: + spec.CircleCI = &types.ProvisionTokenSpecV2CircleCI{ + Allow: []*types.ProvisionTokenSpecV2CircleCI_Rule{ + { + ProjectID: "test-project-id", + }, + }, + OrganizationID: "test-org-id", + } + } +} + func TestCreateTokenForDiscovery(t *testing.T) { ctx := context.Background() username := "test-user@example.com" From 2b90f08d46c6ce1cacf4f53c3820f84ba7e3590c Mon Sep 17 00:00:00 2001 From: Nick Marais Date: Wed, 30 Apr 2025 09:37:45 +0100 Subject: [PATCH 4/9] Cover enterprise token types in tests --- lib/web/join_tokens_test.go | 32 ++++++++++++++++++++++++++------ 1 file changed, 26 insertions(+), 6 deletions(-) diff --git a/lib/web/join_tokens_test.go b/lib/web/join_tokens_test.go index 457c4a34feaf8..952dec11e3d50 100644 --- a/lib/web/join_tokens_test.go +++ b/lib/web/join_tokens_test.go @@ -470,7 +470,15 @@ func TestEditToken(t *testing.T) { } func TestCreateTokenExpiry(t *testing.T) { - t.Parallel() + // Can't t.Parallel because of modules.SetTestModules. + // Use enterprise build to access token types such as TPM and Spacelift + modules.SetTestModules(t, &modules.TestModules{ + TestBuildType: modules.BuildEnterprise, + TestFeatures: modules.Features{ + Cloud: false, + }, + }) + ctx := context.Background() username := "test-user@example.com" env := newWebPack(t, 1) @@ -479,11 +487,6 @@ func TestCreateTokenExpiry(t *testing.T) { for _, method := range types.JoinMethods { t.Run(string(method), func(t *testing.T) { - // Skip enterprise-only methods - if method == types.JoinMethodTPM || method == types.JoinMethodSpacelift { - t.Skipf("Skipping %s, as it's enterprise-only", method) - } - spec := types.ProvisionTokenSpecV2{ Roles: []types.SystemRole{types.RoleNode}, JoinMethod: method, @@ -594,6 +597,23 @@ func setMinimalConfigForMethod(spec *types.ProvisionTokenSpecV2, method types.Jo }, OrganizationID: "test-org-id", } + case types.JoinMethodTPM: + spec.TPM = &types.ProvisionTokenSpecV2TPM{ + Allow: []*types.ProvisionTokenSpecV2TPM_Rule{ + { + EKPublicHash: "test-hash", + }, + }, + } + case types.JoinMethodSpacelift: + spec.Spacelift = &types.ProvisionTokenSpecV2Spacelift{ + Hostname: "test-hostname", + Allow: []*types.ProvisionTokenSpecV2Spacelift_Rule{ + { + SpaceID: "test-space-id", + }, + }, + } } } From e7fd9a564f78315c758dbc68df7d451f49acdefb Mon Sep 17 00:00:00 2001 From: Nick Marais Date: Wed, 30 Apr 2025 09:38:47 +0100 Subject: [PATCH 5/9] Cover github tokens in existing tests --- lib/web/join_tokens_test.go | 169 ++++++++++++++++-------------------- 1 file changed, 75 insertions(+), 94 deletions(-) diff --git a/lib/web/join_tokens_test.go b/lib/web/join_tokens_test.go index 952dec11e3d50..a6bbeecb62c9c 100644 --- a/lib/web/join_tokens_test.go +++ b/lib/web/join_tokens_test.go @@ -27,7 +27,6 @@ import ( "net/http" "net/url" "regexp" - "slices" "strconv" "testing" "time" @@ -96,8 +95,8 @@ func TestGenerateIAMTokenName(t *testing.T) { type tokenData struct { name string - roles types.SystemRoles expiry time.Time + spec types.ProvisionTokenSpecV2 } func TestGetTokens(t *testing.T) { @@ -149,25 +148,31 @@ func TestGetTokens(t *testing.T) { tokenData: []tokenData{ { name: "test-token", - roles: types.SystemRoles{ - types.RoleNode, + spec: types.ProvisionTokenSpecV2{ + Roles: types.SystemRoles{ + types.RoleNode, + }, }, expiry: expiry, }, { name: "test-token-2", - roles: types.SystemRoles{ - types.RoleNode, - types.RoleDatabase, + spec: types.ProvisionTokenSpecV2{ + Roles: types.SystemRoles{ + types.RoleNode, + types.RoleDatabase, + }, }, expiry: expiry, }, { name: "test-token-3-and-super-duper-long", - roles: types.SystemRoles{ - types.RoleNode, - types.RoleKube, - types.RoleDatabase, + spec: types.ProvisionTokenSpecV2{ + Roles: types.SystemRoles{ + types.RoleNode, + types.RoleKube, + types.RoleDatabase, + }, }, expiry: expiry, }, @@ -209,6 +214,64 @@ func TestGetTokens(t *testing.T) { staticUIToken, }, }, + { + name: "github token", + tokenData: []tokenData{ + { + name: "github-test-token", + spec: types.ProvisionTokenSpecV2{ + Roles: types.SystemRoles{ + types.RoleBot, + }, + BotName: "test-bot", + JoinMethod: types.JoinMethodGitHub, + GitHub: &types.ProvisionTokenSpecV2GitHub{ + EnterpriseServerHost: "github.example.com", + StaticJWKS: "{\"keys\":[]}", + Allow: []*types.ProvisionTokenSpecV2GitHub_Rule{ + { + Repository: "gravitational/teleport", + RepositoryOwner: "gravitational", + Sub: "test-sub", + Workflow: "test-workflow", + Environment: "test-environment", + Actor: "octocat", + Ref: "ref/heads/main", + RefType: "branch", + }, + }, + }, + }, + }, + }, + expected: []ui.JoinToken{ + { + ID: "github-test-token", + SafeName: "github-test-token", + BotName: "test-bot", + Expiry: time.Time{}, + Roles: types.SystemRoles{"Bot"}, + Method: types.JoinMethodGitHub, + Github: &types.ProvisionTokenSpecV2GitHub{ + EnterpriseServerHost: "github.example.com", + StaticJWKS: "{\"keys\":[]}", + Allow: []*types.ProvisionTokenSpecV2GitHub_Rule{ + { + Repository: "gravitational/teleport", + RepositoryOwner: "gravitational", + Sub: "test-sub", + Workflow: "test-workflow", + Environment: "test-environment", + Actor: "octocat", + Ref: "ref/heads/main", + RefType: "branch", + }, + }, + }, + }, + staticUIToken, + }, + }, } for _, tc := range tt { @@ -249,9 +312,7 @@ func TestGetTokens(t *testing.T) { } for _, td := range tc.tokenData { - token, err := types.NewProvisionTokenFromSpec(td.name, td.expiry, types.ProvisionTokenSpecV2{ - Roles: td.roles, - }) + token, err := types.NewProvisionTokenFromSpec(td.name, td.expiry, td.spec) require.NoError(t, err) err = env.server.Auth().CreateToken(ctx, token) require.NoError(t, err) @@ -269,86 +330,6 @@ func TestGetTokens(t *testing.T) { } } -func TestGetGithubTokens(t *testing.T) { - t.Parallel() - ctx := context.Background() - - username := "test-user@example.com" - expiry := time.Now().UTC().Add(30 * time.Minute) - - env := newWebPack(t, 1) - proxy := env.proxies[0] - pack := proxy.authPack(t, username, nil /* roles */) - - td := tokenData{ - name: "github-test-token", - expiry: expiry, - roles: types.SystemRoles{types.RoleBot}, - } - - token, err := types.NewProvisionTokenFromSpec(td.name, td.expiry, types.ProvisionTokenSpecV2{ - Roles: td.roles, - BotName: "test-bot", - JoinMethod: types.JoinMethodGitHub, - - GitHub: &types.ProvisionTokenSpecV2GitHub{ - EnterpriseServerHost: "github.example.com", - StaticJWKS: "{\"keys\":[]}", - Allow: []*types.ProvisionTokenSpecV2GitHub_Rule{ - { - Repository: "gravitational/teleport", - RepositoryOwner: "gravitational", - Sub: "test-sub", - Workflow: "test-workflow", - Environment: "test-environment", - Actor: "octocat", - Ref: "ref/heads/main", - RefType: "branch", - }, - }, - }, - }) - require.NoError(t, err) - err = env.server.Auth().CreateToken(ctx, token) - require.NoError(t, err) - - endpoint := pack.clt.Endpoint("webapi", "tokens") - re, err := pack.clt.Get(ctx, endpoint, url.Values{}) - require.NoError(t, err) - - resp := GetTokensResponse{} - require.NoError(t, json.Unmarshal(re.Bytes(), &resp)) - - require.Len(t, resp.Items, 2) // Including a static token - - githubTokenIndex := slices.IndexFunc(resp.Items, func(item ui.JoinToken) bool { return item.Method == types.JoinMethodGitHub }) - require.NotEqual(t, githubTokenIndex, -1) - require.Empty(t, cmp.Diff(resp.Items[githubTokenIndex], ui.JoinToken{ - ID: "github-test-token", - SafeName: "github-test-token", - BotName: "test-bot", - Expiry: expiry, - Roles: types.SystemRoles{"Bot"}, - Method: types.JoinMethodGitHub, - Github: &types.ProvisionTokenSpecV2GitHub{ - EnterpriseServerHost: "github.example.com", - StaticJWKS: "{\"keys\":[]}", - Allow: []*types.ProvisionTokenSpecV2GitHub_Rule{ - { - Repository: "gravitational/teleport", - RepositoryOwner: "gravitational", - Sub: "test-sub", - Workflow: "test-workflow", - Environment: "test-environment", - Actor: "octocat", - Ref: "ref/heads/main", - RefType: "branch", - }, - }, - }, - }, cmpopts.IgnoreFields(ui.JoinToken{}, "Content"))) -} - func TestDeleteToken(t *testing.T) { ctx := context.Background() username := "test-user@example.com" From 98c1c72d143401eb89c1df0f631b725985e126f8 Mon Sep 17 00:00:00 2001 From: Nick Marais Date: Wed, 30 Apr 2025 10:18:35 +0100 Subject: [PATCH 6/9] Tweak handling of `tokenId` --- lib/web/join_tokens.go | 12 ++++-------- 1 file changed, 4 insertions(+), 8 deletions(-) diff --git a/lib/web/join_tokens.go b/lib/web/join_tokens.go index e7c852981537c..c4a7431a85f9e 100644 --- a/lib/web/join_tokens.go +++ b/lib/web/join_tokens.go @@ -190,7 +190,7 @@ func (h *Handler) upsertTokenHandle(w http.ResponseWriter, r *http.Request, para return nil, trace.Wrap(err) } - var tokenId string + var tokenId string = req.Name if r.Method == "PUT" { // if using the PUT route, tokenId will be present // in the X-Teleport-TokenName header @@ -199,8 +199,6 @@ func (h *Handler) upsertTokenHandle(w http.ResponseWriter, r *http.Request, para if tokenId != "" && tokenId != req.Name { return nil, trace.BadParameter("renaming tokens is not supported") } - } else { - tokenId = req.Name } clt, err := ctx.GetClient() @@ -209,11 +207,9 @@ func (h *Handler) upsertTokenHandle(w http.ResponseWriter, r *http.Request, para } var existingToken types.ProvisionToken - if tokenId != "" { - existingToken, err = clt.GetToken(r.Context(), tokenId) - if err != nil && !trace.IsNotFound(err) { - return nil, trace.Wrap(err) - } + existingToken, err = clt.GetToken(r.Context(), tokenId) + if err != nil && !trace.IsNotFound(err) { + return nil, trace.Wrap(err) } var expires time.Time From 1311d912b107109a445c7511a4241a2b96da5711 Mon Sep 17 00:00:00 2001 From: Nick Marais Date: Wed, 30 Apr 2025 10:19:24 +0100 Subject: [PATCH 7/9] Check expiry is not overwritten --- lib/web/join_tokens_test.go | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/lib/web/join_tokens_test.go b/lib/web/join_tokens_test.go index a6bbeecb62c9c..50762a048199f 100644 --- a/lib/web/join_tokens_test.go +++ b/lib/web/join_tokens_test.go @@ -406,6 +406,8 @@ func TestEditToken(t *testing.T) { proxy := env.proxies[0] pack := proxy.authPack(t, username, nil /* roles */) + expiry := time.Now().UTC() + // Setup an existing token spec := types.ProvisionTokenSpecV2{ Roles: types.SystemRoles{types.RoleBot}, @@ -422,6 +424,7 @@ func TestEditToken(t *testing.T) { } token, err := types.NewProvisionTokenFromSpec("github-test-token", time.Time{}, spec) require.NoError(t, err) + token.SetExpiry(expiry) token.SetLabels(map[string]string{ "test-key": "test-value", }) @@ -445,6 +448,7 @@ func TestEditToken(t *testing.T) { editedToken, err := env.server.Auth().GetToken(ctx, "github-test-token") require.NoError(t, err) require.Equal(t, "test-bot_EDITED", editedToken.GetBotName()) + require.Equal(t, expiry, *editedToken.GetMetadata().Expires) require.Equal(t, map[string]string{ "test-key": "test-value", }, editedToken.GetMetadata().Labels) From f0254a692340c137b95521aa33f8d08f391d6b05 Mon Sep 17 00:00:00 2001 From: Nick Marais Date: Wed, 30 Apr 2025 10:34:13 +0100 Subject: [PATCH 8/9] Revert removing tokenId check --- lib/web/join_tokens.go | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/lib/web/join_tokens.go b/lib/web/join_tokens.go index c4a7431a85f9e..5e1db8b0ed579 100644 --- a/lib/web/join_tokens.go +++ b/lib/web/join_tokens.go @@ -207,9 +207,11 @@ func (h *Handler) upsertTokenHandle(w http.ResponseWriter, r *http.Request, para } var existingToken types.ProvisionToken - existingToken, err = clt.GetToken(r.Context(), tokenId) - if err != nil && !trace.IsNotFound(err) { - return nil, trace.Wrap(err) + if tokenId != "" { + existingToken, err = clt.GetToken(r.Context(), tokenId) + if err != nil && !trace.IsNotFound(err) { + return nil, trace.Wrap(err) + } } var expires time.Time From 99dcad06738de6c943e506b8d5a4b61025d00fcd Mon Sep 17 00:00:00 2001 From: Nick Marais Date: Thu, 1 May 2025 11:43:44 +0100 Subject: [PATCH 9/9] Remove use of `X-Teleport-TokenName` header --- lib/web/join_tokens.go | 15 +----- lib/web/join_tokens_test.go | 95 +++++++++++++++++++++---------------- 2 files changed, 57 insertions(+), 53 deletions(-) diff --git a/lib/web/join_tokens.go b/lib/web/join_tokens.go index 5e1db8b0ed579..9a330d3466a69 100644 --- a/lib/web/join_tokens.go +++ b/lib/web/join_tokens.go @@ -190,25 +190,14 @@ func (h *Handler) upsertTokenHandle(w http.ResponseWriter, r *http.Request, para return nil, trace.Wrap(err) } - var tokenId string = req.Name - if r.Method == "PUT" { - // if using the PUT route, tokenId will be present - // in the X-Teleport-TokenName header - tokenId = r.Header.Get(HeaderTokenName) - - if tokenId != "" && tokenId != req.Name { - return nil, trace.BadParameter("renaming tokens is not supported") - } - } - clt, err := ctx.GetClient() if err != nil { return nil, trace.Wrap(err) } var existingToken types.ProvisionToken - if tokenId != "" { - existingToken, err = clt.GetToken(r.Context(), tokenId) + if req.Name != "" { + existingToken, err = clt.GetToken(r.Context(), req.Name) if err != nil && !trace.IsNotFound(err) { return nil, trace.Wrap(err) } diff --git a/lib/web/join_tokens_test.go b/lib/web/join_tokens_test.go index 50762a048199f..5ac264b99a369 100644 --- a/lib/web/join_tokens_test.go +++ b/lib/web/join_tokens_test.go @@ -33,6 +33,7 @@ import ( "github.com/google/go-cmp/cmp" "github.com/google/go-cmp/cmp/cmpopts" + "github.com/gravitational/roundtrip" "github.com/gravitational/trace" "github.com/stretchr/testify/mock" "github.com/stretchr/testify/require" @@ -408,50 +409,64 @@ func TestEditToken(t *testing.T) { expiry := time.Now().UTC() - // Setup an existing token - spec := types.ProvisionTokenSpecV2{ - Roles: types.SystemRoles{types.RoleBot}, - BotName: "test-bot", - JoinMethod: types.JoinMethodGitHub, + tcs := []struct { + Name string + Method func(ctx context.Context, endpoint string, val any) (*roundtrip.Response, error) + }{ + {Name: "http_post", Method: pack.clt.PostJSON}, + {Name: "http_put", Method: pack.clt.PutJSON}, + } - GitHub: &types.ProvisionTokenSpecV2GitHub{ - Allow: []*types.ProvisionTokenSpecV2GitHub_Rule{ - { - Repository: "gravitational/teleport", + for _, tc := range tcs { + t.Run(tc.Name, func(t *testing.T) { + + // Setup an existing token + spec := types.ProvisionTokenSpecV2{ + Roles: types.SystemRoles{types.RoleBot}, + BotName: "test-bot", + JoinMethod: types.JoinMethodGitHub, + + GitHub: &types.ProvisionTokenSpecV2GitHub{ + Allow: []*types.ProvisionTokenSpecV2GitHub_Rule{ + { + Repository: "gravitational/teleport", + }, + }, }, - }, - }, - } - token, err := types.NewProvisionTokenFromSpec("github-test-token", time.Time{}, spec) - require.NoError(t, err) - token.SetExpiry(expiry) - token.SetLabels(map[string]string{ - "test-key": "test-value", - }) - err = env.server.Auth().CreateToken(ctx, token) - require.NoError(t, err) + } + tokenName := "github-test-token" + tc.Name + token, err := types.NewProvisionTokenFromSpec(tokenName, time.Time{}, spec) + require.NoError(t, err) + token.SetExpiry(expiry) + token.SetLabels(map[string]string{ + "test-key": "test-value", + }) + err = env.server.Auth().CreateToken(ctx, token) + require.NoError(t, err) - // Make a simple edit - spec.BotName = "test-bot_EDITED" - data := struct { - types.ProvisionTokenSpecV2 - Name string `json:"name"` - }{ - ProvisionTokenSpecV2: spec, - Name: "github-test-token", - } - endpointV1 := pack.clt.Endpoint("v1", "webapi", "tokens") - _, err = pack.clt.PostJSON(ctx, endpointV1, data) - require.NoError(t, err) + // Make a simple edit + spec.BotName = "test-bot_EDITED" + data := struct { + types.ProvisionTokenSpecV2 + Name string `json:"name"` + }{ + ProvisionTokenSpecV2: spec, + Name: tokenName, + } + endpointV1 := pack.clt.Endpoint("v1", "webapi", "tokens") + _, err = tc.Method(ctx, endpointV1, data) + require.NoError(t, err) - // Fetch the token and compare - editedToken, err := env.server.Auth().GetToken(ctx, "github-test-token") - require.NoError(t, err) - require.Equal(t, "test-bot_EDITED", editedToken.GetBotName()) - require.Equal(t, expiry, *editedToken.GetMetadata().Expires) - require.Equal(t, map[string]string{ - "test-key": "test-value", - }, editedToken.GetMetadata().Labels) + // Fetch the token and compare + editedToken, err := env.server.Auth().GetToken(ctx, tokenName) + require.NoError(t, err) + require.Equal(t, "test-bot_EDITED", editedToken.GetBotName()) + require.Equal(t, expiry, *editedToken.GetMetadata().Expires) + require.Equal(t, map[string]string{ + "test-key": "test-value", + }, editedToken.GetMetadata().Labels) + }) + } } func TestCreateTokenExpiry(t *testing.T) {