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