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 +}