Skip to content

Commit

Permalink
[16] Join Tokens UI (#44701)
Browse files Browse the repository at this point in the history
* 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

* Add token ACL/routes

* Add initial join tokens UI

* Add Allow and GCP fields to ui join token

* Fix missing styled components

* Fix empty join token state (#45413)

Empty join token responses would show an error instead of an empty list
message

* Update button case
  • Loading branch information
avatus authored Aug 26, 2024
1 parent 4389e65 commit 187358d
Show file tree
Hide file tree
Showing 31 changed files with 2,222 additions and 25 deletions.
23 changes: 19 additions & 4 deletions api/types/provisioning.go
Original file line number Diff line number Diff line change
Expand Up @@ -121,13 +121,16 @@ type ProvisionToken interface {
GetAllowRules() []*TokenRule
// SetAllowRules sets the allow rules
SetAllowRules([]*TokenRule)
// GetGCPRules will return the GCP rules within this token.
GetGCPRules() *ProvisionTokenSpecV2GCP
// GetAWSIIDTTL returns the TTL of EC2 IIDs
GetAWSIIDTTL() Duration
// GetJoinMethod returns joining method that must be used with this token.
GetJoinMethod() JoinMethod
// GetBotName returns the BotName field which must be set for joining bots.
GetBotName() string

// IsStatic returns true if the token is statically configured
IsStatic() bool
// GetSuggestedLabels returns the set of labels that the resource should add when adding itself to the cluster
GetSuggestedLabels() Labels

Expand Down Expand Up @@ -384,6 +387,11 @@ func (p *ProvisionTokenV2) SetAllowRules(rules []*TokenRule) {
p.Spec.Allow = rules
}

// GetGCPRules will return the GCP rules within this token.
func (p *ProvisionTokenV2) GetGCPRules() *ProvisionTokenSpecV2GCP {
return p.Spec.GCP
}

// GetAWSIIDTTL returns the TTL of EC2 IIDs
func (p *ProvisionTokenV2) GetAWSIIDTTL() Duration {
return p.Spec.AWSIIDTTL
Expand All @@ -394,6 +402,11 @@ func (p *ProvisionTokenV2) GetJoinMethod() JoinMethod {
return p.Spec.JoinMethod
}

// IsStatic returns true if the token is statically configured
func (p *ProvisionTokenV2) IsStatic() bool {
return p.Origin() == OriginConfigFile
}

// GetBotName returns the BotName field which must be set for joining bots.
func (p *ProvisionTokenV2) GetBotName() string {
return p.Spec.BotName
Expand Down Expand Up @@ -535,14 +548,16 @@ func ProvisionTokensToV1(in []ProvisionToken) []ProvisionTokenV1 {
return out
}

// ProvisionTokensFromV1 converts V1 provision tokens to resource list
func ProvisionTokensFromV1(in []ProvisionTokenV1) []ProvisionToken {
// ProvisionTokensFromStatic converts static tokens to resource list
func ProvisionTokensFromStatic(in []ProvisionTokenV1) []ProvisionToken {
if in == nil {
return nil
}
out := make([]ProvisionToken, len(in))
for i := range in {
out[i] = in[i].V2()
tok := in[i].V2()
tok.SetOrigin(OriginConfigFile)
out[i] = tok
}
return out
}
Expand Down
2 changes: 1 addition & 1 deletion api/types/statictokens.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
4 changes: 2 additions & 2 deletions lib/auth/auth_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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")},
}}))
Expand All @@ -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")},
}}))
Expand Down
10 changes: 9 additions & 1 deletion lib/auth/helpers.go
Original file line number Diff line number Diff line change
Expand Up @@ -390,9 +390,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)
Expand Down
4 changes: 2 additions & 2 deletions lib/auth/tls_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
7 changes: 7 additions & 0 deletions lib/auth/trustedcluster.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
2 changes: 1 addition & 1 deletion lib/config/configuration_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -734,7 +734,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"}),
Expand Down
12 changes: 10 additions & 2 deletions lib/web/apiserver.go
Original file line number Diff line number Diff line change
Expand Up @@ -769,8 +769,16 @@ func (h *Handler) bindDefaultEndpoints() {
h.GET("/webapi/sites/:site/auth/export", h.authExportPublic)
h.GET("/webapi/auth/export", h.authExportPublic)

// token generation
h.POST("/webapi/token", h.WithAuth(h.createTokenHandle))
// join token handlers
h.PUT("/webapi/tokens/yaml", h.WithAuth(h.updateTokenYAML))
// used for creating a new token
h.POST("/webapi/tokens", h.WithAuth(h.upsertTokenHandle))
// used for updating a token
h.PUT("/webapi/tokens", h.WithAuth(h.upsertTokenHandle))
// used for creating tokens used during guided discover flows
h.POST("/webapi/token", h.WithAuth(h.createTokenForDiscoveryHandle))
h.GET("/webapi/tokens", h.WithAuth(h.getTokens))
h.DELETE("/webapi/tokens", h.WithAuth(h.deleteToken))

// join scripts
h.GET("/scripts/:token/install-node.sh", h.WithLimiter(h.getNodeJoinScriptHandle))
Expand Down
165 changes: 163 additions & 2 deletions lib/web/join_tokens.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand All @@ -53,6 +54,7 @@ import (

const (
stableCloudChannelRepo = "stable/cloud"
HeaderTokenName = "X-Teleport-TokenName"
)

// nodeJoinToken contains node token fields for the UI.
Expand Down Expand Up @@ -92,17 +94,176 @@ func automaticUpgrades(features proto.Features) bool {
return features.AutomaticUpgrades && features.Cloud
}

func (h *Handler) createTokenHandle(w http.ResponseWriter, r *http.Request, params httprouter.Params, ctx *SessionContext) (interface{}, error) {
var req types.ProvisionTokenSpecV2
// Currently we aren't paginating this endpoint as we don't
// expect many tokens to exist at a time. I'm leaving it in a "paginated" form
// without a nextKey for now so implementing pagination won't change the response shape
// TODO (avatus) implement pagination

// GetTokensResponse returns a list of JoinTokens.
type GetTokensResponse struct {
Items []ui.JoinToken `json:"items"`
}

func (h *Handler) getTokens(w http.ResponseWriter, r *http.Request, params httprouter.Params, ctx *SessionContext) (interface{}, error) {
clt, err := ctx.GetClient()
if err != nil {
return nil, trace.Wrap(err)
}

tokens, err := clt.GetTokens(r.Context())
if err != nil {
return nil, trace.Wrap(err)
}

uiTokens, err := ui.MakeJoinTokens(tokens)
if err != nil {
return nil, trace.Wrap(err)
}

return GetTokensResponse{
Items: uiTokens,
}, nil
}

func (h *Handler) deleteToken(w http.ResponseWriter, r *http.Request, params httprouter.Params, ctx *SessionContext) (interface{}, error) {
token := r.Header.Get(HeaderTokenName)
if token == "" {
return nil, trace.BadParameter("requires a token to delete")
}

clt, err := ctx.GetClient()
if err != nil {
return nil, trace.Wrap(err)
}

if err := clt.DeleteToken(r.Context(), token); err != nil {
return nil, trace.Wrap(err)
}

return OK(), nil
}

type CreateTokenRequest struct {
Content string `json:"content"`
}

func (h *Handler) updateTokenYAML(w http.ResponseWriter, r *http.Request, params httprouter.Params, sctx *SessionContext) (interface{}, error) {
tokenId := r.Header.Get(HeaderTokenName)
if tokenId == "" {
return nil, trace.BadParameter("requires a token name to edit")
}

var yaml CreateTokenRequest
if err := httplib.ReadJSON(r, &yaml); err != nil {
return nil, trace.Wrap(err)
}

extractedRes, err := ExtractResourceAndValidate(yaml.Content)
if err != nil {
return nil, trace.Wrap(err)
}

if tokenId != extractedRes.Metadata.Name {
return nil, trace.BadParameter("renaming tokens is not supported")
}

token, err := services.UnmarshalProvisionToken(extractedRes.Raw)
if err != nil {
return nil, trace.Wrap(err)
}

clt, err := sctx.GetClient()
if err != nil {
return nil, trace.Wrap(err)
}

err = clt.UpsertToken(r.Context(), token)
if err != nil {
return nil, trace.Wrap(err)
}

uiToken, err := ui.MakeJoinToken(token)
if err != nil {
return nil, trace.Wrap(err)
}

return uiToken, trace.Wrap(err)

}

type upsertTokenHandleRequest struct {
types.ProvisionTokenSpecV2
Name string `json:"name"`
}

func (h *Handler) upsertTokenHandle(w http.ResponseWriter, r *http.Request, params httprouter.Params, ctx *SessionContext) (interface{}, error) {
// if using the PUT route, tokenId will be present
// in the X-Teleport-TokenName header
editing := r.Method == "PUT"
tokenId := r.Header.Get(HeaderTokenName)
if editing && tokenId == "" {
return nil, trace.BadParameter("requires a token name to edit")
}

var req upsertTokenHandleRequest
if err := httplib.ReadJSON(r, &req); err != nil {
return nil, trace.Wrap(err)
}

if editing && tokenId != req.Name {
return nil, trace.BadParameter("renaming tokens is not supported")
}

// set expires time to default node join token TTL
expires := time.Now().UTC().Add(defaults.NodeJoinTokenTTL)
// IAM and GCP tokens should never expire
if req.JoinMethod == types.JoinMethodGCP || req.JoinMethod == types.JoinMethodIAM {
expires = time.Now().UTC().AddDate(1000, 0, 0)
}

name := req.Name
if name == "" {
randName, err := utils.CryptoRandomHex(defaults.TokenLenBytes)
if err != nil {
return nil, trace.Wrap(err)
}
name = randName
}

token, err := types.NewProvisionTokenFromSpec(name, expires, req.ProvisionTokenSpecV2)
if err != nil {
return nil, trace.Wrap(err)
}

clt, err := ctx.GetClient()
if err != nil {
return nil, trace.Wrap(err)
}

err = clt.UpsertToken(r.Context(), token)
if err != nil {
return nil, trace.Wrap(err)
}

uiToken, err := ui.MakeJoinToken(token)
if err != nil {
return nil, trace.Wrap(err)
}

return uiToken, nil
}

func (h *Handler) createTokenForDiscoveryHandle(w http.ResponseWriter, r *http.Request, params httprouter.Params, ctx *SessionContext) (interface{}, error) {
clt, err := ctx.GetClient()
if err != nil {
return nil, trace.Wrap(err)
}

var req types.ProvisionTokenSpecV2
if err := httplib.ReadJSON(r, &req); err != nil {
return nil, trace.Wrap(err)
}

var expires time.Time
var tokenName string
switch req.JoinMethod {
Expand Down
Loading

0 comments on commit 187358d

Please sign in to comment.