diff --git a/api/types/provisioning.go b/api/types/provisioning.go
index cfdc48a9ccbcc..8087c85335903 100644
--- a/api/types/provisioning.go
+++ b/api/types/provisioning.go
@@ -121,6 +121,8 @@ type ProvisionToken interface {
GetAllowRules() []*TokenRule
// SetAllowRules sets the allow rules
SetAllowRules([]*TokenRule)
+ // GetGCPRules will return the GCP rules within this token.
+ GetGCPRules() *ProvisionTokenSpecV2GCP
// GetAWSIIDTTL returns the TTL of EC2 IIDs
GetAWSIIDTTL() Duration
// GetJoinMethod returns joining method that must be used with this token.
@@ -385,6 +387,11 @@ func (p *ProvisionTokenV2) SetAllowRules(rules []*TokenRule) {
p.Spec.Allow = rules
}
+// GetGCPRules will return the GCP rules within this token.
+func (p *ProvisionTokenV2) GetGCPRules() *ProvisionTokenSpecV2GCP {
+ return p.Spec.GCP
+}
+
// GetAWSIIDTTL returns the TTL of EC2 IIDs
func (p *ProvisionTokenV2) GetAWSIIDTTL() Duration {
return p.Spec.AWSIIDTTL
diff --git a/lib/web/apiserver.go b/lib/web/apiserver.go
index 0d23f7c272e4b..98e7c7ac6fcad 100644
--- a/lib/web/apiserver.go
+++ b/lib/web/apiserver.go
@@ -771,8 +771,13 @@ func (h *Handler) bindDefaultEndpoints() {
h.GET("/webapi/auth/export", h.authExportPublic)
// join token handlers
- h.PUT("/webapi/token/yaml", h.WithAuth(h.upsertTokenContent))
- h.POST("/webapi/token", h.WithAuth(h.createTokenHandle))
+ h.PUT("/webapi/tokens/yaml", h.WithAuth(h.updateTokenYAML))
+ // used for creating a new token
+ h.POST("/webapi/tokens", h.WithAuth(h.upsertTokenHandle))
+ // used for updating a token
+ h.PUT("/webapi/tokens", h.WithAuth(h.upsertTokenHandle))
+ // used for creating tokens used during guided discover flows
+ h.POST("/webapi/token", h.WithAuth(h.createTokenForDiscoveryHandle))
h.GET("/webapi/tokens", h.WithAuth(h.getTokens))
h.DELETE("/webapi/tokens", h.WithAuth(h.deleteToken))
diff --git a/lib/web/join_tokens.go b/lib/web/join_tokens.go
index 6d8129dfd52f1..c5939e30953d7 100644
--- a/lib/web/join_tokens.go
+++ b/lib/web/join_tokens.go
@@ -147,7 +147,12 @@ type CreateTokenRequest struct {
Content string `json:"content"`
}
-func (h *Handler) upsertTokenContent(w http.ResponseWriter, r *http.Request, params httprouter.Params, sctx *SessionContext) (interface{}, error) {
+func (h *Handler) updateTokenYAML(w http.ResponseWriter, r *http.Request, params httprouter.Params, sctx *SessionContext) (interface{}, error) {
+ tokenId := r.Header.Get(HeaderTokenName)
+ if tokenId == "" {
+ return nil, trace.BadParameter("requires a token name to edit")
+ }
+
var yaml CreateTokenRequest
if err := httplib.ReadJSON(r, &yaml); err != nil {
return nil, trace.Wrap(err)
@@ -158,6 +163,10 @@ func (h *Handler) upsertTokenContent(w http.ResponseWriter, r *http.Request, par
return nil, trace.Wrap(err)
}
+ if tokenId != extractedRes.Metadata.Name {
+ return nil, trace.BadParameter("renaming tokens is not supported")
+ }
+
token, err := services.UnmarshalProvisionToken(extractedRes.Raw)
if err != nil {
return nil, trace.Wrap(err)
@@ -182,7 +191,69 @@ func (h *Handler) upsertTokenContent(w http.ResponseWriter, r *http.Request, par
}
-func (h *Handler) createTokenHandle(w http.ResponseWriter, r *http.Request, params httprouter.Params, ctx *SessionContext) (interface{}, error) {
+type upsertTokenHandleRequest struct {
+ types.ProvisionTokenSpecV2
+ Name string `json:"name"`
+}
+
+func (h *Handler) upsertTokenHandle(w http.ResponseWriter, r *http.Request, params httprouter.Params, ctx *SessionContext) (interface{}, error) {
+ // if using the PUT route, tokenId will be present
+ // in the X-Teleport-TokenName header
+ editing := r.Method == "PUT"
+ tokenId := r.Header.Get(HeaderTokenName)
+ if editing && tokenId == "" {
+ return nil, trace.BadParameter("requires a token name to edit")
+ }
+
+ var req upsertTokenHandleRequest
+ if err := httplib.ReadJSON(r, &req); err != nil {
+ return nil, trace.Wrap(err)
+ }
+
+ if editing && tokenId != req.Name {
+ return nil, trace.BadParameter("renaming tokens is not supported")
+ }
+
+ // set expires time to default node join token TTL
+ expires := time.Now().UTC().Add(defaults.NodeJoinTokenTTL)
+ // IAM and GCP tokens should never expire
+ if req.JoinMethod == types.JoinMethodGCP || req.JoinMethod == types.JoinMethodIAM {
+ expires = time.Now().UTC().AddDate(1000, 0, 0)
+ }
+
+ name := req.Name
+ if name == "" {
+ randName, err := utils.CryptoRandomHex(defaults.TokenLenBytes)
+ if err != nil {
+ return nil, trace.Wrap(err)
+ }
+ name = randName
+ }
+
+ token, err := types.NewProvisionTokenFromSpec(name, expires, req.ProvisionTokenSpecV2)
+ if err != nil {
+ return nil, trace.Wrap(err)
+ }
+
+ clt, err := ctx.GetClient()
+ if err != nil {
+ return nil, trace.Wrap(err)
+ }
+
+ err = clt.UpsertToken(r.Context(), token)
+ if err != nil {
+ return nil, trace.Wrap(err)
+ }
+
+ uiToken, err := ui.MakeJoinToken(token)
+ if err != nil {
+ return nil, trace.Wrap(err)
+ }
+
+ return uiToken, nil
+}
+
+func (h *Handler) createTokenForDiscoveryHandle(w http.ResponseWriter, r *http.Request, params httprouter.Params, ctx *SessionContext) (interface{}, error) {
clt, err := ctx.GetClient()
if err != nil {
return nil, trace.Wrap(err)
diff --git a/lib/web/ui/join_token.go b/lib/web/ui/join_token.go
index f22994ed94bde..be068482aa1ba 100644
--- a/lib/web/ui/join_token.go
+++ b/lib/web/ui/join_token.go
@@ -32,6 +32,8 @@ type JoinToken struct {
// SafeName returns the name of the token, sanitized appropriately for
// join methods where the name is secret.
SafeName string `json:"safeName"`
+ // BotName is the name of the bot this token grants access to, if any
+ BotName string `json:"bot_name"`
// Expiry is the time that the token resource expires. Tokens that do not expire
// should expect a zero value time to be returned.
Expiry time.Time `json:"expiry"`
@@ -41,8 +43,10 @@ type JoinToken struct {
IsStatic bool `json:"isStatic"`
// Method is the join method that the token supports
Method types.JoinMethod `json:"method"`
- // AllowRules is a list of allow rules
- AllowRules []string `json:"allowRules,omitempty"`
+ // Allow is a list of allow rules
+ Allow []*types.TokenRule `json:"allow,omitempty"`
+ // GCP allows the configuration of options specific to the "gcp" join method.
+ GCP *types.ProvisionTokenSpecV2GCP `json:"gcp,omitempty"`
// Content is resource yaml content.
Content string `json:"content"`
}
@@ -52,15 +56,22 @@ func MakeJoinToken(token types.ProvisionToken) (*JoinToken, error) {
if err != nil {
return nil, trace.Wrap(err)
}
- return &JoinToken{
+ uiToken := &JoinToken{
ID: token.GetName(),
SafeName: token.GetSafeName(),
+ BotName: token.GetBotName(),
Expiry: token.Expiry(),
Roles: token.GetRoles(),
IsStatic: token.IsStatic(),
Method: token.GetJoinMethod(),
+ Allow: token.GetAllowRules(),
Content: string(content[:]),
- }, nil
+ }
+
+ if uiToken.Method == types.JoinMethodGCP {
+ uiToken.GCP = token.GetGCPRules()
+ }
+ return uiToken, nil
}
func MakeJoinTokens(tokens []types.ProvisionToken) (joinTokens []JoinToken, err error) {
diff --git a/web/packages/teleport/src/JoinTokens/JoinTokenForms.tsx b/web/packages/teleport/src/JoinTokens/JoinTokenForms.tsx
new file mode 100644
index 0000000000000..e511ded3df6bf
--- /dev/null
+++ b/web/packages/teleport/src/JoinTokens/JoinTokenForms.tsx
@@ -0,0 +1,230 @@
+/**
+ * Teleport
+ * Copyright (C) 2024 Gravitational, Inc.
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program. If not, see .
+ */
+
+import React from 'react';
+import { Flex, Text, ButtonIcon, ButtonText } from 'design';
+import { Plus, Trash } from 'design/Icon';
+import { requiredField } from 'shared/components/Validation/rules';
+import FieldInput from 'shared/components/FieldInput';
+import { FieldSelectCreatable } from 'shared/components/FieldSelect';
+
+import { NewJoinTokenState, OptionGCP, RuleBox } from './UpsertJoinTokenDialog';
+
+export const JoinTokenIAMForm = ({
+ tokenState,
+ onUpdateState,
+}: {
+ tokenState: NewJoinTokenState;
+ onUpdateState: (newToken: NewJoinTokenState) => void;
+}) => {
+ const rules = tokenState.iam;
+
+ function removeRule(index: number) {
+ const newRules = rules.filter((_, i) => index !== i);
+ const newState = {
+ ...tokenState,
+ iam: newRules,
+ };
+ onUpdateState(newState);
+ }
+
+ function setTokenRulesField(
+ ruleIndex: number,
+ fieldName: string,
+ value: string
+ ) {
+ const newState = {
+ ...tokenState,
+ [tokenState.method.value]: tokenState[tokenState.method.value].map(
+ (rule, i) => {
+ if (ruleIndex !== i) {
+ return rule;
+ }
+ return {
+ ...rule,
+ [fieldName]: value,
+ };
+ }
+ ),
+ };
+ onUpdateState(newState);
+ }
+
+ function addNewRule() {
+ const newState = {
+ ...tokenState,
+ iam: [...tokenState.iam, { aws_account: '' }],
+ };
+ onUpdateState(newState);
+ }
+
+ return (
+ <>
+ {rules.map((rule, index) => (
+
+
+
+ AWS Rule
+
+
+ {rules.length > 1 && ( // at least one rule is required, so lets not allow the user to remove it
+ removeRule(index)}
+ >
+
+
+ )}
+
+
+ setTokenRulesField(index, 'aws_account', e.target.value)
+ }
+ />
+ setTokenRulesField(index, 'aws_arn', e.target.value)}
+ />
+
+ ))}
+
+
+ Add another AWS Rule
+
+ >
+ );
+};
+
+export const JoinTokenGCPForm = ({
+ tokenState,
+ onUpdateState,
+}: {
+ tokenState: NewJoinTokenState;
+ onUpdateState: (newToken: NewJoinTokenState) => void;
+}) => {
+ const rules = tokenState.gcp;
+ function removeRule(index: number) {
+ const newRules = rules.filter((_, i) => index !== i);
+ const newState = {
+ ...tokenState,
+ gcp: newRules,
+ };
+ onUpdateState(newState);
+ }
+
+ function addNewRule() {
+ const newState = {
+ ...tokenState,
+ gcp: [
+ ...tokenState.gcp,
+ { project_ids: [], locations: [], service_accounts: [] },
+ ],
+ };
+ onUpdateState(newState);
+ }
+
+ function updateRuleField(
+ index: number,
+ fieldName: string,
+ opts: OptionGCP[]
+ ) {
+ const newState = {
+ ...tokenState,
+ gcp: tokenState.gcp.map((rule, i) => {
+ if (i === index) {
+ return { ...rule, [fieldName]: opts };
+ }
+ return rule;
+ }),
+ };
+ onUpdateState(newState);
+ }
+
+ return (
+ <>
+ {rules.map((rule, index) => (
+
+
+
+ GCP Rule
+
+
+ {rules.length > 1 && ( // at least one rule is required, so lets not allow the user to remove it
+ removeRule(index)}
+ >
+
+
+ )}
+
+
+ updateRuleField(index, 'project_ids', opts as OptionGCP[])
+ }
+ value={rule.project_ids}
+ label="Add Project ID(s)"
+ rule={requiredField('At least 1 Project ID required')}
+ />
+
+ updateRuleField(index, 'locations', opts as OptionGCP[])
+ }
+ value={rule.locations}
+ label="Add Locations"
+ labelTip="Allows regions and/or zones."
+ />
+
+ updateRuleField(index, 'service_accounts', opts as OptionGCP[])
+ }
+ value={rule.service_accounts}
+ label="Add Service Account Emails"
+ />
+
+ ))}
+
+
+ Add another GCP Rule
+
+ >
+ );
+};
diff --git a/web/packages/teleport/src/JoinTokens/JoinTokens.story.tsx b/web/packages/teleport/src/JoinTokens/JoinTokens.story.tsx
index 453abc753dd54..5e80cb99b80e3 100644
--- a/web/packages/teleport/src/JoinTokens/JoinTokens.story.tsx
+++ b/web/packages/teleport/src/JoinTokens/JoinTokens.story.tsx
@@ -68,6 +68,10 @@ const tokens: JoinToken[] = [
expiry: new Date('0001-01-01'),
method: 'token',
safeName: '******',
+ allow: [],
+ gcp: {
+ allow: [],
+ },
content: '',
},
{
@@ -77,6 +81,10 @@ const tokens: JoinToken[] = [
expiry: new Date('2023-06-01'),
method: 'iam',
safeName: 'iam-EDIT-ME-BUT-DONT-SAVE',
+ allow: [],
+ gcp: {
+ allow: [],
+ },
content: `kind: token
metadata:
name: iam-EDIT-ME-BUT-DONT-SAVE
diff --git a/web/packages/teleport/src/JoinTokens/JoinTokens.test.tsx b/web/packages/teleport/src/JoinTokens/JoinTokens.test.tsx
new file mode 100644
index 0000000000000..8a14145e8303a
--- /dev/null
+++ b/web/packages/teleport/src/JoinTokens/JoinTokens.test.tsx
@@ -0,0 +1,191 @@
+/**
+ * Teleport
+ * Copyright (C) 2023 Gravitational, Inc.
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program. If not, see .
+ */
+import { render, screen, fireEvent } from 'design/utils/testing';
+import userEvent from '@testing-library/user-event';
+import { within } from '@testing-library/react';
+
+import { createTeleportContext } from 'teleport/mocks/contexts';
+import { ContextProvider } from 'teleport';
+import makeJoinToken from 'teleport/services/joinToken/makeJoinToken';
+
+import { JoinTokens } from './JoinTokens';
+
+describe('JoinTokens', () => {
+ test('create dialog opens', async () => {
+ render();
+ await userEvent.click(
+ screen.getByRole('button', { name: /create new token/i })
+ );
+
+ expect(screen.getByText(/create a new join token/i)).toBeInTheDocument();
+ });
+
+ test('edit dialog opens with values', async () => {
+ const token = tokens[0];
+ render();
+ const optionButtons = await screen.findAllByText(/options/i);
+ await userEvent.click(optionButtons[0]);
+ const editButtons = await screen.findAllByText(/view\/edit/i);
+ await userEvent.click(editButtons[0]);
+ expect(screen.getByText(/edit token/i)).toBeInTheDocument();
+
+ expect(screen.getByDisplayValue(token.id)).toBeInTheDocument();
+ expect(
+ screen.getByDisplayValue(token.allow[0].aws_account)
+ ).toBeInTheDocument();
+ });
+
+ test('create form fails if roles arent selected', async () => {
+ render();
+ await userEvent.click(
+ screen.getByRole('button', { name: /create new token/i })
+ );
+
+ fireEvent.change(screen.getByPlaceholderText('iam-token-name'), {
+ target: { value: 'the_token' },
+ });
+
+ fireEvent.click(screen.getByRole('button', { name: /create join token/i }));
+ expect(
+ screen.getByText('At least one role is required')
+ ).toBeInTheDocument();
+ });
+
+ test('successful create adds token to the table', async () => {
+ render();
+ await userEvent.click(
+ screen.getByRole('button', { name: /create new token/i })
+ );
+
+ fireEvent.change(screen.getByPlaceholderText('iam-token-name'), {
+ target: { value: 'the_token' },
+ });
+
+ const inputEl = within(screen.getByTestId('role_select')).getByRole(
+ 'textbox'
+ );
+ fireEvent.change(inputEl, { target: { value: 'Node' } });
+ fireEvent.focus(inputEl);
+ fireEvent.keyDown(inputEl, { key: 'Enter', keyCode: 13 });
+
+ fireEvent.click(screen.getByRole('button', { name: /create join token/i }));
+ expect(
+ screen.queryByText('At least one role is required')
+ ).not.toBeInTheDocument();
+ fireEvent.change(screen.getByPlaceholderText('AWS Account ID'), {
+ target: { value: '123123123' },
+ });
+
+ await userEvent.click(
+ screen.getByRole('button', { name: /create join token/i })
+ );
+
+ expect(
+ screen.queryByText(/create a new join token/i)
+ ).not.toBeInTheDocument();
+ expect(screen.getByText('the_token')).toBeInTheDocument();
+ });
+
+ test('a rule cannot be deleted if it is the only rule', async () => {
+ render();
+ await userEvent.click(
+ screen.getByRole('button', { name: /create new token/i })
+ );
+
+ const buttons = screen.queryAllByTestId('delete_rule');
+ expect(buttons).toHaveLength(0);
+ });
+
+ test('a rule can be deleted more than one rule exists', async () => {
+ render();
+ await userEvent.click(
+ screen.getByRole('button', { name: /create new token/i })
+ );
+
+ fireEvent.click(screen.getByText('Add another AWS Rule'));
+
+ const buttons = screen.queryAllByTestId('delete_rule');
+ expect(buttons).toHaveLength(2);
+ });
+});
+
+const Component = () => {
+ const ctx = createTeleportContext();
+ jest
+ .spyOn(ctx.joinTokenService, 'fetchJoinTokens')
+ .mockResolvedValue({ items: tokens.map(makeJoinToken) });
+
+ jest.spyOn(ctx.joinTokenService, 'createJoinToken').mockResolvedValue(
+ makeJoinToken({
+ id: 'the_token',
+ safeName: 'the_token',
+ bot_name: '',
+ expiry: '3024-07-26T11:52:48.320045Z',
+ roles: ['Node'],
+ isStatic: false,
+ method: 'iam',
+ allow: [
+ {
+ aws_account: '1234444',
+ aws_arn: 'asdf',
+ },
+ ],
+ content: 'fake content',
+ })
+ );
+
+ return (
+
+
+
+ );
+};
+
+const tokens = [
+ {
+ id: '123123ffff',
+ safeName: '123123ffff',
+ bot_name: '',
+ expiry: '3024-07-26T11:52:48.320045Z',
+ roles: ['Node'],
+ isStatic: false,
+ method: 'iam',
+ allow: [
+ {
+ aws_account: '1234444',
+ aws_arn: 'asdf',
+ },
+ ],
+ content: 'fake content',
+ },
+ {
+ id: 'rrrrr',
+ safeName: 'rrrrr',
+ bot_name: '7777777',
+ expiry: '3024-07-26T12:05:48.08241Z',
+ roles: ['Bot', 'Node'],
+ isStatic: false,
+ method: 'iam',
+ allow: [
+ {
+ aws_account: '445555444',
+ },
+ ],
+ content: 'fake content',
+ },
+];
diff --git a/web/packages/teleport/src/JoinTokens/JoinTokens.tsx b/web/packages/teleport/src/JoinTokens/JoinTokens.tsx
index b4cd1fd3049df..1364471a2c68c 100644
--- a/web/packages/teleport/src/JoinTokens/JoinTokens.tsx
+++ b/web/packages/teleport/src/JoinTokens/JoinTokens.tsx
@@ -30,6 +30,7 @@ import {
MenuItem,
ButtonWarning,
ButtonSecondary,
+ Button,
} from 'design';
import Table, { Cell } from 'design/DataTable';
import { Warning } from 'design/Icon';
@@ -56,6 +57,8 @@ import { JoinToken } from 'teleport/services/joinToken';
import { Resource, KindJoinToken } from 'teleport/services/resources';
import ResourceEditor from 'teleport/components/ResourceEditor';
+import { UpsertJoinTokenDialog } from './UpsertJoinTokenDialog';
+
function makeTokenResource(token: JoinToken): Resource {
return {
id: token.id,
@@ -67,6 +70,8 @@ function makeTokenResource(token: JoinToken): Resource {
export const JoinTokens = () => {
const ctx = useTeleport();
+ const [creatingToken, setCreatingToken] = useState(false);
+ const [editingToken, setEditingToken] = useState(null);
const [tokenToDelete, setTokenToDelete] = useState(null);
const [joinTokensAttempt, runJoinTokensAttempt, setJoinTokensAttempt] =
useAsync(async () => await ctx.joinTokenService.fetchJoinTokens());
@@ -76,25 +81,17 @@ export const JoinTokens = () => {
{ join_token: '' } // we are only editing for now, so template can be empty
);
- async function handleSave(content: string): Promise {
- const token = await ctx.joinTokenService.upsertJoinToken({ content });
+ function updateTokenList(token: JoinToken): JoinToken[] {
let items = [...joinTokensAttempt.data.items];
- if (resources.status === 'creating') {
+ if (creatingToken) {
items.push(token);
} else {
- let tokenExistsInPreviousList = false;
const newItems = items.map(item => {
if (item.id === token.id) {
- tokenExistsInPreviousList = true;
return token;
}
return item;
});
- // in the edge case that someone only edits the name of the token, it will return
- // a "new" token via the upsert, and therefore should be treated as a new token
- if (!tokenExistsInPreviousList) {
- newItems.push(token);
- }
items = newItems;
}
setJoinTokensAttempt({
@@ -102,10 +99,19 @@ export const JoinTokens = () => {
status: 'success',
statusText: '',
});
+ return items;
}
- const [deleteTokenAttempt, runDeleteTokenAttempt] = useAsync(
- async (token: string) => {
+ async function handleSave(content: string): Promise {
+ const token = await ctx.joinTokenService.upsertJoinTokenYAML(
+ { content },
+ resources.item.id
+ );
+ updateTokenList(token);
+ }
+
+ const [deleteTokenAttempt, runDeleteTokenAttempt, setDeleteTokenAttempt] =
+ useAsync(async (token: string) => {
await ctx.joinTokenService.deleteJoinToken(token);
setJoinTokensAttempt({
status: 'success',
@@ -115,8 +121,9 @@ export const JoinTokens = () => {
},
});
setTokenToDelete(null);
- }
- );
+ setEditingToken(null);
+ setCreatingToken(false);
+ });
useEffect(() => {
runJoinTokensAttempt();
@@ -132,94 +139,142 @@ export const JoinTokens = () => {
alignItems="center"
>
Join Tokens
-
-
- {joinTokensAttempt.status === 'error' && (
- {joinTokensAttempt.statusText}
+ {!creatingToken && !editingToken && (
+
)}
- {deleteTokenAttempt.status === 'error' && (
- {deleteTokenAttempt.statusText}
- )}
- {joinTokensAttempt.status === 'success' && (
- ,
- },
- {
- key: 'method',
- headerText: 'Join Method',
- isSortable: true,
- },
- {
- key: 'roles',
- headerText: 'Roles',
- isSortable: false,
- render: renderRolesCell,
- },
- // expiryText is non render and used for searching
- {
- key: 'expiryText',
- isNonRender: true,
- },
- // expiry is used for sorting, but we display the expiryText value
- {
- key: 'expiry',
- headerText: 'Expires in',
- isSortable: true,
- render: ({ expiry, expiryText, isStatic, method }) => {
- const now = new Date();
- const isLongLived =
- isAfter(expiry, addHours(now, 24)) && method === 'token';
- return (
-
-
- {expiryText}
- {(isLongLived || isStatic) && (
-
-
-
- )}
-
- |
- );
+
+
+
+ {joinTokensAttempt.status === 'error' && (
+ {joinTokensAttempt.statusText}
+ )}
+ {joinTokensAttempt.status === 'success' && (
+ ,
+ },
+ {
+ key: 'method',
+ headerText: 'Join Method',
+ isSortable: true,
+ },
+ {
+ key: 'roles',
+ headerText: 'Roles',
+ isSortable: false,
+ render: renderRolesCell,
+ },
+ // expiryText is non render and used for searching
+ {
+ key: 'expiryText',
+ isNonRender: true,
},
- },
- {
- altKey: 'options-btn',
- render: (token: JoinToken) => (
- resources.edit(token.id)}
- onDelete={() => setTokenToDelete(token)}
- />
- ),
- },
- ]}
- emptyText="No active join tokens found"
- pagination={{ pageSize: 30, pagerPosition: 'top' }}
- customSearchMatchers={[searchMatcher]}
- initialSort={{
- key: 'expiry',
- dir: 'ASC',
+ // expiry is used for sorting, but we display the expiryText value
+ {
+ key: 'expiry',
+ headerText: 'Expires in',
+ isSortable: true,
+ render: ({ expiry, expiryText, isStatic, method }) => {
+ const now = new Date();
+ const isLongLived =
+ isAfter(expiry, addHours(now, 24)) && method === 'token';
+ return (
+
+
+ {expiryText}
+ {(isLongLived || isStatic) && (
+
+
+
+ )}
+
+ |
+ );
+ },
+ },
+ {
+ altKey: 'options-btn',
+ render: (token: JoinToken) => (
+ {
+ // prefer editing in the standard form
+ // if we support that join method
+ if (
+ token.method === 'iam' ||
+ token.method === 'gcp' ||
+ token.method === 'token'
+ ) {
+ setEditingToken(token);
+ return;
+ }
+ // otherwise, edit in yaml editor
+ setEditingToken(null); // close any editing token
+ resources.edit(token.id);
+ }}
+ onDelete={() => setTokenToDelete(token)}
+ />
+ ),
+ },
+ ]}
+ emptyText="No active join tokens found"
+ pagination={{ pageSize: 30, pagerPosition: 'top' }}
+ customSearchMatchers={[searchMatcher]}
+ initialSort={{
+ key: 'expiry',
+ dir: 'ASC',
+ }}
+ />
+ )}
+ {joinTokensAttempt.status === 'processing' && (
+
+
+
+ )}
+
+
+ {(creatingToken || !!editingToken) && (
+ {
+ setCreatingToken(false);
+ setEditingToken(null);
}}
/>
)}
- {joinTokensAttempt.status === 'processing' && (
-
-
-
- )}
-
+
{tokenToDelete && (
setTokenToDelete(null)}
+ onClose={() => {
+ setDeleteTokenAttempt({
+ status: 'success',
+ statusText: '',
+ data: null,
+ });
+ setTokenToDelete(null);
+ }}
onDelete={() => runDeleteTokenAttempt(tokenToDelete.id)}
attempt={deleteTokenAttempt}
/>
diff --git a/web/packages/teleport/src/JoinTokens/UpsertJoinTokenDialog.tsx b/web/packages/teleport/src/JoinTokens/UpsertJoinTokenDialog.tsx
new file mode 100644
index 0000000000000..b3ba66f4ced67
--- /dev/null
+++ b/web/packages/teleport/src/JoinTokens/UpsertJoinTokenDialog.tsx
@@ -0,0 +1,367 @@
+/**
+ * Teleport
+ * Copyright (C) 2024 Gravitational, Inc.
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program. If not, see .
+ */
+
+import { useState } from 'react';
+
+import {
+ Flex,
+ Text,
+ Box,
+ ButtonIcon,
+ ButtonText,
+ ButtonPrimary,
+ ButtonSecondary,
+ Alert,
+} from 'design';
+import styled from 'styled-components';
+import { HoverTooltip } from 'shared/components/ToolTip';
+import { Cross } from 'design/Icon';
+import Validation from 'shared/components/Validation';
+import FieldInput from 'shared/components/FieldInput';
+import { requiredField } from 'shared/components/Validation/rules';
+import { FieldSelect } from 'shared/components/FieldSelect';
+import { Option } from 'shared/components/Select';
+import { useAsync } from 'shared/hooks/useAsync';
+
+import { useTeleport } from 'teleport';
+import {
+ AWSRules,
+ CreateJoinTokenRequest,
+ JoinMethod,
+ JoinRole,
+ JoinToken,
+} from 'teleport/services/joinToken';
+
+import { JoinTokenGCPForm, JoinTokenIAMForm } from './JoinTokenForms';
+
+const maxWidth = '550px';
+
+const joinRoleOptions: OptionJoinRole[] = [
+ 'App',
+ 'Node',
+ 'Db',
+ 'Kube',
+ 'Bot',
+ 'WindowsDesktop',
+ 'Discovery',
+].map(role => ({ value: role as JoinRole, label: role as JoinRole }));
+
+const availableJoinMethods: OptionJoinMethod[] = ['iam', 'gcp'].map(method => ({
+ value: method as JoinMethod,
+ label: method as JoinMethod,
+}));
+
+export type OptionGCP = Option;
+type OptionJoinMethod = Option;
+type OptionJoinRole = Option;
+type NewJoinTokenGCPState = {
+ project_ids: OptionGCP[];
+ service_accounts: OptionGCP[];
+ locations: OptionGCP[];
+};
+
+export type NewJoinTokenState = {
+ name: string;
+ // bot_name is only required when Bot is selected in the roles
+ bot_name?: string;
+ method: OptionJoinMethod;
+ roles: OptionJoinRole[];
+ iam: AWSRules[];
+ gcp: NewJoinTokenGCPState[];
+};
+
+export const defaultNewTokenState: NewJoinTokenState = {
+ name: '',
+ bot_name: '',
+ method: { value: 'iam', label: 'iam' },
+ roles: [],
+ iam: [{ aws_account: '', aws_arn: '' }],
+ gcp: [{ project_ids: [], service_accounts: [], locations: [] }],
+};
+
+function makeDefaultEditState(token: JoinToken): NewJoinTokenState {
+ return {
+ name: token.id,
+ bot_name: token.bot_name,
+ method: {
+ value: token.method,
+ label: token.method,
+ } as OptionJoinMethod,
+ roles: token.roles.map(r => ({ value: r, label: r })) as OptionJoinRole[],
+ iam: token.allow,
+ gcp: token.gcp?.allow.map(r => ({
+ project_ids: r.project_ids?.map(i => ({ value: i, label: i })),
+ service_accounts: r.service_accounts?.map(i => ({ value: i, label: i })),
+ locations: r.locations?.map(i => ({ value: i, label: i })),
+ })),
+ };
+}
+
+export const UpsertJoinTokenDialog = ({
+ onClose,
+ updateTokenList,
+ editToken,
+ editTokenWithYAML,
+}: {
+ onClose(): void;
+ updateTokenList: (token: JoinToken) => void;
+ editToken?: JoinToken;
+ editTokenWithYAML: (tokenId: string) => void;
+}) => {
+ const ctx = useTeleport();
+ const [newTokenState, setNewTokenState] = useState(
+ editToken ? makeDefaultEditState(editToken) : defaultNewTokenState
+ );
+
+ const [createTokenAttempt, runCreateTokenAttempt] = useAsync(
+ async (req: CreateJoinTokenRequest) => {
+ const token = await ctx.joinTokenService.createJoinToken(req);
+ updateTokenList(token);
+ onClose();
+ }
+ );
+
+ function reset(validator) {
+ validator.reset();
+ setNewTokenState(defaultNewTokenState);
+ }
+
+ async function save(validator) {
+ if (!validator.validate()) {
+ return;
+ }
+
+ const request: CreateJoinTokenRequest = {
+ name: newTokenState.name,
+ roles: newTokenState.roles.map(r => r.value),
+ join_method: newTokenState.method.value,
+ };
+
+ if (newTokenState.method.value === 'iam') {
+ request.allow = newTokenState.iam;
+ }
+
+ if (request.roles.includes('Bot')) {
+ request.bot_name = newTokenState.bot_name;
+ }
+
+ if (newTokenState.method.value === 'gcp') {
+ const gcp = {
+ allow: newTokenState.gcp.map(rule => ({
+ project_ids: rule.project_ids?.map(id => id.value),
+ locations: rule.locations?.map(loc => loc.value),
+ service_accounts: rule.service_accounts?.map(
+ account => account.value
+ ),
+ })),
+ };
+ request.gcp = gcp;
+ }
+
+ runCreateTokenAttempt(request);
+ }
+
+ function setTokenRoles(roles: OptionJoinRole[]) {
+ setNewTokenState(prevState => ({
+ ...prevState,
+ roles: roles || [],
+ }));
+ }
+
+ function setTokenMethod(method: OptionJoinMethod) {
+ // set the method and reset the token rules per type for a fresh form
+ setNewTokenState(prevState => ({
+ ...prevState,
+ method,
+ iam: [{ aws_account: '', aws_arn: '' }], // default
+ }));
+ }
+
+ function setTokenField(fieldName: string, value: string) {
+ setNewTokenState(prevState => ({
+ ...prevState,
+ [fieldName]: value,
+ }));
+ }
+
+ return (
+
+
+
+
+
+
+
+
+
+
+ {editToken ? `Edit Token` : 'Create a New Join Token'}
+
+
+ {editToken && (
+ {
+ onClose();
+ editTokenWithYAML(editToken.id);
+ }}
+ >
+ Use YAML editor
+
+ )}
+
+
+ {({ validator }) => (
+
+ {createTokenAttempt.status === 'error' && (
+ {createTokenAttempt.statusText}
+ )}
+ {!editToken && ( // We only want to change the method when creating a new token
+
+ )}
+ {newTokenState.method.value !== 'token' && ( // if the method is token, we generate the name for them on the backend
+ setTokenField('name', e.target.value)}
+ readonly={!!editToken}
+ />
+ )}
+
+ {newTokenState.roles.some(i => i.value === 'Bot') && ( // if Bot is included, we must get a bot name as well
+ setTokenField('bot_name', e.target.value)}
+ />
+ )}
+ {newTokenState.method.value === 'iam' && (
+ setNewTokenState(newState)}
+ />
+ )}
+ {newTokenState.method.value === 'gcp' && (
+ setNewTokenState(newState)}
+ />
+ )}
+ theme.colors.levels.sunken};
+ border-top: 1px solid
+ ${props => props.theme.colors.spotBackground[1]};
+ `}
+ >
+ save(validator)}
+ disabled={createTokenAttempt.status === 'processing'}
+ >
+ {editToken ? 'Edit' : 'Create'} Join Token
+
+ {
+ reset(validator);
+ onClose();
+ }}
+ disabled={false}
+ >
+ Cancel
+
+
+
+ )}
+
+
+
+ );
+};
+
+export const RuleBox = styled(Box)`
+ border-color: ${props =>
+ props.theme.colors.interactive.tonal.neutral[0].background};
+ border-width: 2px;
+ border-style: solid;
+ border-radius: ${props => props.theme.radii[2]}px;
+
+ margin-bottom: ${props => props.theme.space[3]}px;
+
+ padding: ${props => props.theme.space[3]}px;
+`;
diff --git a/web/packages/teleport/src/config.ts b/web/packages/teleport/src/config.ts
index 226e7faa619bd..d54da559ce425 100644
--- a/web/packages/teleport/src/config.ts
+++ b/web/packages/teleport/src/config.ts
@@ -263,7 +263,7 @@ const cfg = {
connectMyComputerLoginsPath: '/v1/webapi/connectmycomputer/logins',
joinTokenPath: '/v1/webapi/token',
- joinTokenYamlPath: '/v1/webapi/token/yaml',
+ joinTokenYamlPath: '/v1/webapi/tokens/yaml',
joinTokensPath: '/v1/webapi/tokens',
dbScriptPath: '/scripts/:token/install-database.sh',
nodeScriptPath: '/scripts/:token/install-node.sh',
diff --git a/web/packages/teleport/src/features.tsx b/web/packages/teleport/src/features.tsx
index c687344e915e6..15bc9cc24f935 100644
--- a/web/packages/teleport/src/features.tsx
+++ b/web/packages/teleport/src/features.tsx
@@ -25,6 +25,7 @@ import {
ClipboardUser,
Cluster,
Integrations as IntegrationsIcon,
+ Key,
Laptop,
ListAddCheck,
ListThin,
@@ -125,6 +126,16 @@ export class FeatureNodes implements TeleportFeature {
// TODO (avatus) add navigationItem when ready to release
export class FeatureJoinTokens implements TeleportFeature {
category = NavigationCategory.Management;
+ section = ManagementSection.Access;
+ navigationItem = {
+ title: NavTitle.JoinTokens,
+ icon: Key,
+ exact: true,
+ getLink() {
+ return cfg.getJoinTokensRoute();
+ },
+ };
+
route = {
title: NavTitle.JoinTokens,
path: cfg.routes.joinTokens,
diff --git a/web/packages/teleport/src/services/api/api.test.ts b/web/packages/teleport/src/services/api/api.test.ts
index 3f840f4345a3a..a662a5fcc4d90 100644
--- a/web/packages/teleport/src/services/api/api.test.ts
+++ b/web/packages/teleport/src/services/api/api.test.ts
@@ -120,6 +120,31 @@ describe('api.fetch', () => {
},
});
});
+
+ const customContentType = {
+ ...customOpts,
+ headers: {
+ Accept: 'application/json',
+ 'Content-Type': 'multipart/form-data',
+ },
+ };
+
+ test('with customOptions including custom content-type', async () => {
+ await api.fetch('/something', customContentType, null);
+ expect(mockedFetch).toHaveBeenCalledTimes(1);
+
+ const firstCall = mockedFetch.mock.calls[0];
+ const [, actualRequestOptions] = firstCall;
+
+ expect(actualRequestOptions).toStrictEqual({
+ ...defaultRequestOptions,
+ ...customOpts,
+ headers: {
+ ...customContentType.headers,
+ ...getAuthHeaders(),
+ },
+ });
+ });
});
// The code below should guard us from changes to api.fetchJson which would cause it to lose type
diff --git a/web/packages/teleport/src/services/api/api.ts b/web/packages/teleport/src/services/api/api.ts
index 027fa3b30c04f..8490cecac2125 100644
--- a/web/packages/teleport/src/services/api/api.ts
+++ b/web/packages/teleport/src/services/api/api.ts
@@ -77,12 +77,21 @@ const api = {
);
},
- deleteWithHeaders(url, headers?: Record, signal?) {
- return api.fetch(url, {
- method: 'DELETE',
- headers,
- signal,
- });
+ deleteWithHeaders(
+ url,
+ headers?: Record,
+ signal?,
+ webauthnResponse?: WebauthnAssertionResponse
+ ) {
+ return api.fetchJsonWithMfaAuthnRetry(
+ url,
+ {
+ method: 'DELETE',
+ headers,
+ signal,
+ },
+ webauthnResponse
+ );
},
// TODO (avatus) add abort signal to this
@@ -97,6 +106,23 @@ const api = {
);
},
+ putWithHeaders(
+ url,
+ data,
+ headers?: Record,
+ webauthnResponse?: WebauthnAssertionResponse
+ ) {
+ return api.fetchJsonWithMfaAuthnRetry(
+ url,
+ {
+ body: JSON.stringify(data),
+ method: 'PUT',
+ headers,
+ },
+ webauthnResponse
+ );
+ },
+
/**
* fetchJsonWithMfaAuthnRetry calls on `api.fetch` and
* processes the response.
diff --git a/web/packages/teleport/src/services/joinToken/joinToken.ts b/web/packages/teleport/src/services/joinToken/joinToken.ts
index 24deb61270092..33b3faeefc809 100644
--- a/web/packages/teleport/src/services/joinToken/joinToken.ts
+++ b/web/packages/teleport/src/services/joinToken/joinToken.ts
@@ -24,6 +24,8 @@ import { makeLabelMapOfStrArrs } from '../agents/make';
import makeJoinToken from './makeJoinToken';
import { JoinToken, JoinRule, JoinTokenRequest } from './types';
+const TeleportTokenNameHeader = 'X-Teleport-TokenName';
+
class JoinTokenService {
// TODO (avatus) refactor this code to eventually use `createJoinToken`
fetchJoinToken(
@@ -46,16 +48,28 @@ class JoinTokenService {
.then(makeJoinToken);
}
- // TODO (avatus) for the first iteration, we will create tokens using only yaml and
- // slowly create a form for each token type.
- upsertJoinToken(req: JoinTokenRequest): Promise {
+ upsertJoinTokenYAML(
+ req: JoinTokenRequest,
+ tokenName: string
+ ): Promise {
return api
- .put(cfg.getJoinTokenYamlUrl(), {
- content: req.content,
- })
+ .putWithHeaders(
+ cfg.getJoinTokenYamlUrl(),
+ {
+ content: req.content,
+ },
+ {
+ [TeleportTokenNameHeader]: tokenName,
+ 'Content-Type': 'application/json',
+ }
+ )
.then(makeJoinToken);
}
+ createJoinToken(req: JoinTokenRequest): Promise {
+ return api.post(cfg.getJoinTokensUrl(), req).then(makeJoinToken);
+ }
+
fetchJoinTokens(signal: AbortSignal = null): Promise<{ items: JoinToken[] }> {
return api.get(cfg.getJoinTokensUrl(), signal).then(resp => {
return {
@@ -67,7 +81,7 @@ class JoinTokenService {
deleteJoinToken(id: string, signal: AbortSignal = null) {
return api.deleteWithHeaders(
cfg.getJoinTokensUrl(),
- { 'X-Teleport-TokenName': id },
+ { [TeleportTokenNameHeader]: id },
signal
);
}
diff --git a/web/packages/teleport/src/services/joinToken/makeJoinToken.ts b/web/packages/teleport/src/services/joinToken/makeJoinToken.ts
index 49772fd38d802..57a134dfe3588 100644
--- a/web/packages/teleport/src/services/joinToken/makeJoinToken.ts
+++ b/web/packages/teleport/src/services/joinToken/makeJoinToken.ts
@@ -16,7 +16,7 @@
* along with this program. If not, see .
*/
-import { formatDistanceStrict } from 'date-fns';
+import { formatDistanceStrict, differenceInYears } from 'date-fns';
import type { JoinToken } from './types';
@@ -28,6 +28,9 @@ export default function makeToken(json): JoinToken {
id,
roles,
isStatic,
+ allow,
+ gcp,
+ bot_name,
expiry,
method,
suggestedLabels,
@@ -41,7 +44,10 @@ export default function makeToken(json): JoinToken {
id,
isStatic,
safeName,
+ bot_name,
method,
+ allow,
+ gcp,
roles: roles?.sort((a, b) => a.localeCompare(b)) || [],
suggestedLabels: labels,
internalResourceId: extractInternalResourceId(labels),
@@ -52,14 +58,24 @@ export default function makeToken(json): JoinToken {
}
function getExpiryText(expiry: string, isStatic: boolean): string {
+ const expiryDate = new Date(expiry);
+ const now = new Date();
+
+ // dynamically configured tokens that "never expire" are set to actually expire
+ // 1000 years from now. We can just check if the expiry date is over 100 years away
+ // and show a "never" text instead of 999years. If a customer is still running teleport
+ // and using this token for over 100 years and they see the 899, maybe they
+ // actually care about the date.
+ const yearsDifference = differenceInYears(expiryDate, now);
// a manually configured token with no TTL will be set to zero date
- if (expiry == '0001-01-01T00:00:00Z' || isStatic) {
+ if (expiry == '0001-01-01T00:00:00Z' || isStatic || yearsDifference > 100) {
return 'never';
}
if (!expiry) {
return '';
}
- return formatDistanceStrict(new Date(), new Date(expiry));
+
+ return formatDistanceStrict(now, expiryDate);
}
function extractInternalResourceId(labels: any[]) {
diff --git a/web/packages/teleport/src/services/joinToken/types.ts b/web/packages/teleport/src/services/joinToken/types.ts
index 8179bb29e35e9..a36c1a975d1bd 100644
--- a/web/packages/teleport/src/services/joinToken/types.ts
+++ b/web/packages/teleport/src/services/joinToken/types.ts
@@ -24,6 +24,8 @@ export type JoinToken = {
// the first 16 chars will be * and the rest of the token's chars will be visible
// ex. ****************asdf1234
safeName: string;
+ // bot_name is present on tokens with Bot in their join roles
+ bot_name?: string;
isStatic: boolean;
// the join method of the token
method: string;
@@ -41,6 +43,10 @@ export type JoinToken = {
internalResourceId?: string;
// yaml content of the resource
content: string;
+ allow?: AWSRules[];
+ gcp?: {
+ allow: GCPRules[];
+ };
};
// JoinRole defines built-in system roles and are roles associated with
@@ -50,6 +56,7 @@ export type JoinToken = {
// - 'Db' is a role for a database proxy in the cluster
// - 'Kube' is a role for a kube service
// - 'Node' is a role for a node in the cluster
+// - 'Bot' for MachineID (when set, "spec.bot_name" must be set in the token)
// - 'WindowsDesktop' is a role for a windows desktop service.
// - 'Discovery' is a role for a discovery service.
export type JoinRole =
@@ -57,6 +64,7 @@ export type JoinRole =
| 'Node'
| 'Db'
| 'Kube'
+ | 'Bot'
| 'WindowsDesktop'
| 'Discovery';
@@ -82,6 +90,37 @@ export type JoinRule = {
awsAccountId: string;
// awsArn is used for the IAM join method.
awsArn?: string;
+ regions?: string[];
+};
+
+export type AWSRules = {
+ aws_account: string; // naming kept consistent with backend spec
+ aws_arn?: string;
+};
+
+export type GCPRules = {
+ project_ids: string[];
+ locations: string[];
+ service_accounts: string[];
+};
+
+export type JoinTokenRulesObject = AWSRules | GCPRules;
+
+export type CreateJoinTokenRequest = {
+ name: string;
+ // roles is a list of join roles, since there can be more than
+ // one role associated with a token.
+ roles: JoinRole[];
+ // bot_name only needs to be specified if "Bot" is in the selected roles.
+ // otherwise, it is ignored
+ bot_name?: string;
+ join_method: JoinMethod;
+ // rules is a list of allow rules associated with the join token
+ // and the node using this token must match one of the rules.
+ allow?: JoinTokenRulesObject[];
+ gcp?: {
+ allow: GCPRules[];
+ };
};
export type JoinTokenRequest = {