diff --git a/api/types/provisioning.go b/api/types/provisioning.go index cc14c077e4c9a..9cfa51ce42c26 100644 --- a/api/types/provisioning.go +++ b/api/types/provisioning.go @@ -136,6 +136,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. @@ -444,6 +446,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.go b/lib/web/join_tokens.go index b517b1f9fda30..9a330d3466a69 100644 --- a/lib/web/join_tokens.go +++ b/lib/web/join_tokens.go @@ -184,29 +184,30 @@ 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") + clt, err := ctx.GetClient() + if err != nil { + return nil, trace.Wrap(err) + } + + var existingToken types.ProvisionToken + if req.Name != "" { + existingToken, err = clt.GetToken(r.Context(), req.Name) + 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 +227,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 d47e2c1b137f3..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" @@ -95,8 +96,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) { @@ -148,25 +149,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, }, @@ -208,6 +215,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 { @@ -248,9 +313,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) @@ -336,6 +399,224 @@ 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 */) + + expiry := time.Now().UTC() + + 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}, + } + + 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", + }, + }, + }, + } + 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: 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, 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) { + // 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) + proxy := env.proxies[0] + pack := proxy.authPack(t, username, nil /* roles */) + + for _, method := range types.JoinMethods { + t.Run(string(method), func(t *testing.T) { + 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", + } + 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", + }, + }, + } + } +} + func TestCreateTokenForDiscovery(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 } 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) {