From dd48a9af5ecb960ab085c27609d015ac16215854 Mon Sep 17 00:00:00 2001 From: Michael Date: Fri, 7 Jun 2024 15:59:11 -0700 Subject: [PATCH 1/7] Add token list/delete endpoints (#42402) This PR implements list/delete for tokens in the api the same way as tctl interacts with them. This does not include pagination yet but is currently being used while the feature is in development. Pagination can come in the future in the form of a sort cache, but the endpoints won't change --- api/types/provisioning.go | 16 +- api/types/statictokens.go | 2 +- lib/auth/auth_test.go | 4 +- lib/auth/helpers.go | 10 +- lib/auth/tls_test.go | 4 +- lib/auth/trustedcluster.go | 7 + lib/config/configuration_test.go | 2 +- lib/web/apiserver.go | 4 +- lib/web/join_tokens.go | 45 ++++++ lib/web/join_tokens_test.go | 250 +++++++++++++++++++++++++++++++ lib/web/ui/join_token.go | 61 ++++++++ 11 files changed, 393 insertions(+), 12 deletions(-) create mode 100644 lib/web/ui/join_token.go diff --git a/api/types/provisioning.go b/api/types/provisioning.go index 82cdaf05e2dfc..cfdc48a9ccbcc 100644 --- a/api/types/provisioning.go +++ b/api/types/provisioning.go @@ -127,7 +127,8 @@ type ProvisionToken interface { 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 @@ -394,6 +395,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 +541,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..36f78ad372913 100644 --- a/lib/web/apiserver.go +++ b/lib/web/apiserver.go @@ -770,8 +770,10 @@ func (h *Handler) bindDefaultEndpoints() { h.GET("/webapi/sites/:site/auth/export", h.authExportPublic) h.GET("/webapi/auth/export", h.authExportPublic) - // token generation + // join token handlers h.POST("/webapi/token", h.WithAuth(h.createTokenHandle)) + 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..5e2016d1c005b 100644 --- a/lib/web/join_tokens.go +++ b/lib/web/join_tokens.go @@ -53,6 +53,7 @@ import ( const ( stableCloudChannelRepo = "stable/cloud" + HeaderTokenName = "X-Teleport-TokenName" ) // nodeJoinToken contains node token fields for the UI. @@ -92,6 +93,50 @@ func automaticUpgrades(features proto.Features) bool { return features.AutomaticUpgrades && features.Cloud } +// 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) + } + + return GetTokensResponse{ + Items: ui.MakeJoinTokens(tokens), + }, 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 +} + func (h *Handler) createTokenHandle(w http.ResponseWriter, r *http.Request, params httprouter.Params, ctx *SessionContext) (interface{}, error) { var req types.ProvisionTokenSpecV2 if err := httplib.ReadJSON(r, &req); err != nil { diff --git a/lib/web/join_tokens_test.go b/lib/web/join_tokens_test.go index b01291bf8eaf5..a7768f8bb9533 100644 --- a/lib/web/join_tokens_test.go +++ b/lib/web/join_tokens_test.go @@ -21,9 +21,13 @@ package web import ( "context" "encoding/hex" + "encoding/json" "fmt" + "net/http" + "net/url" "regexp" "testing" + "time" "github.com/gravitational/trace" "github.com/stretchr/testify/require" @@ -32,8 +36,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 +82,248 @@ 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, + } + + 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{ + staticUIToken, + { + 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, + }, + }, + }, + } + 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.ElementsMatch(t, resp.Items, tc.expected) + }) + } +} + +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, + } + + // 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.ElementsMatch(t, resp.Items, []ui.JoinToken{ + staticUIToken, + }) +} + 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..2cf00c7435c43 --- /dev/null +++ b/lib/web/ui/join_token.go @@ -0,0 +1,61 @@ +// 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" + + "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"` + // 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"` + // AllowRules is a list of allow rules + AllowRules []string `json:"allowRules,omitempty"` +} + +func MakeJoinToken(token types.ProvisionToken) JoinToken { + return JoinToken{ + ID: token.GetName(), + SafeName: token.GetSafeName(), + Expiry: token.Expiry(), + Roles: token.GetRoles(), + IsStatic: token.IsStatic(), + Method: token.GetJoinMethod(), + } +} + +func MakeJoinTokens(tokens []types.ProvisionToken) (joinTokens []JoinToken) { + for _, t := range tokens { + joinTokens = append(joinTokens, MakeJoinToken(t)) + } + return joinTokens +} From 9db82c0a7c6df25acb45260be2202ad655032ece Mon Sep 17 00:00:00 2001 From: Michael Myers Date: Fri, 26 Jul 2024 12:45:01 -0500 Subject: [PATCH 2/7] Add token ACL/routes --- web/packages/teleport/src/config.ts | 9 +++++++++ web/packages/teleport/src/teleportContext.tsx | 2 ++ web/packages/teleport/src/types.ts | 1 + 3 files changed, 12 insertions(+) diff --git a/web/packages/teleport/src/config.ts b/web/packages/teleport/src/config.ts index 2849e35ac12c0..0ac7e983601cc 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', @@ -483,6 +484,14 @@ const cfg = { return generatePath(cfg.routes.desktops, { clusterId }); }, + getJoinTokensRoute() { + return cfg.routes.joinTokens; + }, + + getJoinTokensUrl() { + return cfg.api.joinTokensPath; + }, + getJoinTokenUrl() { return cfg.api.joinTokenPath; }, 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..6d7bbbab0c8b7 100644 --- a/web/packages/teleport/src/types.ts +++ b/web/packages/teleport/src/types.ts @@ -180,6 +180,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; From 2491fcd45594f9b052c458997840661235479ec8 Mon Sep 17 00:00:00 2001 From: Michael Myers Date: Fri, 26 Jul 2024 12:36:51 -0500 Subject: [PATCH 3/7] Add initial join tokens UI --- lib/web/apiserver.go | 1 + lib/web/join_tokens.go | 53 ++- lib/web/join_tokens_test.go | 13 +- lib/web/ui/join_token.go | 26 +- .../components/ToolTip/HoverTooltip.tsx | 4 + .../teleport/src/Apps/AddApp/AddApp.story.tsx | 11 +- .../src/Apps/AddApp/Automatically.test.tsx | 11 +- .../Add/GitHubActions/GitHubActions.test.tsx | 5 + .../EnrollEKSCluster/AgentWaitingDialog.tsx | 5 + .../src/JoinTokens/JoinTokens.story.tsx | 122 ++++++ .../teleport/src/JoinTokens/JoinTokens.tsx | 402 ++++++++++++++++++ web/packages/teleport/src/config.ts | 6 + web/packages/teleport/src/features.tsx | 17 + web/packages/teleport/src/services/api/api.ts | 9 + .../src/services/joinToken/joinToken.ts | 27 ++ .../src/services/joinToken/makeJoinToken.ts | 31 +- .../teleport/src/services/joinToken/types.ts | 27 +- .../teleport/src/services/resources/types.ts | 7 +- web/packages/teleport/src/types.ts | 1 + 19 files changed, 754 insertions(+), 24 deletions(-) create mode 100644 web/packages/teleport/src/JoinTokens/JoinTokens.story.tsx create mode 100644 web/packages/teleport/src/JoinTokens/JoinTokens.tsx diff --git a/lib/web/apiserver.go b/lib/web/apiserver.go index 36f78ad372913..08644dac24345 100644 --- a/lib/web/apiserver.go +++ b/lib/web/apiserver.go @@ -771,6 +771,7 @@ func (h *Handler) bindDefaultEndpoints() { h.GET("/webapi/auth/export", h.authExportPublic) // join token handlers + h.PUT("/webapi/token/yaml", h.WithAuth(h.upsertTokenContent)) h.POST("/webapi/token", h.WithAuth(h.createTokenHandle)) h.GET("/webapi/tokens", h.WithAuth(h.getTokens)) h.DELETE("/webapi/tokens", h.WithAuth(h.deleteToken)) diff --git a/lib/web/join_tokens.go b/lib/web/join_tokens.go index 5e2016d1c005b..6d8129dfd52f1 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" @@ -114,8 +115,13 @@ func (h *Handler) getTokens(w http.ResponseWriter, r *http.Request, params httpr return nil, trace.Wrap(err) } + uiTokens, err := ui.MakeJoinTokens(tokens) + if err != nil { + return nil, trace.Wrap(err) + } + return GetTokensResponse{ - Items: ui.MakeJoinTokens(tokens), + Items: uiTokens, }, nil } @@ -137,17 +143,56 @@ func (h *Handler) deleteToken(w http.ResponseWriter, r *http.Request, params htt return OK(), nil } -func (h *Handler) createTokenHandle(w http.ResponseWriter, r *http.Request, params httprouter.Params, ctx *SessionContext) (interface{}, error) { - var req types.ProvisionTokenSpecV2 - if err := httplib.ReadJSON(r, &req); err != nil { +type CreateTokenRequest struct { + Content string `json:"content"` +} + +func (h *Handler) upsertTokenContent(w http.ResponseWriter, r *http.Request, params httprouter.Params, sctx *SessionContext) (interface{}, error) { + 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) + } + + 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) + +} + +func (h *Handler) createTokenHandle(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 a7768f8bb9533..08be47c4e448c 100644 --- a/lib/web/join_tokens_test.go +++ b/lib/web/join_tokens_test.go @@ -29,6 +29,8 @@ import ( "testing" "time" + "github.com/google/go-cmp/cmp" + "github.com/google/go-cmp/cmp/cmpopts" "github.com/gravitational/trace" "github.com/stretchr/testify/require" @@ -101,6 +103,7 @@ func TestGetTokens(t *testing.T) { 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 { @@ -160,7 +163,6 @@ func TestGetTokens(t *testing.T) { }, }, expected: []ui.JoinToken{ - staticUIToken, { ID: "test-token", SafeName: "**********", @@ -194,9 +196,11 @@ func TestGetTokens(t *testing.T) { }, Method: types.JoinMethodToken, }, + staticUIToken, }, }, } + for _, tc := range tt { t.Run(tc.name, func(t *testing.T) { env := newWebPack(t, 1) @@ -250,7 +254,7 @@ func TestGetTokens(t *testing.T) { resp := GetTokensResponse{} require.NoError(t, json.Unmarshal(re.Bytes(), &resp)) require.Len(t, resp.Items, len(tc.expected)) - require.ElementsMatch(t, resp.Items, tc.expected) + require.Empty(t, cmp.Diff(resp.Items, tc.expected, cmpopts.IgnoreFields(ui.JoinToken{}, "Content"))) }) } } @@ -269,6 +273,7 @@ func TestDeleteToken(t *testing.T) { 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 @@ -319,9 +324,7 @@ func TestDeleteToken(t *testing.T) { resp = GetTokensResponse{} require.NoError(t, json.Unmarshal(re.Bytes(), &resp)) require.Len(t, resp.Items, 1 /* only static again */) - require.ElementsMatch(t, resp.Items, []ui.JoinToken{ - staticUIToken, - }) + require.Empty(t, cmp.Diff(resp.Items, []ui.JoinToken{staticUIToken}, cmpopts.IgnoreFields(ui.JoinToken{}, "Content"))) } func TestGenerateAzureTokenName(t *testing.T) { diff --git a/lib/web/ui/join_token.go b/lib/web/ui/join_token.go index 2cf00c7435c43..f22994ed94bde 100644 --- a/lib/web/ui/join_token.go +++ b/lib/web/ui/join_token.go @@ -19,6 +19,9 @@ package ui import ( "time" + yaml "github.com/ghodss/yaml" + "github.com/gravitational/trace" + "github.com/gravitational/teleport/api/types" ) @@ -40,22 +43,33 @@ type JoinToken struct { Method types.JoinMethod `json:"method"` // AllowRules is a list of allow rules AllowRules []string `json:"allowRules,omitempty"` + // Content is resource yaml content. + Content string `json:"content"` } -func MakeJoinToken(token types.ProvisionToken) JoinToken { - return JoinToken{ +func MakeJoinToken(token types.ProvisionToken) (*JoinToken, error) { + content, err := yaml.Marshal(token) + if err != nil { + return nil, trace.Wrap(err) + } + return &JoinToken{ ID: token.GetName(), SafeName: token.GetSafeName(), Expiry: token.Expiry(), Roles: token.GetRoles(), IsStatic: token.IsStatic(), Method: token.GetJoinMethod(), - } + Content: string(content[:]), + }, nil } -func MakeJoinTokens(tokens []types.ProvisionToken) (joinTokens []JoinToken) { +func MakeJoinTokens(tokens []types.ProvisionToken) (joinTokens []JoinToken, err error) { for _, t := range tokens { - joinTokens = append(joinTokens, MakeJoinToken(t)) + uiToken, err := MakeJoinToken(t) + if err != nil { + return nil, trace.Wrap(err) + } + joinTokens = append(joinTokens, *uiToken) } - return joinTokens + return joinTokens, nil } diff --git a/web/packages/shared/components/ToolTip/HoverTooltip.tsx b/web/packages/shared/components/ToolTip/HoverTooltip.tsx index ef82bb0c08704..21c80025d96a7 100644 --- a/web/packages/shared/components/ToolTip/HoverTooltip.tsx +++ b/web/packages/shared/components/ToolTip/HoverTooltip.tsx @@ -19,6 +19,7 @@ import React, { PropsWithChildren, useState } from 'react'; import styled from 'styled-components'; import { Popover, Flex, Text } from 'design'; +import { JustifyContentProps } from 'design/system'; type OriginProps = { vertical: string; @@ -32,6 +33,7 @@ export const HoverTooltip: React.FC< className?: string; anchorOrigin?: OriginProps; transformOrigin?: OriginProps; + justifyContentProps?: JustifyContentProps; }> > = ({ tipContent, @@ -40,6 +42,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 +80,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/JoinTokens.story.tsx b/web/packages/teleport/src/JoinTokens/JoinTokens.story.tsx new file mode 100644 index 0000000000000..7205fb2a98e8d --- /dev/null +++ b/web/packages/teleport/src/JoinTokens/JoinTokens.story.tsx @@ -0,0 +1,122 @@ +/** + * 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: '******', + 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', + 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.tsx b/web/packages/teleport/src/JoinTokens/JoinTokens.tsx new file mode 100644 index 0000000000000..b4cd1fd3049df --- /dev/null +++ b/web/packages/teleport/src/JoinTokens/JoinTokens.tsx @@ -0,0 +1,402 @@ +/** + * 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, +} 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'; + +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 [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 + ); + + async function handleSave(content: string): Promise { + const token = await ctx.joinTokenService.upsertJoinToken({ content }); + let items = [...joinTokensAttempt.data.items]; + if (resources.status === 'creating') { + items.push(token); + } else { + let tokenExistsInPreviousList = false; + const newItems = items.map(item => { + if (item.id === token.id) { + tokenExistsInPreviousList = true; + return token; + } + return item; + }); + // in the edge case that someone only edits the name of the token, it will return + // a "new" token via the upsert, and therefore should be treated as a new token + if (!tokenExistsInPreviousList) { + newItems.push(token); + } + items = newItems; + } + setJoinTokensAttempt({ + data: { ...joinTokensAttempt.data, items }, + status: 'success', + statusText: '', + }); + } + + const [deleteTokenAttempt, runDeleteTokenAttempt] = 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); + } + ); + + useEffect(() => { + runJoinTokensAttempt(); + }, []); + + return ( + + + Join Tokens + + + {joinTokensAttempt.status === 'error' && ( + {joinTokensAttempt.statusText} + )} + {deleteTokenAttempt.status === 'error' && ( + {deleteTokenAttempt.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) => ( + 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' && ( + + + + )} + + {tokenToDelete && ( + 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/config.ts b/web/packages/teleport/src/config.ts index 0ac7e983601cc..d07981f946205 100644 --- a/web/packages/teleport/src/config.ts +++ b/web/packages/teleport/src/config.ts @@ -259,6 +259,8 @@ const cfg = { connectMyComputerLoginsPath: '/v1/webapi/connectmycomputer/logins', joinTokenPath: '/v1/webapi/token', + joinTokenYamlPath: '/v1/webapi/token/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', @@ -496,6 +498,10 @@ const cfg = { 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..c687344e915e6 100644 --- a/web/packages/teleport/src/features.tsx +++ b/web/packages/teleport/src/features.tsx @@ -68,6 +68,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 +122,21 @@ export class FeatureNodes implements TeleportFeature { } } +// TODO (avatus) add navigationItem when ready to release +export class FeatureJoinTokens implements TeleportFeature { + category = NavigationCategory.Management; + 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 +642,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.ts b/web/packages/teleport/src/services/api/api.ts index db11784edd48b..027fa3b30c04f 100644 --- a/web/packages/teleport/src/services/api/api.ts +++ b/web/packages/teleport/src/services/api/api.ts @@ -77,6 +77,15 @@ const api = { ); }, + deleteWithHeaders(url, headers?: Record, signal?) { + return api.fetch(url, { + method: 'DELETE', + headers, + signal, + }); + }, + + // TODO (avatus) add abort signal to this put(url, data, webauthnResponse?: WebauthnAssertionResponse) { return api.fetchJsonWithMfaAuthnRetry( url, diff --git a/web/packages/teleport/src/services/joinToken/joinToken.ts b/web/packages/teleport/src/services/joinToken/joinToken.ts index df9483407c5a0..24deb61270092 100644 --- a/web/packages/teleport/src/services/joinToken/joinToken.ts +++ b/web/packages/teleport/src/services/joinToken/joinToken.ts @@ -25,6 +25,7 @@ import makeJoinToken from './makeJoinToken'; import { JoinToken, JoinRule, JoinTokenRequest } from './types'; class JoinTokenService { + // TODO (avatus) refactor this code to eventually use `createJoinToken` fetchJoinToken( req: JoinTokenRequest, signal: AbortSignal = null @@ -44,6 +45,32 @@ class JoinTokenService { ) .then(makeJoinToken); } + + // TODO (avatus) for the first iteration, we will create tokens using only yaml and + // slowly create a form for each token type. + upsertJoinToken(req: JoinTokenRequest): Promise { + return api + .put(cfg.getJoinTokenYamlUrl(), { + content: req.content, + }) + .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(), + { 'X-Teleport-TokenName': 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..49772fd38d802 100644 --- a/web/packages/teleport/src/services/joinToken/makeJoinToken.ts +++ b/web/packages/teleport/src/services/joinToken/makeJoinToken.ts @@ -24,21 +24,44 @@ 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, + expiry, + method, + suggestedLabels, + safeName, + content, + } = json; const labels = suggestedLabels || []; return { id, + isStatic, + safeName, + method, + 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 { + // a manually configured token with no TTL will be set to zero date + if (expiry == '0001-01-01T00:00:00Z' || isStatic) { + return 'never'; + } + if (!expiry) { + return ''; + } + return formatDistanceStrict(new Date(), new Date(expiry)); +} + 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..8179bb29e35e9 100644 --- a/web/packages/teleport/src/services/joinToken/types.ts +++ b/web/packages/teleport/src/services/joinToken/types.ts @@ -20,6 +20,15 @@ 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; + 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 +39,8 @@ export type JoinToken = { // // Extracted from suggestedLabels. internalResourceId?: string; + // yaml content of the resource + content: string; }; // JoinRole defines built-in system roles and are roles associated with @@ -53,7 +64,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. @@ -66,7 +87,7 @@ export type JoinRule = { export type JoinTokenRequest = { // roles is a list of join roles, since there can be more than // one role associated with a token. - roles: JoinRole[]; + 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 +97,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/types.ts b/web/packages/teleport/src/types.ts index 6d7bbbab0c8b7..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', From 5dd59174c85cc7fa9309d6289ec2058eade1eb22 Mon Sep 17 00:00:00 2001 From: Michael Myers Date: Thu, 18 Jul 2024 13:28:49 -0500 Subject: [PATCH 4/7] Add Allow and GCP fields to ui join token --- api/types/provisioning.go | 7 + lib/web/apiserver.go | 9 +- lib/web/join_tokens.go | 75 +++- lib/web/ui/join_token.go | 19 +- .../src/JoinTokens/JoinTokenForms.tsx | 230 +++++++++++ .../src/JoinTokens/JoinTokens.story.tsx | 8 + .../src/JoinTokens/JoinTokens.test.tsx | 191 +++++++++ .../teleport/src/JoinTokens/JoinTokens.tsx | 243 +++++++----- .../src/JoinTokens/UpsertJoinTokenDialog.tsx | 367 ++++++++++++++++++ web/packages/teleport/src/config.ts | 2 +- web/packages/teleport/src/features.tsx | 11 + .../teleport/src/services/api/api.test.ts | 25 ++ web/packages/teleport/src/services/api/api.ts | 38 +- .../src/services/joinToken/joinToken.ts | 28 +- .../src/services/joinToken/makeJoinToken.ts | 22 +- .../teleport/src/services/joinToken/types.ts | 39 ++ 16 files changed, 1195 insertions(+), 119 deletions(-) create mode 100644 web/packages/teleport/src/JoinTokens/JoinTokenForms.tsx create mode 100644 web/packages/teleport/src/JoinTokens/JoinTokens.test.tsx create mode 100644 web/packages/teleport/src/JoinTokens/UpsertJoinTokenDialog.tsx diff --git a/api/types/provisioning.go b/api/types/provisioning.go index cfdc48a9ccbcc..8087c85335903 100644 --- a/api/types/provisioning.go +++ b/api/types/provisioning.go @@ -121,6 +121,8 @@ 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. @@ -385,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 diff --git a/lib/web/apiserver.go b/lib/web/apiserver.go index 08644dac24345..e59b5d0a16b82 100644 --- a/lib/web/apiserver.go +++ b/lib/web/apiserver.go @@ -771,8 +771,13 @@ func (h *Handler) bindDefaultEndpoints() { h.GET("/webapi/auth/export", h.authExportPublic) // join token handlers - h.PUT("/webapi/token/yaml", h.WithAuth(h.upsertTokenContent)) - h.POST("/webapi/token", h.WithAuth(h.createTokenHandle)) + 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)) diff --git a/lib/web/join_tokens.go b/lib/web/join_tokens.go index 6d8129dfd52f1..c5939e30953d7 100644 --- a/lib/web/join_tokens.go +++ b/lib/web/join_tokens.go @@ -147,7 +147,12 @@ type CreateTokenRequest struct { Content string `json:"content"` } -func (h *Handler) upsertTokenContent(w http.ResponseWriter, r *http.Request, params httprouter.Params, sctx *SessionContext) (interface{}, error) { +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) @@ -158,6 +163,10 @@ func (h *Handler) upsertTokenContent(w http.ResponseWriter, r *http.Request, par 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) @@ -182,7 +191,69 @@ func (h *Handler) upsertTokenContent(w http.ResponseWriter, r *http.Request, par } -func (h *Handler) createTokenHandle(w http.ResponseWriter, r *http.Request, params httprouter.Params, ctx *SessionContext) (interface{}, error) { +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) diff --git a/lib/web/ui/join_token.go b/lib/web/ui/join_token.go index f22994ed94bde..be068482aa1ba 100644 --- a/lib/web/ui/join_token.go +++ b/lib/web/ui/join_token.go @@ -32,6 +32,8 @@ type JoinToken struct { // 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"` @@ -41,8 +43,10 @@ type JoinToken struct { IsStatic bool `json:"isStatic"` // Method is the join method that the token supports Method types.JoinMethod `json:"method"` - // AllowRules is a list of allow rules - AllowRules []string `json:"allowRules,omitempty"` + // 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"` } @@ -52,15 +56,22 @@ func MakeJoinToken(token types.ProvisionToken) (*JoinToken, error) { if err != nil { return nil, trace.Wrap(err) } - return &JoinToken{ + 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[:]), - }, nil + } + + if uiToken.Method == types.JoinMethodGCP { + uiToken.GCP = token.GetGCPRules() + } + return uiToken, nil } func MakeJoinTokens(tokens []types.ProvisionToken) (joinTokens []JoinToken, err error) { 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 index 7205fb2a98e8d..3dfdea17ab491 100644 --- a/web/packages/teleport/src/JoinTokens/JoinTokens.story.tsx +++ b/web/packages/teleport/src/JoinTokens/JoinTokens.story.tsx @@ -72,6 +72,10 @@ const tokens: JoinToken[] = [ expiry: new Date('0001-01-01'), method: 'token', safeName: '******', + allow: [], + gcp: { + allow: [], + }, content: '', }, { @@ -81,6 +85,10 @@ const tokens: JoinToken[] = [ 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 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 index b4cd1fd3049df..1364471a2c68c 100644 --- a/web/packages/teleport/src/JoinTokens/JoinTokens.tsx +++ b/web/packages/teleport/src/JoinTokens/JoinTokens.tsx @@ -30,6 +30,7 @@ import { MenuItem, ButtonWarning, ButtonSecondary, + Button, } from 'design'; import Table, { Cell } from 'design/DataTable'; import { Warning } from 'design/Icon'; @@ -56,6 +57,8 @@ 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, @@ -67,6 +70,8 @@ function makeTokenResource(token: JoinToken): Resource { 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()); @@ -76,25 +81,17 @@ export const JoinTokens = () => { { join_token: '' } // we are only editing for now, so template can be empty ); - async function handleSave(content: string): Promise { - const token = await ctx.joinTokenService.upsertJoinToken({ content }); + function updateTokenList(token: JoinToken): JoinToken[] { let items = [...joinTokensAttempt.data.items]; - if (resources.status === 'creating') { + if (creatingToken) { items.push(token); } else { - let tokenExistsInPreviousList = false; const newItems = items.map(item => { if (item.id === token.id) { - tokenExistsInPreviousList = true; return token; } return item; }); - // in the edge case that someone only edits the name of the token, it will return - // a "new" token via the upsert, and therefore should be treated as a new token - if (!tokenExistsInPreviousList) { - newItems.push(token); - } items = newItems; } setJoinTokensAttempt({ @@ -102,10 +99,19 @@ export const JoinTokens = () => { status: 'success', statusText: '', }); + return items; } - const [deleteTokenAttempt, runDeleteTokenAttempt] = useAsync( - async (token: string) => { + 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', @@ -115,8 +121,9 @@ export const JoinTokens = () => { }, }); setTokenToDelete(null); - } - ); + setEditingToken(null); + setCreatingToken(false); + }); useEffect(() => { runJoinTokensAttempt(); @@ -132,94 +139,142 @@ export const JoinTokens = () => { alignItems="center" > Join Tokens - - - {joinTokensAttempt.status === 'error' && ( - {joinTokensAttempt.statusText} + {!creatingToken && !editingToken && ( + )} - {deleteTokenAttempt.status === 'error' && ( - {deleteTokenAttempt.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) && ( - - - - )} - - - ); + + + + {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, }, - }, - { - altKey: 'options-btn', - render: (token: JoinToken) => ( - 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', + // 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); }} /> )} - {joinTokensAttempt.status === 'processing' && ( - - - - )} - + {tokenToDelete && ( setTokenToDelete(null)} + onClose={() => { + setDeleteTokenAttempt({ + status: 'success', + statusText: '', + data: null, + }); + setTokenToDelete(null); + }} onDelete={() => runDeleteTokenAttempt(tokenToDelete.id)} attempt={deleteTokenAttempt} /> diff --git a/web/packages/teleport/src/JoinTokens/UpsertJoinTokenDialog.tsx b/web/packages/teleport/src/JoinTokens/UpsertJoinTokenDialog.tsx new file mode 100644 index 0000000000000..b3ba66f4ced67 --- /dev/null +++ b/web/packages/teleport/src/JoinTokens/UpsertJoinTokenDialog.tsx @@ -0,0 +1,367 @@ +/** + * 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].background}; + 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 d07981f946205..4e10451ce5954 100644 --- a/web/packages/teleport/src/config.ts +++ b/web/packages/teleport/src/config.ts @@ -259,7 +259,7 @@ const cfg = { connectMyComputerLoginsPath: '/v1/webapi/connectmycomputer/logins', joinTokenPath: '/v1/webapi/token', - joinTokenYamlPath: '/v1/webapi/token/yaml', + joinTokenYamlPath: '/v1/webapi/tokens/yaml', joinTokensPath: '/v1/webapi/tokens', dbScriptPath: '/scripts/:token/install-database.sh', nodeScriptPath: '/scripts/:token/install-node.sh', diff --git a/web/packages/teleport/src/features.tsx b/web/packages/teleport/src/features.tsx index c687344e915e6..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, @@ -125,6 +126,16 @@ 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, 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 027fa3b30c04f..8490cecac2125 100644 --- a/web/packages/teleport/src/services/api/api.ts +++ b/web/packages/teleport/src/services/api/api.ts @@ -77,12 +77,21 @@ const api = { ); }, - deleteWithHeaders(url, headers?: Record, signal?) { - return api.fetch(url, { - method: 'DELETE', - headers, - signal, - }); + deleteWithHeaders( + url, + headers?: Record, + signal?, + webauthnResponse?: WebauthnAssertionResponse + ) { + return api.fetchJsonWithMfaAuthnRetry( + url, + { + method: 'DELETE', + headers, + signal, + }, + webauthnResponse + ); }, // TODO (avatus) add abort signal to this @@ -97,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 24deb61270092..33b3faeefc809 100644 --- a/web/packages/teleport/src/services/joinToken/joinToken.ts +++ b/web/packages/teleport/src/services/joinToken/joinToken.ts @@ -24,6 +24,8 @@ 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( @@ -46,16 +48,28 @@ class JoinTokenService { .then(makeJoinToken); } - // TODO (avatus) for the first iteration, we will create tokens using only yaml and - // slowly create a form for each token type. - upsertJoinToken(req: JoinTokenRequest): Promise { + upsertJoinTokenYAML( + req: JoinTokenRequest, + tokenName: string + ): Promise { return api - .put(cfg.getJoinTokenYamlUrl(), { - content: req.content, - }) + .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 { @@ -67,7 +81,7 @@ class JoinTokenService { deleteJoinToken(id: string, signal: AbortSignal = null) { return api.deleteWithHeaders( cfg.getJoinTokensUrl(), - { 'X-Teleport-TokenName': id }, + { [TeleportTokenNameHeader]: id }, signal ); } diff --git a/web/packages/teleport/src/services/joinToken/makeJoinToken.ts b/web/packages/teleport/src/services/joinToken/makeJoinToken.ts index 49772fd38d802..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'; @@ -28,6 +28,9 @@ export default function makeToken(json): JoinToken { id, roles, isStatic, + allow, + gcp, + bot_name, expiry, method, suggestedLabels, @@ -41,7 +44,10 @@ export default function makeToken(json): JoinToken { id, isStatic, safeName, + bot_name, method, + allow, + gcp, roles: roles?.sort((a, b) => a.localeCompare(b)) || [], suggestedLabels: labels, internalResourceId: extractInternalResourceId(labels), @@ -52,14 +58,24 @@ export default function makeToken(json): JoinToken { } 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) { + if (expiry == '0001-01-01T00:00:00Z' || isStatic || yearsDifference > 100) { return 'never'; } if (!expiry) { return ''; } - return formatDistanceStrict(new Date(), new Date(expiry)); + + return formatDistanceStrict(now, expiryDate); } function extractInternalResourceId(labels: any[]) { diff --git a/web/packages/teleport/src/services/joinToken/types.ts b/web/packages/teleport/src/services/joinToken/types.ts index 8179bb29e35e9..a36c1a975d1bd 100644 --- a/web/packages/teleport/src/services/joinToken/types.ts +++ b/web/packages/teleport/src/services/joinToken/types.ts @@ -24,6 +24,8 @@ export type JoinToken = { // 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; @@ -41,6 +43,10 @@ export type JoinToken = { 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 @@ -50,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 = @@ -57,6 +64,7 @@ export type JoinRole = | 'Node' | 'Db' | 'Kube' + | 'Bot' | 'WindowsDesktop' | 'Discovery'; @@ -82,6 +90,37 @@ export type JoinRule = { awsAccountId: string; // awsArn is used for the IAM join method. awsArn?: string; + regions?: string[]; +}; + +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 = { From 10d41c5b520ae04c6d6596d721d0c65a50588698 Mon Sep 17 00:00:00 2001 From: Michael Myers Date: Fri, 26 Jul 2024 13:58:27 -0500 Subject: [PATCH 5/7] Fix missing styled components --- web/packages/shared/components/ToolTip/HoverTooltip.tsx | 3 +-- web/packages/teleport/src/JoinTokens/UpsertJoinTokenDialog.tsx | 3 +-- 2 files changed, 2 insertions(+), 4 deletions(-) diff --git a/web/packages/shared/components/ToolTip/HoverTooltip.tsx b/web/packages/shared/components/ToolTip/HoverTooltip.tsx index 21c80025d96a7..f761be484d89b 100644 --- a/web/packages/shared/components/ToolTip/HoverTooltip.tsx +++ b/web/packages/shared/components/ToolTip/HoverTooltip.tsx @@ -19,7 +19,6 @@ import React, { PropsWithChildren, useState } from 'react'; import styled from 'styled-components'; import { Popover, Flex, Text } from 'design'; -import { JustifyContentProps } from 'design/system'; type OriginProps = { vertical: string; @@ -33,7 +32,7 @@ export const HoverTooltip: React.FC< className?: string; anchorOrigin?: OriginProps; transformOrigin?: OriginProps; - justifyContentProps?: JustifyContentProps; + justifyContentProps?: { justifyContent: string }; }> > = ({ tipContent, diff --git a/web/packages/teleport/src/JoinTokens/UpsertJoinTokenDialog.tsx b/web/packages/teleport/src/JoinTokens/UpsertJoinTokenDialog.tsx index b3ba66f4ced67..ffa07787cfaba 100644 --- a/web/packages/teleport/src/JoinTokens/UpsertJoinTokenDialog.tsx +++ b/web/packages/teleport/src/JoinTokens/UpsertJoinTokenDialog.tsx @@ -355,8 +355,7 @@ export const UpsertJoinTokenDialog = ({ }; export const RuleBox = styled(Box)` - border-color: ${props => - props.theme.colors.interactive.tonal.neutral[0].background}; + border-color: ${props => props.theme.colors.interactive.tonal.neutral[0]}; border-width: 2px; border-style: solid; border-radius: ${props => props.theme.radii[2]}px; From 7423e3d89c7344240afb77390eda0611aa7fcb05 Mon Sep 17 00:00:00 2001 From: Michael Date: Tue, 13 Aug 2024 09:13:55 -0500 Subject: [PATCH 6/7] Fix empty join token state (#45413) Empty join token responses would show an error instead of an empty list message --- web/packages/teleport/src/services/joinToken/joinToken.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/web/packages/teleport/src/services/joinToken/joinToken.ts b/web/packages/teleport/src/services/joinToken/joinToken.ts index 33b3faeefc809..759ae40595cbb 100644 --- a/web/packages/teleport/src/services/joinToken/joinToken.ts +++ b/web/packages/teleport/src/services/joinToken/joinToken.ts @@ -73,7 +73,7 @@ class JoinTokenService { fetchJoinTokens(signal: AbortSignal = null): Promise<{ items: JoinToken[] }> { return api.get(cfg.getJoinTokensUrl(), signal).then(resp => { return { - items: resp.items.map(makeJoinToken), + items: resp.items?.map(makeJoinToken) || [], }; }); } From d496934cbcba7e6c8834fc3cbcf90ea2dea84618 Mon Sep 17 00:00:00 2001 From: Michael Myers Date: Thu, 15 Aug 2024 12:38:35 -0500 Subject: [PATCH 7/7] Update button case --- web/packages/teleport/src/JoinTokens/JoinTokens.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/web/packages/teleport/src/JoinTokens/JoinTokens.tsx b/web/packages/teleport/src/JoinTokens/JoinTokens.tsx index 1364471a2c68c..bbd6ac1720ac3 100644 --- a/web/packages/teleport/src/JoinTokens/JoinTokens.tsx +++ b/web/packages/teleport/src/JoinTokens/JoinTokens.tsx @@ -147,7 +147,7 @@ export const JoinTokens = () => { width="240px" onClick={() => setCreatingToken(true)} > - Create new Token + Create New Token )}