From ffc70715b937cebbd67e82157e67cac1acf5e7ed Mon Sep 17 00:00:00 2001 From: Michael Myers Date: Tue, 4 Jun 2024 12:00:32 -0700 Subject: [PATCH] Add token list/delete endpoints 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 ea21740618973..c1e174af4056a 100644 --- a/lib/auth/auth_test.go +++ b/lib/auth/auth_test.go @@ -1009,7 +1009,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")}, }})) @@ -1018,7 +1018,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 d84e37df0fada..499479ef80095 100644 --- a/lib/auth/helpers.go +++ b/lib/auth/helpers.go @@ -374,9 +374,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 f3b1cc75aee83..ff4f7819b94ce 100644 --- a/lib/auth/tls_test.go +++ b/lib/auth/tls_test.go @@ -4636,12 +4636,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 e24dd4ac8960d..d3448ba410737 100644 --- a/lib/config/configuration_test.go +++ b/lib/config/configuration_test.go @@ -729,7 +729,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 45d4c84e9366c..5802036e3258d 100644 --- a/lib/web/apiserver.go +++ b/lib/web/apiserver.go @@ -800,8 +800,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 +}