diff --git a/api/types/provisioning.go b/api/types/provisioning.go index 82cdaf05e2dfc..8087c85335903 100644 --- a/api/types/provisioning.go +++ b/api/types/provisioning.go @@ -121,13 +121,16 @@ type ProvisionToken interface { GetAllowRules() []*TokenRule // SetAllowRules sets the allow rules SetAllowRules([]*TokenRule) + // GetGCPRules will return the GCP rules within this token. + GetGCPRules() *ProvisionTokenSpecV2GCP // GetAWSIIDTTL returns the TTL of EC2 IIDs GetAWSIIDTTL() Duration // GetJoinMethod returns joining method that must be used with this token. GetJoinMethod() JoinMethod // GetBotName returns the BotName field which must be set for joining bots. GetBotName() string - + // IsStatic returns true if the token is statically configured + IsStatic() bool // GetSuggestedLabels returns the set of labels that the resource should add when adding itself to the cluster GetSuggestedLabels() Labels @@ -384,6 +387,11 @@ func (p *ProvisionTokenV2) SetAllowRules(rules []*TokenRule) { p.Spec.Allow = rules } +// GetGCPRules will return the GCP rules within this token. +func (p *ProvisionTokenV2) GetGCPRules() *ProvisionTokenSpecV2GCP { + return p.Spec.GCP +} + // GetAWSIIDTTL returns the TTL of EC2 IIDs func (p *ProvisionTokenV2) GetAWSIIDTTL() Duration { return p.Spec.AWSIIDTTL @@ -394,6 +402,11 @@ func (p *ProvisionTokenV2) GetJoinMethod() JoinMethod { return p.Spec.JoinMethod } +// IsStatic returns true if the token is statically configured +func (p *ProvisionTokenV2) IsStatic() bool { + return p.Origin() == OriginConfigFile +} + // GetBotName returns the BotName field which must be set for joining bots. func (p *ProvisionTokenV2) GetBotName() string { return p.Spec.BotName @@ -535,14 +548,16 @@ func ProvisionTokensToV1(in []ProvisionToken) []ProvisionTokenV1 { return out } -// ProvisionTokensFromV1 converts V1 provision tokens to resource list -func ProvisionTokensFromV1(in []ProvisionTokenV1) []ProvisionToken { +// ProvisionTokensFromStatic converts static tokens to resource list +func ProvisionTokensFromStatic(in []ProvisionTokenV1) []ProvisionToken { if in == nil { return nil } out := make([]ProvisionToken, len(in)) for i := range in { - out[i] = in[i].V2() + tok := in[i].V2() + tok.SetOrigin(OriginConfigFile) + out[i] = tok } return out } diff --git a/api/types/statictokens.go b/api/types/statictokens.go index bf239d71f16fe..daff43c12247b 100644 --- a/api/types/statictokens.go +++ b/api/types/statictokens.go @@ -113,7 +113,7 @@ func (c *StaticTokensV2) SetStaticTokens(s []ProvisionToken) { // GetStaticTokens gets the list of static tokens used to provision nodes. func (c *StaticTokensV2) GetStaticTokens() []ProvisionToken { - return ProvisionTokensFromV1(c.Spec.StaticTokens) + return ProvisionTokensFromStatic(c.Spec.StaticTokens) } // setStaticFields sets static resource header and metadata fields. diff --git a/lib/auth/auth_test.go b/lib/auth/auth_test.go index bdcf55bffb4a7..c86ffb32c12f5 100644 --- a/lib/auth/auth_test.go +++ b/lib/auth/auth_test.go @@ -1143,7 +1143,7 @@ func TestUpdateConfig(t *testing.T) { require.Equal(t, cn.GetClusterName(), s.clusterName.GetClusterName()) st, err = s.a.GetStaticTokens() require.NoError(t, err) - require.Equal(t, st.GetStaticTokens(), types.ProvisionTokensFromV1([]types.ProvisionTokenV1{{ + require.Equal(t, st.GetStaticTokens(), types.ProvisionTokensFromStatic([]types.ProvisionTokenV1{{ Token: "bar", Roles: types.SystemRoles{types.SystemRole("baz")}, }})) @@ -1152,7 +1152,7 @@ func TestUpdateConfig(t *testing.T) { // new static tokens st, err = authServer.GetStaticTokens() require.NoError(t, err) - require.Equal(t, st.GetStaticTokens(), types.ProvisionTokensFromV1([]types.ProvisionTokenV1{{ + require.Equal(t, st.GetStaticTokens(), types.ProvisionTokensFromStatic([]types.ProvisionTokenV1{{ Token: "bar", Roles: types.SystemRoles{types.SystemRole("baz")}, }})) diff --git a/lib/auth/helpers.go b/lib/auth/helpers.go index 2e020bbab2248..3bcbfaeccaaf0 100644 --- a/lib/auth/helpers.go +++ b/lib/auth/helpers.go @@ -389,9 +389,17 @@ func NewTestAuthServer(cfg TestAuthServerConfig) (*TestAuthServer, error) { return nil, trace.Wrap(err) } + token, err := types.NewProvisionTokenFromSpec("static-token", time.Unix(0, 0).UTC(), types.ProvisionTokenSpecV2{ + Roles: types.SystemRoles{types.RoleNode}, + }) + if err != nil { + return nil, trace.Wrap(err) + } // set static tokens staticTokens, err := types.NewStaticTokens(types.StaticTokensSpecV2{ - StaticTokens: []types.ProvisionTokenV1{}, + StaticTokens: []types.ProvisionTokenV1{ + *token.V1(), + }, }) if err != nil { return nil, trace.Wrap(err) diff --git a/lib/auth/tls_test.go b/lib/auth/tls_test.go index 1302d2c7c6250..d66e3e1744b89 100644 --- a/lib/auth/tls_test.go +++ b/lib/auth/tls_test.go @@ -4637,12 +4637,12 @@ func TestGRPCServer_GetTokens(t *testing.T) { ) require.NoError(t, err) - t.Run("no tokens", func(t *testing.T) { + t.Run("no extra tokens", func(t *testing.T) { client, err := testSrv.NewClient(TestUser(privilegedUser.GetName())) require.NoError(t, err) toks, err := client.GetTokens(ctx) require.NoError(t, err) - require.Empty(t, toks) + require.Len(t, toks, 1) // only a single static token exists }) // Create tokens to then assert are returned diff --git a/lib/auth/trustedcluster.go b/lib/auth/trustedcluster.go index f0c1ecc75c580..500a367010f6a 100644 --- a/lib/auth/trustedcluster.go +++ b/lib/auth/trustedcluster.go @@ -509,6 +509,13 @@ func (a *Server) validateTrustedCluster(ctx context.Context, validateRequest *au if err != nil { return nil, trace.Wrap(err) } + + originLabel, ok := tokenLabels[types.OriginLabel] + if ok && originLabel == types.OriginConfigFile { + // static tokens have an OriginLabel of OriginConfigFile and we don't want + // to propegate that to the trusted cluster + delete(tokenLabels, types.OriginLabel) + } if len(tokenLabels) != 0 { meta := remoteCluster.GetMetadata() meta.Labels = utils.CopyStringsMap(tokenLabels) diff --git a/lib/config/configuration_test.go b/lib/config/configuration_test.go index 9398765cc5104..e659296d21b34 100644 --- a/lib/config/configuration_test.go +++ b/lib/config/configuration_test.go @@ -728,7 +728,7 @@ func TestApplyConfig(t *testing.T) { require.NoError(t, err) require.Equal(t, "join-token", token) - require.Equal(t, types.ProvisionTokensFromV1([]types.ProvisionTokenV1{ + require.Equal(t, types.ProvisionTokensFromStatic([]types.ProvisionTokenV1{ { Token: "xxx", Roles: types.SystemRoles([]types.SystemRole{"Proxy", "Node"}), diff --git a/lib/web/apiserver.go b/lib/web/apiserver.go index c36e256e9ab0f..e59b5d0a16b82 100644 --- a/lib/web/apiserver.go +++ b/lib/web/apiserver.go @@ -770,8 +770,16 @@ func (h *Handler) bindDefaultEndpoints() { h.GET("/webapi/sites/:site/auth/export", h.authExportPublic) h.GET("/webapi/auth/export", h.authExportPublic) - // token generation - h.POST("/webapi/token", h.WithAuth(h.createTokenHandle)) + // join token handlers + h.PUT("/webapi/tokens/yaml", h.WithAuth(h.updateTokenYAML)) + // used for creating a new token + h.POST("/webapi/tokens", h.WithAuth(h.upsertTokenHandle)) + // used for updating a token + h.PUT("/webapi/tokens", h.WithAuth(h.upsertTokenHandle)) + // used for creating tokens used during guided discover flows + h.POST("/webapi/token", h.WithAuth(h.createTokenForDiscoveryHandle)) + h.GET("/webapi/tokens", h.WithAuth(h.getTokens)) + h.DELETE("/webapi/tokens", h.WithAuth(h.deleteToken)) // join scripts h.GET("/scripts/:token/install-node.sh", h.WithLimiter(h.getNodeJoinScriptHandle)) diff --git a/lib/web/join_tokens.go b/lib/web/join_tokens.go index 9b5bfd459aaf3..c5939e30953d7 100644 --- a/lib/web/join_tokens.go +++ b/lib/web/join_tokens.go @@ -45,6 +45,7 @@ import ( "github.com/gravitational/teleport/lib/defaults" "github.com/gravitational/teleport/lib/httplib" "github.com/gravitational/teleport/lib/modules" + "github.com/gravitational/teleport/lib/services" "github.com/gravitational/teleport/lib/tlsca" "github.com/gravitational/teleport/lib/utils" "github.com/gravitational/teleport/lib/web/scripts" @@ -53,6 +54,7 @@ import ( const ( stableCloudChannelRepo = "stable/cloud" + HeaderTokenName = "X-Teleport-TokenName" ) // nodeJoinToken contains node token fields for the UI. @@ -92,17 +94,176 @@ func automaticUpgrades(features proto.Features) bool { return features.AutomaticUpgrades && features.Cloud } -func (h *Handler) createTokenHandle(w http.ResponseWriter, r *http.Request, params httprouter.Params, ctx *SessionContext) (interface{}, error) { - var req types.ProvisionTokenSpecV2 +// Currently we aren't paginating this endpoint as we don't +// expect many tokens to exist at a time. I'm leaving it in a "paginated" form +// without a nextKey for now so implementing pagination won't change the response shape +// TODO (avatus) implement pagination + +// GetTokensResponse returns a list of JoinTokens. +type GetTokensResponse struct { + Items []ui.JoinToken `json:"items"` +} + +func (h *Handler) getTokens(w http.ResponseWriter, r *http.Request, params httprouter.Params, ctx *SessionContext) (interface{}, error) { + clt, err := ctx.GetClient() + if err != nil { + return nil, trace.Wrap(err) + } + + tokens, err := clt.GetTokens(r.Context()) + if err != nil { + return nil, trace.Wrap(err) + } + + uiTokens, err := ui.MakeJoinTokens(tokens) + if err != nil { + return nil, trace.Wrap(err) + } + + return GetTokensResponse{ + Items: uiTokens, + }, nil +} + +func (h *Handler) deleteToken(w http.ResponseWriter, r *http.Request, params httprouter.Params, ctx *SessionContext) (interface{}, error) { + token := r.Header.Get(HeaderTokenName) + if token == "" { + return nil, trace.BadParameter("requires a token to delete") + } + + clt, err := ctx.GetClient() + if err != nil { + return nil, trace.Wrap(err) + } + + if err := clt.DeleteToken(r.Context(), token); err != nil { + return nil, trace.Wrap(err) + } + + return OK(), nil +} + +type CreateTokenRequest struct { + Content string `json:"content"` +} + +func (h *Handler) updateTokenYAML(w http.ResponseWriter, r *http.Request, params httprouter.Params, sctx *SessionContext) (interface{}, error) { + tokenId := r.Header.Get(HeaderTokenName) + if tokenId == "" { + return nil, trace.BadParameter("requires a token name to edit") + } + + var yaml CreateTokenRequest + if err := httplib.ReadJSON(r, &yaml); err != nil { + return nil, trace.Wrap(err) + } + + extractedRes, err := ExtractResourceAndValidate(yaml.Content) + if err != nil { + return nil, trace.Wrap(err) + } + + if tokenId != extractedRes.Metadata.Name { + return nil, trace.BadParameter("renaming tokens is not supported") + } + + token, err := services.UnmarshalProvisionToken(extractedRes.Raw) + if err != nil { + return nil, trace.Wrap(err) + } + + clt, err := sctx.GetClient() + if err != nil { + return nil, trace.Wrap(err) + } + + err = clt.UpsertToken(r.Context(), token) + if err != nil { + return nil, trace.Wrap(err) + } + + uiToken, err := ui.MakeJoinToken(token) + if err != nil { + return nil, trace.Wrap(err) + } + + return uiToken, trace.Wrap(err) + +} + +type upsertTokenHandleRequest struct { + types.ProvisionTokenSpecV2 + 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") + } + + var req upsertTokenHandleRequest if err := httplib.ReadJSON(r, &req); err != nil { return nil, trace.Wrap(err) } + if editing && tokenId != req.Name { + return nil, trace.BadParameter("renaming tokens is not supported") + } + + // set expires time to default node join token TTL + expires := time.Now().UTC().Add(defaults.NodeJoinTokenTTL) + // IAM and GCP tokens should never expire + if req.JoinMethod == types.JoinMethodGCP || req.JoinMethod == types.JoinMethodIAM { + expires = time.Now().UTC().AddDate(1000, 0, 0) + } + + name := req.Name + if name == "" { + randName, err := utils.CryptoRandomHex(defaults.TokenLenBytes) + if err != nil { + return nil, trace.Wrap(err) + } + name = randName + } + + token, err := types.NewProvisionTokenFromSpec(name, expires, req.ProvisionTokenSpecV2) + if err != nil { + return nil, trace.Wrap(err) + } + clt, err := ctx.GetClient() if err != nil { return nil, trace.Wrap(err) } + err = clt.UpsertToken(r.Context(), token) + if err != nil { + return nil, trace.Wrap(err) + } + + uiToken, err := ui.MakeJoinToken(token) + if err != nil { + return nil, trace.Wrap(err) + } + + return uiToken, nil +} + +func (h *Handler) createTokenForDiscoveryHandle(w http.ResponseWriter, r *http.Request, params httprouter.Params, ctx *SessionContext) (interface{}, error) { + clt, err := ctx.GetClient() + if err != nil { + return nil, trace.Wrap(err) + } + + var req types.ProvisionTokenSpecV2 + if err := httplib.ReadJSON(r, &req); err != nil { + return nil, trace.Wrap(err) + } + var expires time.Time var tokenName string switch req.JoinMethod { diff --git a/lib/web/join_tokens_test.go b/lib/web/join_tokens_test.go index b01291bf8eaf5..08be47c4e448c 100644 --- a/lib/web/join_tokens_test.go +++ b/lib/web/join_tokens_test.go @@ -21,10 +21,16 @@ package web import ( "context" "encoding/hex" + "encoding/json" "fmt" + "net/http" + "net/url" "regexp" "testing" + "time" + "github.com/google/go-cmp/cmp" + "github.com/google/go-cmp/cmp/cmpopts" "github.com/gravitational/trace" "github.com/stretchr/testify/require" @@ -32,8 +38,12 @@ import ( "github.com/gravitational/teleport/api/client/proto" "github.com/gravitational/teleport/api/types" "github.com/gravitational/teleport/api/utils" + "github.com/gravitational/teleport/lib/auth/authclient" + "github.com/gravitational/teleport/lib/defaults" "github.com/gravitational/teleport/lib/fixtures" "github.com/gravitational/teleport/lib/modules" + "github.com/gravitational/teleport/lib/services" + "github.com/gravitational/teleport/lib/web/ui" ) func TestGenerateIAMTokenName(t *testing.T) { @@ -74,6 +84,249 @@ func TestGenerateIAMTokenName(t *testing.T) { require.NotEqual(t, hash1, hash2) } +type tokenData struct { + name string + roles types.SystemRoles + expiry time.Time +} + +func TestGetTokens(t *testing.T) { + t.Parallel() + username := "test-user@example.com" + ctx := context.Background() + expiry := time.Now().UTC().Add(30 * time.Minute) + + staticUIToken := ui.JoinToken{ + ID: "static-token", + SafeName: "************", + Roles: types.SystemRoles{types.RoleNode}, + Expiry: time.Unix(0, 0).UTC(), + IsStatic: true, + Method: types.JoinMethodToken, + Content: "kind: token\nmetadata:\n expires: \"1970-01-01T00:00:00Z\"\n labels:\n teleport.dev/origin: config-file\n name: static-token\nspec:\n join_method: token\n roles:\n - Node\nversion: v2\n", + } + + tt := []struct { + name string + tokenData []tokenData + expected []ui.JoinToken + noAccess bool + includeUserToken bool + }{ + { + name: "no access", + tokenData: []tokenData{}, + noAccess: true, + expected: []ui.JoinToken{}, + }, + { + name: "only static tokens exist", + tokenData: []tokenData{}, + expected: []ui.JoinToken{ + staticUIToken, + }, + }, + { + name: "static and sign up tokens", + tokenData: []tokenData{}, + expected: []ui.JoinToken{ + staticUIToken, + }, + includeUserToken: true, + }, + { + name: "all tokens", + tokenData: []tokenData{ + { + name: "test-token", + roles: types.SystemRoles{ + types.RoleNode, + }, + expiry: expiry, + }, + { + name: "test-token-2", + 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, + }, + expiry: expiry, + }, + }, + expected: []ui.JoinToken{ + { + ID: "test-token", + SafeName: "**********", + IsStatic: false, + Expiry: expiry, + Roles: types.SystemRoles{ + types.RoleNode, + }, + Method: types.JoinMethodToken, + }, + { + ID: "test-token-2", + SafeName: "************", + IsStatic: false, + Expiry: expiry, + Roles: types.SystemRoles{ + types.RoleNode, + types.RoleDatabase, + }, + Method: types.JoinMethodToken, + }, + { + ID: "test-token-3-and-super-duper-long", + SafeName: "************************uper-long", + IsStatic: false, + Expiry: expiry, + Roles: types.SystemRoles{ + types.RoleNode, + types.RoleKube, + types.RoleDatabase, + }, + Method: types.JoinMethodToken, + }, + staticUIToken, + }, + }, + } + + for _, tc := range tt { + t.Run(tc.name, func(t *testing.T) { + env := newWebPack(t, 1) + proxy := env.proxies[0] + pack := proxy.authPack(t, username, nil /* roles */) + + if tc.noAccess { + noAccessRole, err := types.NewRole(services.RoleNameForUser("test-no-access@example.com"), types.RoleSpecV6{}) + require.NoError(t, err) + noAccessPack := proxy.authPack(t, "test-no-access@example.com", []types.Role{noAccessRole}) + endpoint := noAccessPack.clt.Endpoint("webapi", "tokens") + _, err = noAccessPack.clt.Get(ctx, endpoint, url.Values{}) + require.Error(t, err) + return + } + + if tc.includeUserToken { + passwordToken, err := env.server.Auth().CreateResetPasswordToken(ctx, authclient.CreateUserTokenRequest{ + Name: username, + TTL: defaults.MaxSignupTokenTTL, + Type: authclient.UserTokenTypeResetPasswordInvite, + }) + require.NoError(t, err) + userToken, err := types.NewProvisionToken(passwordToken.GetName(), types.SystemRoles{types.RoleSignup}, passwordToken.Expiry()) + require.NoError(t, err) + + userUiToken := ui.JoinToken{ + ID: userToken.GetName(), + SafeName: userToken.GetSafeName(), + IsStatic: false, + Expiry: userToken.Expiry(), + Roles: userToken.GetRoles(), + Method: types.JoinMethodToken, + } + tc.expected = append(tc.expected, userUiToken) + } + + for _, td := range tc.tokenData { + token, err := types.NewProvisionTokenFromSpec(td.name, td.expiry, types.ProvisionTokenSpecV2{ + Roles: td.roles, + }) + 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, len(tc.expected)) + require.Empty(t, cmp.Diff(resp.Items, tc.expected, cmpopts.IgnoreFields(ui.JoinToken{}, "Content"))) + }) + } +} + +func TestDeleteToken(t *testing.T) { + ctx := context.Background() + username := "test-user@example.com" + env := newWebPack(t, 1) + proxy := env.proxies[0] + pack := proxy.authPack(t, username, nil /* roles */) + endpoint := pack.clt.Endpoint("webapi", "tokens") + staticUIToken := ui.JoinToken{ + ID: "static-token", + SafeName: "************", + Roles: types.SystemRoles{types.RoleNode}, + Expiry: time.Unix(0, 0).UTC(), + IsStatic: true, + Method: types.JoinMethodToken, + Content: "kind: token\nmetadata:\n expires: \"1970-01-01T00:00:00Z\"\n labels:\n teleport.dev/origin: config-file\n name: static-token\nspec:\n join_method: token\n roles:\n - Node\nversion: v2\n", + } + + // create join token + token, err := types.NewProvisionTokenFromSpec("my-token", time.Now().UTC().Add(30*time.Minute), types.ProvisionTokenSpecV2{ + Roles: types.SystemRoles{ + types.RoleNode, + types.RoleDatabase, + }, + }) + require.NoError(t, err) + err = env.server.Auth().CreateToken(ctx, token) + require.NoError(t, err) + + // create password reset token + passwordToken, err := env.server.Auth().CreateResetPasswordToken(ctx, authclient.CreateUserTokenRequest{ + Name: username, + TTL: defaults.MaxSignupTokenTTL, + Type: authclient.UserTokenTypeResetPasswordInvite, + }) + require.NoError(t, err) + userToken, err := types.NewProvisionToken(passwordToken.GetName(), types.SystemRoles{types.RoleSignup}, passwordToken.Expiry()) + require.NoError(t, err) + + // should have static token + a signup token now + 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, 3 /* static + sign up + join */) + + // delete + req, err := http.NewRequest("DELETE", endpoint, nil) + require.NoError(t, err) + req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", pack.session.Token)) + req.Header.Set(HeaderTokenName, userToken.GetName()) + _, err = pack.clt.RoundTrip(func() (*http.Response, error) { + return pack.clt.HTTPClient().Do(req) + }) + require.NoError(t, err) + req.Header.Set(HeaderTokenName, token.GetName()) + _, err = pack.clt.RoundTrip(func() (*http.Response, error) { + return pack.clt.HTTPClient().Do(req) + }) + require.NoError(t, err) + + 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, 1 /* only static again */) + require.Empty(t, cmp.Diff(resp.Items, []ui.JoinToken{staticUIToken}, cmpopts.IgnoreFields(ui.JoinToken{}, "Content"))) +} + func TestGenerateAzureTokenName(t *testing.T) { t.Parallel() rule1 := types.ProvisionTokenSpecV2Azure_Rule{ diff --git a/lib/web/ui/join_token.go b/lib/web/ui/join_token.go new file mode 100644 index 0000000000000..be068482aa1ba --- /dev/null +++ b/lib/web/ui/join_token.go @@ -0,0 +1,86 @@ +// Teleport +// Copyright (C) 2024 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 ui + +import ( + "time" + + yaml "github.com/ghodss/yaml" + "github.com/gravitational/trace" + + "github.com/gravitational/teleport/api/types" +) + +// JoinToken is a UI-friendly representation of a JoinToken +type JoinToken struct { + // ID is the name of the token + ID string `json:"id"` + // SafeName returns the name of the token, sanitized appropriately for + // join methods where the name is secret. + SafeName string `json:"safeName"` + // BotName is the name of the bot this token grants access to, if any + BotName string `json:"bot_name"` + // Expiry is the time that the token resource expires. Tokens that do not expire + // should expect a zero value time to be returned. + Expiry time.Time `json:"expiry"` + // Roles are the roles granted to the token + Roles types.SystemRoles `json:"roles"` + // IsStatic is true if the token is statically configured + IsStatic bool `json:"isStatic"` + // Method is the join method that the token supports + Method types.JoinMethod `json:"method"` + // Allow is a list of allow rules + Allow []*types.TokenRule `json:"allow,omitempty"` + // GCP allows the configuration of options specific to the "gcp" join method. + GCP *types.ProvisionTokenSpecV2GCP `json:"gcp,omitempty"` + // Content is resource yaml content. + Content string `json:"content"` +} + +func MakeJoinToken(token types.ProvisionToken) (*JoinToken, error) { + content, err := yaml.Marshal(token) + if err != nil { + return nil, trace.Wrap(err) + } + uiToken := &JoinToken{ + ID: token.GetName(), + SafeName: token.GetSafeName(), + BotName: token.GetBotName(), + Expiry: token.Expiry(), + Roles: token.GetRoles(), + IsStatic: token.IsStatic(), + Method: token.GetJoinMethod(), + Allow: token.GetAllowRules(), + Content: string(content[:]), + } + + if uiToken.Method == types.JoinMethodGCP { + uiToken.GCP = token.GetGCPRules() + } + return uiToken, nil +} + +func MakeJoinTokens(tokens []types.ProvisionToken) (joinTokens []JoinToken, err error) { + for _, t := range tokens { + uiToken, err := MakeJoinToken(t) + if err != nil { + return nil, trace.Wrap(err) + } + joinTokens = append(joinTokens, *uiToken) + } + return joinTokens, nil +} diff --git a/web/packages/shared/components/ToolTip/HoverTooltip.tsx b/web/packages/shared/components/ToolTip/HoverTooltip.tsx index ef82bb0c08704..f761be484d89b 100644 --- a/web/packages/shared/components/ToolTip/HoverTooltip.tsx +++ b/web/packages/shared/components/ToolTip/HoverTooltip.tsx @@ -32,6 +32,7 @@ export const HoverTooltip: React.FC< className?: string; anchorOrigin?: OriginProps; transformOrigin?: OriginProps; + justifyContentProps?: { justifyContent: string }; }> > = ({ tipContent, @@ -40,6 +41,7 @@ export const HoverTooltip: React.FC< className, anchorOrigin = { vertical: 'top', horizontal: 'center' }, transformOrigin = { vertical: 'bottom', horizontal: 'center' }, + justifyContentProps = {}, }) => { const [anchorEl, setAnchorEl] = useState(); const open = Boolean(anchorEl); @@ -77,6 +79,7 @@ export const HoverTooltip: React.FC< onMouseEnter={handlePopoverOpen} onMouseLeave={handlePopoverClose} className={className} + {...justifyContentProps} > {children} { - const token = { id: 'token', expiryText: '', expiry: null }; + const token = { + id: 'token', + expiryText: '', + expiry: null, + safeName: '', + isStatic: false, + method: 'kubernetes', + roles: [], + content: '', + }; render( { jest.spyOn(ctx.joinTokenService, 'fetchJoinToken').mockResolvedValue({ id: tokenName, expiry: new Date('2020-01-01'), + safeName: '', + isStatic: false, + method: 'kubernetes', + roles: [], + content: '', }); jest.spyOn(botService, 'createBot').mockResolvedValue(); jest.spyOn(botService, 'createBotToken').mockResolvedValue({ diff --git a/web/packages/teleport/src/Discover/Kubernetes/EnrollEKSCluster/AgentWaitingDialog.tsx b/web/packages/teleport/src/Discover/Kubernetes/EnrollEKSCluster/AgentWaitingDialog.tsx index 3d5b5b7db8cdb..177c280826dd2 100644 --- a/web/packages/teleport/src/Discover/Kubernetes/EnrollEKSCluster/AgentWaitingDialog.tsx +++ b/web/packages/teleport/src/Discover/Kubernetes/EnrollEKSCluster/AgentWaitingDialog.tsx @@ -56,6 +56,11 @@ export function AgentWaitingDialog({ // These are not used by usePingTeleport // todo(anton): Refactor usePingTeleport to not require full join token. expiry: undefined, + safeName: '', + isStatic: false, + method: 'kubernetes', + roles: [], + content: '', expiryText: '', id: '', suggestedLabels: [], diff --git a/web/packages/teleport/src/JoinTokens/JoinTokenForms.tsx b/web/packages/teleport/src/JoinTokens/JoinTokenForms.tsx new file mode 100644 index 0000000000000..e511ded3df6bf --- /dev/null +++ b/web/packages/teleport/src/JoinTokens/JoinTokenForms.tsx @@ -0,0 +1,230 @@ +/** + * Teleport + * Copyright (C) 2024 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 . + */ + +import React from 'react'; +import { Flex, Text, ButtonIcon, ButtonText } from 'design'; +import { Plus, Trash } from 'design/Icon'; +import { requiredField } from 'shared/components/Validation/rules'; +import FieldInput from 'shared/components/FieldInput'; +import { FieldSelectCreatable } from 'shared/components/FieldSelect'; + +import { NewJoinTokenState, OptionGCP, RuleBox } from './UpsertJoinTokenDialog'; + +export const JoinTokenIAMForm = ({ + tokenState, + onUpdateState, +}: { + tokenState: NewJoinTokenState; + onUpdateState: (newToken: NewJoinTokenState) => void; +}) => { + const rules = tokenState.iam; + + function removeRule(index: number) { + const newRules = rules.filter((_, i) => index !== i); + const newState = { + ...tokenState, + iam: newRules, + }; + onUpdateState(newState); + } + + function setTokenRulesField( + ruleIndex: number, + fieldName: string, + value: string + ) { + const newState = { + ...tokenState, + [tokenState.method.value]: tokenState[tokenState.method.value].map( + (rule, i) => { + if (ruleIndex !== i) { + return rule; + } + return { + ...rule, + [fieldName]: value, + }; + } + ), + }; + onUpdateState(newState); + } + + function addNewRule() { + const newState = { + ...tokenState, + iam: [...tokenState.iam, { aws_account: '' }], + }; + onUpdateState(newState); + } + + return ( + <> + {rules.map((rule, index) => ( + + + + AWS Rule + + + {rules.length > 1 && ( // at least one rule is required, so lets not allow the user to remove it + removeRule(index)} + > + + + )} + + + setTokenRulesField(index, 'aws_account', e.target.value) + } + /> + setTokenRulesField(index, 'aws_arn', e.target.value)} + /> + + ))} + + + Add another AWS Rule + + + ); +}; + +export const JoinTokenGCPForm = ({ + tokenState, + onUpdateState, +}: { + tokenState: NewJoinTokenState; + onUpdateState: (newToken: NewJoinTokenState) => void; +}) => { + const rules = tokenState.gcp; + function removeRule(index: number) { + const newRules = rules.filter((_, i) => index !== i); + const newState = { + ...tokenState, + gcp: newRules, + }; + onUpdateState(newState); + } + + function addNewRule() { + const newState = { + ...tokenState, + gcp: [ + ...tokenState.gcp, + { project_ids: [], locations: [], service_accounts: [] }, + ], + }; + onUpdateState(newState); + } + + function updateRuleField( + index: number, + fieldName: string, + opts: OptionGCP[] + ) { + const newState = { + ...tokenState, + gcp: tokenState.gcp.map((rule, i) => { + if (i === index) { + return { ...rule, [fieldName]: opts }; + } + return rule; + }), + }; + onUpdateState(newState); + } + + return ( + <> + {rules.map((rule, index) => ( + + + + GCP Rule + + + {rules.length > 1 && ( // at least one rule is required, so lets not allow the user to remove it + removeRule(index)} + > + + + )} + + + updateRuleField(index, 'project_ids', opts as OptionGCP[]) + } + value={rule.project_ids} + label="Add Project ID(s)" + rule={requiredField('At least 1 Project ID required')} + /> + + updateRuleField(index, 'locations', opts as OptionGCP[]) + } + value={rule.locations} + label="Add Locations" + labelTip="Allows regions and/or zones." + /> + + updateRuleField(index, 'service_accounts', opts as OptionGCP[]) + } + value={rule.service_accounts} + label="Add Service Account Emails" + /> + + ))} + + + Add another GCP Rule + + + ); +}; diff --git a/web/packages/teleport/src/JoinTokens/JoinTokens.story.tsx b/web/packages/teleport/src/JoinTokens/JoinTokens.story.tsx new file mode 100644 index 0000000000000..3dfdea17ab491 --- /dev/null +++ b/web/packages/teleport/src/JoinTokens/JoinTokens.story.tsx @@ -0,0 +1,130 @@ +/** + * 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 . + */ + +import React from 'react'; +import { MemoryRouter } from 'react-router'; +import { rest } from 'msw'; +import { initialize, mswLoader } from 'msw-storybook-addon'; + +import { ContextProvider } from 'teleport'; +import cfg from 'teleport/config'; +import { JoinToken } from 'teleport/services/joinToken'; +import { createTeleportContext } from 'teleport/mocks/contexts'; + +import { JoinTokens } from './JoinTokens'; + +export default { + title: 'Teleport/JoinTokens', + loaders: [mswLoader], +}; + +initialize(); + +export const Loaded = () => ( + + + +); + +Loaded.parameters = { + msw: { + handlers: [ + rest.get(cfg.api.joinTokensPath, (req, res, ctx) => { + return res.once(ctx.json({ items: tokens })); + }), + rest.put(cfg.api.joinTokenYamlPath, (req, res, ctx) => { + return res.once(ctx.json(editedToken)); + }), + ], + }, +}; + +const Provider = ({ children }) => { + const ctx = createTeleportContext(); + + return ( + + {children} + + ); +}; + +const tokens: JoinToken[] = [ + { + id: 'token1', + roles: ['Node'], + isStatic: true, + expiry: new Date('0001-01-01'), + method: 'token', + safeName: '******', + allow: [], + gcp: { + allow: [], + }, + content: '', + }, + { + id: 'iam-EDIT-ME-BUT-DONT-SAVE', + roles: ['Node', 'App', 'Trusted_cluster'], + isStatic: false, + expiry: new Date('2023-06-01'), + method: 'iam', + safeName: 'iam-EDIT-ME-BUT-DONT-SAVE', + allow: [], + gcp: { + allow: [], + }, + content: `kind: token + metadata: + name: iam-EDIT-ME-BUT-DONT-SAVE + revision: d018f5e3-774f-43b5-a349-4529147eab2c + spec: + gcp: + allow: + - project_ids: + - "123123" + join_method: iam + roles: + - Node + version: v2 + `, + }, +]; + +const editedToken: JoinToken = { + id: 'iam-I-SAID-DONT-SAVE', + roles: ['Node', 'App', 'Trusted_cluster'], + isStatic: false, + expiry: new Date(), + method: 'iam', + safeName: 'iam-I-SAID-DONT-SAVE', + content: `kind: token + metadata: + name: iam-I-SAID-DONT-SAVE + revision: d018f5e3-774f-43b5-a349-4529147eab2c + spec: + gcp: + allow: + - project_ids: + - "123123" + join_method: iam + roles: + - Node + version: v2 + `, +}; diff --git a/web/packages/teleport/src/JoinTokens/JoinTokens.test.tsx b/web/packages/teleport/src/JoinTokens/JoinTokens.test.tsx new file mode 100644 index 0000000000000..8a14145e8303a --- /dev/null +++ b/web/packages/teleport/src/JoinTokens/JoinTokens.test.tsx @@ -0,0 +1,191 @@ +/** + * 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 . + */ +import { render, screen, fireEvent } from 'design/utils/testing'; +import userEvent from '@testing-library/user-event'; +import { within } from '@testing-library/react'; + +import { createTeleportContext } from 'teleport/mocks/contexts'; +import { ContextProvider } from 'teleport'; +import makeJoinToken from 'teleport/services/joinToken/makeJoinToken'; + +import { JoinTokens } from './JoinTokens'; + +describe('JoinTokens', () => { + test('create dialog opens', async () => { + render(); + await userEvent.click( + screen.getByRole('button', { name: /create new token/i }) + ); + + expect(screen.getByText(/create a new join token/i)).toBeInTheDocument(); + }); + + test('edit dialog opens with values', async () => { + const token = tokens[0]; + render(); + const optionButtons = await screen.findAllByText(/options/i); + await userEvent.click(optionButtons[0]); + const editButtons = await screen.findAllByText(/view\/edit/i); + await userEvent.click(editButtons[0]); + expect(screen.getByText(/edit token/i)).toBeInTheDocument(); + + expect(screen.getByDisplayValue(token.id)).toBeInTheDocument(); + expect( + screen.getByDisplayValue(token.allow[0].aws_account) + ).toBeInTheDocument(); + }); + + test('create form fails if roles arent selected', async () => { + render(); + await userEvent.click( + screen.getByRole('button', { name: /create new token/i }) + ); + + fireEvent.change(screen.getByPlaceholderText('iam-token-name'), { + target: { value: 'the_token' }, + }); + + fireEvent.click(screen.getByRole('button', { name: /create join token/i })); + expect( + screen.getByText('At least one role is required') + ).toBeInTheDocument(); + }); + + test('successful create adds token to the table', async () => { + render(); + await userEvent.click( + screen.getByRole('button', { name: /create new token/i }) + ); + + fireEvent.change(screen.getByPlaceholderText('iam-token-name'), { + target: { value: 'the_token' }, + }); + + const inputEl = within(screen.getByTestId('role_select')).getByRole( + 'textbox' + ); + fireEvent.change(inputEl, { target: { value: 'Node' } }); + fireEvent.focus(inputEl); + fireEvent.keyDown(inputEl, { key: 'Enter', keyCode: 13 }); + + fireEvent.click(screen.getByRole('button', { name: /create join token/i })); + expect( + screen.queryByText('At least one role is required') + ).not.toBeInTheDocument(); + fireEvent.change(screen.getByPlaceholderText('AWS Account ID'), { + target: { value: '123123123' }, + }); + + await userEvent.click( + screen.getByRole('button', { name: /create join token/i }) + ); + + expect( + screen.queryByText(/create a new join token/i) + ).not.toBeInTheDocument(); + expect(screen.getByText('the_token')).toBeInTheDocument(); + }); + + test('a rule cannot be deleted if it is the only rule', async () => { + render(); + await userEvent.click( + screen.getByRole('button', { name: /create new token/i }) + ); + + const buttons = screen.queryAllByTestId('delete_rule'); + expect(buttons).toHaveLength(0); + }); + + test('a rule can be deleted more than one rule exists', async () => { + render(); + await userEvent.click( + screen.getByRole('button', { name: /create new token/i }) + ); + + fireEvent.click(screen.getByText('Add another AWS Rule')); + + const buttons = screen.queryAllByTestId('delete_rule'); + expect(buttons).toHaveLength(2); + }); +}); + +const Component = () => { + const ctx = createTeleportContext(); + jest + .spyOn(ctx.joinTokenService, 'fetchJoinTokens') + .mockResolvedValue({ items: tokens.map(makeJoinToken) }); + + jest.spyOn(ctx.joinTokenService, 'createJoinToken').mockResolvedValue( + makeJoinToken({ + id: 'the_token', + safeName: 'the_token', + bot_name: '', + expiry: '3024-07-26T11:52:48.320045Z', + roles: ['Node'], + isStatic: false, + method: 'iam', + allow: [ + { + aws_account: '1234444', + aws_arn: 'asdf', + }, + ], + content: 'fake content', + }) + ); + + return ( + + + + ); +}; + +const tokens = [ + { + id: '123123ffff', + safeName: '123123ffff', + bot_name: '', + expiry: '3024-07-26T11:52:48.320045Z', + roles: ['Node'], + isStatic: false, + method: 'iam', + allow: [ + { + aws_account: '1234444', + aws_arn: 'asdf', + }, + ], + content: 'fake content', + }, + { + id: 'rrrrr', + safeName: 'rrrrr', + bot_name: '7777777', + expiry: '3024-07-26T12:05:48.08241Z', + roles: ['Bot', 'Node'], + isStatic: false, + method: 'iam', + allow: [ + { + aws_account: '445555444', + }, + ], + content: 'fake content', + }, +]; diff --git a/web/packages/teleport/src/JoinTokens/JoinTokens.tsx b/web/packages/teleport/src/JoinTokens/JoinTokens.tsx new file mode 100644 index 0000000000000..bbd6ac1720ac3 --- /dev/null +++ b/web/packages/teleport/src/JoinTokens/JoinTokens.tsx @@ -0,0 +1,457 @@ +/** + * Teleport + * Copyright (C) 2024 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 . + */ + +import styled from 'styled-components'; +import { useEffect, useState } from 'react'; +import { isAfter, addHours } from 'date-fns'; +import { + Box, + Text, + Flex, + Indicator, + Label, + Alert, + Link, + MenuItem, + ButtonWarning, + ButtonSecondary, + Button, +} from 'design'; +import Table, { Cell } from 'design/DataTable'; +import { Warning } from 'design/Icon'; +import Dialog, { + DialogContent, + DialogFooter, + DialogHeader, + DialogTitle, +} from 'design/Dialog'; +import { MenuButton } from 'shared/components/MenuAction'; +import { Attempt, useAsync } from 'shared/hooks/useAsync'; +import { HoverTooltip } from 'shared/components/ToolTip'; +import { CopyButton } from 'shared/components/UnifiedResources/shared/CopyButton'; + +import { useTeleport } from 'teleport'; +import useResources from 'teleport/components/useResources'; + +import { + FeatureBox, + FeatureHeader, + FeatureHeaderTitle, +} from 'teleport/components/Layout'; +import { JoinToken } from 'teleport/services/joinToken'; +import { Resource, KindJoinToken } from 'teleport/services/resources'; +import ResourceEditor from 'teleport/components/ResourceEditor'; + +import { UpsertJoinTokenDialog } from './UpsertJoinTokenDialog'; + +function makeTokenResource(token: JoinToken): Resource { + return { + id: token.id, + name: token.safeName, + kind: 'join_token', + content: token.content, + }; +} + +export const JoinTokens = () => { + const ctx = useTeleport(); + const [creatingToken, setCreatingToken] = useState(false); + const [editingToken, setEditingToken] = useState(null); + const [tokenToDelete, setTokenToDelete] = useState(null); + const [joinTokensAttempt, runJoinTokensAttempt, setJoinTokensAttempt] = + useAsync(async () => await ctx.joinTokenService.fetchJoinTokens()); + + const resources = useResources( + joinTokensAttempt.data?.items.map(makeTokenResource) || [], + { join_token: '' } // we are only editing for now, so template can be empty + ); + + function updateTokenList(token: JoinToken): JoinToken[] { + let items = [...joinTokensAttempt.data.items]; + if (creatingToken) { + items.push(token); + } else { + const newItems = items.map(item => { + if (item.id === token.id) { + return token; + } + return item; + }); + items = newItems; + } + setJoinTokensAttempt({ + data: { ...joinTokensAttempt.data, items }, + status: 'success', + statusText: '', + }); + return items; + } + + async function handleSave(content: string): Promise { + const token = await ctx.joinTokenService.upsertJoinTokenYAML( + { content }, + resources.item.id + ); + updateTokenList(token); + } + + const [deleteTokenAttempt, runDeleteTokenAttempt, setDeleteTokenAttempt] = + useAsync(async (token: string) => { + await ctx.joinTokenService.deleteJoinToken(token); + setJoinTokensAttempt({ + status: 'success', + statusText: '', + data: { + items: joinTokensAttempt.data.items.filter(t => t.id !== token), + }, + }); + setTokenToDelete(null); + setEditingToken(null); + setCreatingToken(false); + }); + + useEffect(() => { + runJoinTokensAttempt(); + }, []); + + return ( + + + Join Tokens + {!creatingToken && !editingToken && ( + + )} + + + + {joinTokensAttempt.status === 'error' && ( + {joinTokensAttempt.statusText} + )} + {joinTokensAttempt.status === 'success' && ( + , + }, + { + key: 'method', + headerText: 'Join Method', + isSortable: true, + }, + { + key: 'roles', + headerText: 'Roles', + isSortable: false, + render: renderRolesCell, + }, + // expiryText is non render and used for searching + { + key: 'expiryText', + isNonRender: true, + }, + // expiry is used for sorting, but we display the expiryText value + { + key: 'expiry', + headerText: 'Expires in', + isSortable: true, + render: ({ expiry, expiryText, isStatic, method }) => { + const now = new Date(); + const isLongLived = + isAfter(expiry, addHours(now, 24)) && method === 'token'; + return ( + + + {expiryText} + {(isLongLived || isStatic) && ( + + + + )} + + + ); + }, + }, + { + altKey: 'options-btn', + render: (token: JoinToken) => ( + { + // prefer editing in the standard form + // if we support that join method + if ( + token.method === 'iam' || + token.method === 'gcp' || + token.method === 'token' + ) { + setEditingToken(token); + return; + } + // otherwise, edit in yaml editor + setEditingToken(null); // close any editing token + resources.edit(token.id); + }} + onDelete={() => setTokenToDelete(token)} + /> + ), + }, + ]} + emptyText="No active join tokens found" + pagination={{ pageSize: 30, pagerPosition: 'top' }} + customSearchMatchers={[searchMatcher]} + initialSort={{ + key: 'expiry', + dir: 'ASC', + }} + /> + )} + {joinTokensAttempt.status === 'processing' && ( + + + + )} + + + {(creatingToken || !!editingToken) && ( + { + setCreatingToken(false); + setEditingToken(null); + }} + /> + )} + + {tokenToDelete && ( + { + setDeleteTokenAttempt({ + status: 'success', + statusText: '', + data: null, + }); + setTokenToDelete(null); + }} + onDelete={() => runDeleteTokenAttempt(tokenToDelete.id)} + attempt={deleteTokenAttempt} + /> + )} + {resources.status === 'editing' && ( + } + kind={'join_token'} + /> + )} + + ); +}; + +export function searchMatcher( + targetValue: any, + searchValue: string, + propName: keyof JoinToken & string +) { + if (propName === 'roles') { + return targetValue.some((role: string) => + role.toUpperCase().includes(searchValue) + ); + } +} + +const renderRolesCell = ({ roles }: JoinToken) => { + return ( + + {roles.map(role => ( + {role} + ))} + + ); +}; + +const NameCell = ({ token }: { token: JoinToken }) => { + const { id, safeName, method } = token; + const [hovered, setHovered] = useState(false); + return ( + setHovered(true)} + onMouseLeave={() => setHovered(false)} + > + + + {method !== 'token' ? id : safeName} + + {hovered && } + + + ); +}; + +const StyledLabel = styled(Label)` + height: 20px; + margin: 1px 0; + margin-right: ${props => props.theme.space[2]}px; + background-color: ${props => props.theme.colors.interactive.tonal.neutral[0]}; + color: ${props => props.theme.colors.text.main}; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + line-height: 20px; +`; + +function TokenDelete({ + token, + onDelete, + onClose, + attempt, +}: { + token: JoinToken; + onDelete: (token: string) => Promise; + onClose: () => void; + attempt: Attempt; +}) { + return ( + ({ maxWidth: '500px', width: '100%' })} + disableEscapeKeyDown={false} + onClose={close} + open={true} + > + + Delete Join Token? + + + {attempt.status === 'error' && } + + You are about to delete join token + + {` ${token.safeName}`} + + . This will not remove any resources that used this token to join the + cluster. This will remove the ability for any new resources to join + with this token and any non-renewable resource from renewing. + + + + + I understand, delete token + + Cancel + + + ); +} + +const ActionCell = ({ + onEdit, + onDelete, + token, +}: { + onEdit(): void; + onDelete(): void; + token: JoinToken; +}) => { + const buttonProps = { width: '100px' }; + if (token.isStatic) { + return ( + + + + + + ); + } + return ( + + + View/Edit... + Delete... + + + ); +}; + +function Directions() { + return ( + <> + WARNING Roles are defined using{' '} + + YAML format + + . YAML is sensitive to white space, so please be careful. + + ); +} diff --git a/web/packages/teleport/src/JoinTokens/UpsertJoinTokenDialog.tsx b/web/packages/teleport/src/JoinTokens/UpsertJoinTokenDialog.tsx new file mode 100644 index 0000000000000..ffa07787cfaba --- /dev/null +++ b/web/packages/teleport/src/JoinTokens/UpsertJoinTokenDialog.tsx @@ -0,0 +1,366 @@ +/** + * Teleport + * Copyright (C) 2024 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 . + */ + +import { useState } from 'react'; + +import { + Flex, + Text, + Box, + ButtonIcon, + ButtonText, + ButtonPrimary, + ButtonSecondary, + Alert, +} from 'design'; +import styled from 'styled-components'; +import { HoverTooltip } from 'shared/components/ToolTip'; +import { Cross } from 'design/Icon'; +import Validation from 'shared/components/Validation'; +import FieldInput from 'shared/components/FieldInput'; +import { requiredField } from 'shared/components/Validation/rules'; +import { FieldSelect } from 'shared/components/FieldSelect'; +import { Option } from 'shared/components/Select'; +import { useAsync } from 'shared/hooks/useAsync'; + +import { useTeleport } from 'teleport'; +import { + AWSRules, + CreateJoinTokenRequest, + JoinMethod, + JoinRole, + JoinToken, +} from 'teleport/services/joinToken'; + +import { JoinTokenGCPForm, JoinTokenIAMForm } from './JoinTokenForms'; + +const maxWidth = '550px'; + +const joinRoleOptions: OptionJoinRole[] = [ + 'App', + 'Node', + 'Db', + 'Kube', + 'Bot', + 'WindowsDesktop', + 'Discovery', +].map(role => ({ value: role as JoinRole, label: role as JoinRole })); + +const availableJoinMethods: OptionJoinMethod[] = ['iam', 'gcp'].map(method => ({ + value: method as JoinMethod, + label: method as JoinMethod, +})); + +export type OptionGCP = Option; +type OptionJoinMethod = Option; +type OptionJoinRole = Option; +type NewJoinTokenGCPState = { + project_ids: OptionGCP[]; + service_accounts: OptionGCP[]; + locations: OptionGCP[]; +}; + +export type NewJoinTokenState = { + name: string; + // bot_name is only required when Bot is selected in the roles + bot_name?: string; + method: OptionJoinMethod; + roles: OptionJoinRole[]; + iam: AWSRules[]; + gcp: NewJoinTokenGCPState[]; +}; + +export const defaultNewTokenState: NewJoinTokenState = { + name: '', + bot_name: '', + method: { value: 'iam', label: 'iam' }, + roles: [], + iam: [{ aws_account: '', aws_arn: '' }], + gcp: [{ project_ids: [], service_accounts: [], locations: [] }], +}; + +function makeDefaultEditState(token: JoinToken): NewJoinTokenState { + return { + name: token.id, + bot_name: token.bot_name, + method: { + value: token.method, + label: token.method, + } as OptionJoinMethod, + roles: token.roles.map(r => ({ value: r, label: r })) as OptionJoinRole[], + iam: token.allow, + gcp: token.gcp?.allow.map(r => ({ + project_ids: r.project_ids?.map(i => ({ value: i, label: i })), + service_accounts: r.service_accounts?.map(i => ({ value: i, label: i })), + locations: r.locations?.map(i => ({ value: i, label: i })), + })), + }; +} + +export const UpsertJoinTokenDialog = ({ + onClose, + updateTokenList, + editToken, + editTokenWithYAML, +}: { + onClose(): void; + updateTokenList: (token: JoinToken) => void; + editToken?: JoinToken; + editTokenWithYAML: (tokenId: string) => void; +}) => { + const ctx = useTeleport(); + const [newTokenState, setNewTokenState] = useState( + editToken ? makeDefaultEditState(editToken) : defaultNewTokenState + ); + + const [createTokenAttempt, runCreateTokenAttempt] = useAsync( + async (req: CreateJoinTokenRequest) => { + const token = await ctx.joinTokenService.createJoinToken(req); + updateTokenList(token); + onClose(); + } + ); + + function reset(validator) { + validator.reset(); + setNewTokenState(defaultNewTokenState); + } + + async function save(validator) { + if (!validator.validate()) { + return; + } + + const request: CreateJoinTokenRequest = { + name: newTokenState.name, + roles: newTokenState.roles.map(r => r.value), + join_method: newTokenState.method.value, + }; + + if (newTokenState.method.value === 'iam') { + request.allow = newTokenState.iam; + } + + if (request.roles.includes('Bot')) { + request.bot_name = newTokenState.bot_name; + } + + if (newTokenState.method.value === 'gcp') { + const gcp = { + allow: newTokenState.gcp.map(rule => ({ + project_ids: rule.project_ids?.map(id => id.value), + locations: rule.locations?.map(loc => loc.value), + service_accounts: rule.service_accounts?.map( + account => account.value + ), + })), + }; + request.gcp = gcp; + } + + runCreateTokenAttempt(request); + } + + function setTokenRoles(roles: OptionJoinRole[]) { + setNewTokenState(prevState => ({ + ...prevState, + roles: roles || [], + })); + } + + function setTokenMethod(method: OptionJoinMethod) { + // set the method and reset the token rules per type for a fresh form + setNewTokenState(prevState => ({ + ...prevState, + method, + iam: [{ aws_account: '', aws_arn: '' }], // default + })); + } + + function setTokenField(fieldName: string, value: string) { + setNewTokenState(prevState => ({ + ...prevState, + [fieldName]: value, + })); + } + + return ( + + + + + + + + + + + {editToken ? `Edit Token` : 'Create a New Join Token'} + + + {editToken && ( + { + onClose(); + editTokenWithYAML(editToken.id); + }} + > + Use YAML editor + + )} + + + {({ validator }) => ( + + {createTokenAttempt.status === 'error' && ( + {createTokenAttempt.statusText} + )} + {!editToken && ( // We only want to change the method when creating a new token + + )} + {newTokenState.method.value !== 'token' && ( // if the method is token, we generate the name for them on the backend + setTokenField('name', e.target.value)} + readonly={!!editToken} + /> + )} + + {newTokenState.roles.some(i => i.value === 'Bot') && ( // if Bot is included, we must get a bot name as well + setTokenField('bot_name', e.target.value)} + /> + )} + {newTokenState.method.value === 'iam' && ( + setNewTokenState(newState)} + /> + )} + {newTokenState.method.value === 'gcp' && ( + setNewTokenState(newState)} + /> + )} + theme.colors.levels.sunken}; + border-top: 1px solid + ${props => props.theme.colors.spotBackground[1]}; + `} + > + save(validator)} + disabled={createTokenAttempt.status === 'processing'} + > + {editToken ? 'Edit' : 'Create'} Join Token + + { + reset(validator); + onClose(); + }} + disabled={false} + > + Cancel + + + + )} + + + + ); +}; + +export const RuleBox = styled(Box)` + border-color: ${props => props.theme.colors.interactive.tonal.neutral[0]}; + border-width: 2px; + border-style: solid; + border-radius: ${props => props.theme.radii[2]}px; + + margin-bottom: ${props => props.theme.space[3]}px; + + padding: ${props => props.theme.space[3]}px; +`; diff --git a/web/packages/teleport/src/config.ts b/web/packages/teleport/src/config.ts index 2849e35ac12c0..4e10451ce5954 100644 --- a/web/packages/teleport/src/config.ts +++ b/web/packages/teleport/src/config.ts @@ -139,6 +139,7 @@ const cfg = { accountPassword: '/web/account/password', accountMfaDevices: '/web/account/twofactor', roles: '/web/roles', + joinTokens: '/web/tokens', deviceTrust: `/web/devices`, deviceTrustAuthorize: '/web/device/authorize/:id?/:token?', sso: '/web/sso', @@ -258,6 +259,8 @@ const cfg = { connectMyComputerLoginsPath: '/v1/webapi/connectmycomputer/logins', joinTokenPath: '/v1/webapi/token', + joinTokenYamlPath: '/v1/webapi/tokens/yaml', + joinTokensPath: '/v1/webapi/tokens', dbScriptPath: '/scripts/:token/install-database.sh', nodeScriptPath: '/scripts/:token/install-node.sh', appNodeScriptPath: '/scripts/:token/install-app.sh?name=:name&uri=:uri', @@ -483,10 +486,22 @@ const cfg = { return generatePath(cfg.routes.desktops, { clusterId }); }, + getJoinTokensRoute() { + return cfg.routes.joinTokens; + }, + + getJoinTokensUrl() { + return cfg.api.joinTokensPath; + }, + getJoinTokenUrl() { return cfg.api.joinTokenPath; }, + getJoinTokenYamlUrl() { + return cfg.api.joinTokenYamlPath; + }, + getNodeScriptUrl(token: string) { return cfg.baseUrl + generatePath(cfg.api.nodeScriptPath, { token }); }, diff --git a/web/packages/teleport/src/features.tsx b/web/packages/teleport/src/features.tsx index 2f310b7b0fdce..15bc9cc24f935 100644 --- a/web/packages/teleport/src/features.tsx +++ b/web/packages/teleport/src/features.tsx @@ -25,6 +25,7 @@ import { ClipboardUser, Cluster, Integrations as IntegrationsIcon, + Key, Laptop, ListAddCheck, ListThin, @@ -68,6 +69,7 @@ import { LockedAccessRequests } from './AccessRequests'; import { Integrations } from './Integrations'; import { Bots } from './Bots'; import { AddBots } from './Bots/Add'; +import { JoinTokens } from './JoinTokens/JoinTokens'; import type { FeatureFlags, TeleportFeature } from './types'; @@ -121,6 +123,31 @@ export class FeatureNodes implements TeleportFeature { } } +// TODO (avatus) add navigationItem when ready to release +export class FeatureJoinTokens implements TeleportFeature { + category = NavigationCategory.Management; + section = ManagementSection.Access; + navigationItem = { + title: NavTitle.JoinTokens, + icon: Key, + exact: true, + getLink() { + return cfg.getJoinTokensRoute(); + }, + }; + + route = { + title: NavTitle.JoinTokens, + path: cfg.routes.joinTokens, + exact: true, + component: JoinTokens, + }; + + hasAccess(flags: FeatureFlags): boolean { + return flags.tokens; + } +} + export class FeatureUnifiedResources implements TeleportFeature { route = { title: 'Resources', @@ -626,6 +653,7 @@ export function getOSSFeatures(): TeleportFeature[] { new FeatureAddBots(), new FeatureAuthConnectors(), new FeatureIntegrations(), + new FeatureJoinTokens(), new FeatureDiscover(), new FeatureIntegrationEnroll(), diff --git a/web/packages/teleport/src/services/api/api.test.ts b/web/packages/teleport/src/services/api/api.test.ts index 3f840f4345a3a..a662a5fcc4d90 100644 --- a/web/packages/teleport/src/services/api/api.test.ts +++ b/web/packages/teleport/src/services/api/api.test.ts @@ -120,6 +120,31 @@ describe('api.fetch', () => { }, }); }); + + const customContentType = { + ...customOpts, + headers: { + Accept: 'application/json', + 'Content-Type': 'multipart/form-data', + }, + }; + + test('with customOptions including custom content-type', async () => { + await api.fetch('/something', customContentType, null); + expect(mockedFetch).toHaveBeenCalledTimes(1); + + const firstCall = mockedFetch.mock.calls[0]; + const [, actualRequestOptions] = firstCall; + + expect(actualRequestOptions).toStrictEqual({ + ...defaultRequestOptions, + ...customOpts, + headers: { + ...customContentType.headers, + ...getAuthHeaders(), + }, + }); + }); }); // The code below should guard us from changes to api.fetchJson which would cause it to lose type diff --git a/web/packages/teleport/src/services/api/api.ts b/web/packages/teleport/src/services/api/api.ts index db11784edd48b..8490cecac2125 100644 --- a/web/packages/teleport/src/services/api/api.ts +++ b/web/packages/teleport/src/services/api/api.ts @@ -77,6 +77,24 @@ const api = { ); }, + deleteWithHeaders( + url, + headers?: Record, + signal?, + webauthnResponse?: WebauthnAssertionResponse + ) { + return api.fetchJsonWithMfaAuthnRetry( + url, + { + method: 'DELETE', + headers, + signal, + }, + webauthnResponse + ); + }, + + // TODO (avatus) add abort signal to this put(url, data, webauthnResponse?: WebauthnAssertionResponse) { return api.fetchJsonWithMfaAuthnRetry( url, @@ -88,6 +106,23 @@ const api = { ); }, + putWithHeaders( + url, + data, + headers?: Record, + webauthnResponse?: WebauthnAssertionResponse + ) { + return api.fetchJsonWithMfaAuthnRetry( + url, + { + body: JSON.stringify(data), + method: 'PUT', + headers, + }, + webauthnResponse + ); + }, + /** * fetchJsonWithMfaAuthnRetry calls on `api.fetch` and * processes the response. diff --git a/web/packages/teleport/src/services/joinToken/joinToken.ts b/web/packages/teleport/src/services/joinToken/joinToken.ts index df9483407c5a0..759ae40595cbb 100644 --- a/web/packages/teleport/src/services/joinToken/joinToken.ts +++ b/web/packages/teleport/src/services/joinToken/joinToken.ts @@ -24,7 +24,10 @@ import { makeLabelMapOfStrArrs } from '../agents/make'; import makeJoinToken from './makeJoinToken'; import { JoinToken, JoinRule, JoinTokenRequest } from './types'; +const TeleportTokenNameHeader = 'X-Teleport-TokenName'; + class JoinTokenService { + // TODO (avatus) refactor this code to eventually use `createJoinToken` fetchJoinToken( req: JoinTokenRequest, signal: AbortSignal = null @@ -44,6 +47,44 @@ class JoinTokenService { ) .then(makeJoinToken); } + + upsertJoinTokenYAML( + req: JoinTokenRequest, + tokenName: string + ): Promise { + return api + .putWithHeaders( + cfg.getJoinTokenYamlUrl(), + { + content: req.content, + }, + { + [TeleportTokenNameHeader]: tokenName, + 'Content-Type': 'application/json', + } + ) + .then(makeJoinToken); + } + + createJoinToken(req: JoinTokenRequest): Promise { + return api.post(cfg.getJoinTokensUrl(), req).then(makeJoinToken); + } + + fetchJoinTokens(signal: AbortSignal = null): Promise<{ items: JoinToken[] }> { + return api.get(cfg.getJoinTokensUrl(), signal).then(resp => { + return { + items: resp.items?.map(makeJoinToken) || [], + }; + }); + } + + deleteJoinToken(id: string, signal: AbortSignal = null) { + return api.deleteWithHeaders( + cfg.getJoinTokensUrl(), + { [TeleportTokenNameHeader]: id }, + signal + ); + } } function makeAllowField(rules: JoinRule[] = []) { diff --git a/web/packages/teleport/src/services/joinToken/makeJoinToken.ts b/web/packages/teleport/src/services/joinToken/makeJoinToken.ts index 3d4e4bbdf3a5a..57a134dfe3588 100644 --- a/web/packages/teleport/src/services/joinToken/makeJoinToken.ts +++ b/web/packages/teleport/src/services/joinToken/makeJoinToken.ts @@ -16,7 +16,7 @@ * along with this program. If not, see . */ -import { formatDistanceStrict } from 'date-fns'; +import { formatDistanceStrict, differenceInYears } from 'date-fns'; import type { JoinToken } from './types'; @@ -24,21 +24,60 @@ export const INTERNAL_RESOURCE_ID_LABEL_KEY = 'teleport.internal/resource-id'; export default function makeToken(json): JoinToken { json = json || {}; - const { id, expiry, suggestedLabels } = json; + const { + id, + roles, + isStatic, + allow, + gcp, + bot_name, + expiry, + method, + suggestedLabels, + safeName, + content, + } = json; const labels = suggestedLabels || []; return { id, + isStatic, + safeName, + bot_name, + method, + allow, + gcp, + roles: roles?.sort((a, b) => a.localeCompare(b)) || [], suggestedLabels: labels, internalResourceId: extractInternalResourceId(labels), expiry: expiry ? new Date(expiry) : null, - expiryText: expiry - ? formatDistanceStrict(new Date(), new Date(expiry)) - : '', + expiryText: getExpiryText(expiry, isStatic), + content, }; } +function getExpiryText(expiry: string, isStatic: boolean): string { + const expiryDate = new Date(expiry); + const now = new Date(); + + // dynamically configured tokens that "never expire" are set to actually expire + // 1000 years from now. We can just check if the expiry date is over 100 years away + // and show a "never" text instead of 999years. If a customer is still running teleport + // and using this token for over 100 years and they see the 899, maybe they + // actually care about the date. + const yearsDifference = differenceInYears(expiryDate, now); + // a manually configured token with no TTL will be set to zero date + if (expiry == '0001-01-01T00:00:00Z' || isStatic || yearsDifference > 100) { + return 'never'; + } + if (!expiry) { + return ''; + } + + return formatDistanceStrict(now, expiryDate); +} + function extractInternalResourceId(labels: any[]) { let resourceId = ''; labels.forEach(l => { diff --git a/web/packages/teleport/src/services/joinToken/types.ts b/web/packages/teleport/src/services/joinToken/types.ts index 0e7e95e7fe754..a36c1a975d1bd 100644 --- a/web/packages/teleport/src/services/joinToken/types.ts +++ b/web/packages/teleport/src/services/joinToken/types.ts @@ -20,6 +20,17 @@ import { ResourceLabel } from '../agents'; export type JoinToken = { id: string; + // safeName is the name represented by "*". If the name is longer than 16 chars, + // the first 16 chars will be * and the rest of the token's chars will be visible + // ex. ****************asdf1234 + safeName: string; + // bot_name is present on tokens with Bot in their join roles + bot_name?: string; + isStatic: boolean; + // the join method of the token + method: string; + // Roles are the roles granted to the token + roles: string[]; expiry: Date; expiryText?: string; // suggestedLabels are labels that the resource should add when adding @@ -30,6 +41,12 @@ export type JoinToken = { // // Extracted from suggestedLabels. internalResourceId?: string; + // yaml content of the resource + content: string; + allow?: AWSRules[]; + gcp?: { + allow: GCPRules[]; + }; }; // JoinRole defines built-in system roles and are roles associated with @@ -39,6 +56,7 @@ export type JoinToken = { // - 'Db' is a role for a database proxy in the cluster // - 'Kube' is a role for a kube service // - 'Node' is a role for a node in the cluster +// - 'Bot' for MachineID (when set, "spec.bot_name" must be set in the token) // - 'WindowsDesktop' is a role for a windows desktop service. // - 'Discovery' is a role for a discovery service. export type JoinRole = @@ -46,6 +64,7 @@ export type JoinRole = | 'Node' | 'Db' | 'Kube' + | 'Bot' | 'WindowsDesktop' | 'Discovery'; @@ -53,7 +72,17 @@ export type JoinRole = // Same hard-corded value as the backend. // - 'token' is the default method, where nodes join the cluster by // presenting a secret token. -export type JoinMethod = 'token' | 'ec2' | 'iam' | 'github'; +export type JoinMethod = + | 'token' + | 'ec2' + | 'iam' + | 'github' + | 'azure' + | 'gcp' + | 'circleci' + | 'gitlab' + | 'kubernetes' + | 'tpm'; // JoinRule is a rule that a joining node must match in order to use the // associated token. @@ -61,12 +90,43 @@ export type JoinRule = { awsAccountId: string; // awsArn is used for the IAM join method. awsArn?: string; + regions?: string[]; }; -export type JoinTokenRequest = { +export type AWSRules = { + aws_account: string; // naming kept consistent with backend spec + aws_arn?: string; +}; + +export type GCPRules = { + project_ids: string[]; + locations: string[]; + service_accounts: string[]; +}; + +export type JoinTokenRulesObject = AWSRules | GCPRules; + +export type CreateJoinTokenRequest = { + name: string; // roles is a list of join roles, since there can be more than // one role associated with a token. roles: JoinRole[]; + // bot_name only needs to be specified if "Bot" is in the selected roles. + // otherwise, it is ignored + bot_name?: string; + join_method: JoinMethod; + // rules is a list of allow rules associated with the join token + // and the node using this token must match one of the rules. + allow?: JoinTokenRulesObject[]; + gcp?: { + allow: GCPRules[]; + }; +}; + +export type JoinTokenRequest = { + // roles is a list of join roles, since there can be more than + // one role associated with a token. + roles?: JoinRole[]; // rules is a list of allow rules associated with the join token // and the node using this token must match one of the rules. rules?: JoinRule[]; @@ -76,4 +136,6 @@ export type JoinTokenRequest = { // means adding the labels to `db_service.resources.labels`. suggestedAgentMatcherLabels?: ResourceLabel[]; method?: JoinMethod; + // content is the yaml content of the joinToken to be created + content?: string; }; diff --git a/web/packages/teleport/src/services/resources/types.ts b/web/packages/teleport/src/services/resources/types.ts index c484518370afe..af22f0bb0dfa1 100644 --- a/web/packages/teleport/src/services/resources/types.ts +++ b/web/packages/teleport/src/services/resources/types.ts @@ -28,7 +28,12 @@ export type Resource = { export type KindRole = 'role'; export type KindTrustedCluster = 'trusted_cluster'; export type KindAuthConnectors = 'github' | 'saml' | 'oidc'; -export type Kind = KindRole | KindTrustedCluster | KindAuthConnectors; +export type KindJoinToken = 'join_token'; +export type Kind = + | KindRole + | KindTrustedCluster + | KindAuthConnectors + | KindJoinToken; /** Describes a Teleport role. */ export type RoleResource = Resource; diff --git a/web/packages/teleport/src/teleportContext.tsx b/web/packages/teleport/src/teleportContext.tsx index 1f40c5ceee3c6..2602b79de5a95 100644 --- a/web/packages/teleport/src/teleportContext.tsx +++ b/web/packages/teleport/src/teleportContext.tsx @@ -216,6 +216,7 @@ class TeleportContext implements types.Context { accessMonitoring: hasAccessMonitoringAccess(), managementSection: hasManagementSectionAccess(), accessGraph: userContext.getAccessGraphAccess().list, + tokens: userContext.getTokenAccess().create, externalAuditStorage: userContext.getExternalAuditStorageAccess().list, listBots: userContext.getBotsAccess().list, addBots: userContext.getBotsAccess().create, @@ -240,6 +241,7 @@ export const disabledFeatureFlags: types.FeatureFlags = { trustedClusters: false, users: false, newAccessRequest: false, + tokens: false, accessRequests: false, downloadCenter: false, supportLink: false, diff --git a/web/packages/teleport/src/types.ts b/web/packages/teleport/src/types.ts index 48579bafa17da..627755c6222bd 100644 --- a/web/packages/teleport/src/types.ts +++ b/web/packages/teleport/src/types.ts @@ -60,6 +60,7 @@ export enum NavTitle { Users = 'Users', Bots = 'Bots', Roles = 'User Roles', + JoinTokens = 'Join Tokens', AuthConnectors = 'Auth Connectors', Integrations = 'Integrations', EnrollNewResource = 'Enroll New Resource', @@ -180,6 +181,7 @@ export interface FeatureFlags { deviceTrust: boolean; locks: boolean; newLocks: boolean; + tokens: boolean; accessMonitoring: boolean; // Whether or not the management section should be available. managementSection: boolean;