From 8e9cfc6c45f793fc26b031bbd6606fd6240fdc55 Mon Sep 17 00:00:00 2001 From: Michael Date: Fri, 26 Jul 2024 13:47:49 -0500 Subject: [PATCH] Add Allow and GCP fields to ui join token (#44408) --- api/types/provisioning.go | 7 + lib/web/apiserver.go | 9 +- lib/web/join_tokens.go | 75 +++- lib/web/ui/join_token.go | 19 +- .../src/JoinTokens/JoinTokenForms.tsx | 230 +++++++++++ .../src/JoinTokens/JoinTokens.story.tsx | 8 + .../src/JoinTokens/JoinTokens.test.tsx | 191 +++++++++ .../teleport/src/JoinTokens/JoinTokens.tsx | 243 +++++++----- .../src/JoinTokens/UpsertJoinTokenDialog.tsx | 367 ++++++++++++++++++ web/packages/teleport/src/config.ts | 2 +- web/packages/teleport/src/features.tsx | 11 + .../teleport/src/services/api/api.test.ts | 25 ++ web/packages/teleport/src/services/api/api.ts | 38 +- .../src/services/joinToken/joinToken.ts | 28 +- .../src/services/joinToken/makeJoinToken.ts | 22 +- .../teleport/src/services/joinToken/types.ts | 39 ++ 16 files changed, 1195 insertions(+), 119 deletions(-) create mode 100644 web/packages/teleport/src/JoinTokens/JoinTokenForms.tsx create mode 100644 web/packages/teleport/src/JoinTokens/JoinTokens.test.tsx create mode 100644 web/packages/teleport/src/JoinTokens/UpsertJoinTokenDialog.tsx 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 = {