diff --git a/web/packages/shared/components/Validation/rules.ts b/web/packages/shared/components/Validation/rules.ts index 31b867969c4e2..5d0b6045fb116 100644 --- a/web/packages/shared/components/Validation/rules.ts +++ b/web/packages/shared/components/Validation/rules.ts @@ -105,22 +105,19 @@ const isIamRoleNameValid = roleName => { ); }; -const requiredIamRoleName: Rule = value => () => { - if (!value) { - return { - valid: false, - message: 'IAM role name required', - }; - } - - if (value.length > 64) { +/** + * @param name validAwsIAMRoleName verifies if the given value is a + * valid AWS IAM role name. + */ +const validAwsIAMRoleName = (name: string): ValidationResult => { + if (name.length > 64) { return { valid: false, message: 'name should be <= 64 characters', }; } - if (!isIamRoleNameValid(value)) { + if (!isIamRoleNameValid(name)) { return { valid: false, message: 'name can only contain characters @ = , . + - and alphanumerics', @@ -132,6 +129,23 @@ const requiredIamRoleName: Rule = value => () => { }; }; +/** + * requiredIamRoleName is a required field and checks for a + * value which should also be a valid AWS IAM role name. + * @param name is a role name. + * @returns ValidationResult + */ +const requiredIamRoleName: Rule = name => (): ValidationResult => { + if (!name) { + return { + valid: false, + message: 'IAM role name required', + }; + } + + return validAwsIAMRoleName(name); +}; + /** * ROLE_ARN_REGEX_STR checks provided arn (amazon resource names) is * somewhat in the format as documented here: @@ -196,6 +210,32 @@ const requiredEmailLike: Rule = email => () => { }; }; +/** + * requiredMatchingRoleNameAndRoleArn checks if a given roleArn is a valid AWS + * IAM role ARN format and contains a given roleName. + * + * @param roleName Role name that is used to match role ARN. + * @param roleArn Role ARN which is to be tested for a valid AWS IAM role ARN format. + */ +const requiredMatchingRoleNameAndRoleArn = + (roleName: string) => (roleArn: string) => () => { + const regex = new RegExp( + '^arn:aws.*:iam::\\d{12}:role\\/(' + roleName + ')$' + ); + + if (regex.test(roleArn)) { + return { + valid: true, + }; + } + + return { + valid: false, + message: + 'invalid role ARN, double check you copied and pasted the correct output', + }; + }; + /** * A rule function that combines multiple inner rule functions. All rules must * return `valid`, otherwise it returns a comma separated string containing all @@ -233,4 +273,6 @@ export { requiredIamRoleName, requiredEmailLike, requiredAll, + requiredMatchingRoleNameAndRoleArn, + validAwsIAMRoleName, }; diff --git a/web/packages/teleport/src/Integrations/Enroll/AwsOidc/AwsOidc.test.tsx b/web/packages/teleport/src/Integrations/Enroll/AwsOidc/AwsOidc.test.tsx new file mode 100644 index 0000000000000..f2aa071ec5ac4 --- /dev/null +++ b/web/packages/teleport/src/Integrations/Enroll/AwsOidc/AwsOidc.test.tsx @@ -0,0 +1,98 @@ +/** + * 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 { + fireEvent, + render, + screen, + userEvent, + waitFor, +} from 'design/utils/testing'; +import { MemoryRouter } from 'react-router'; + +import { userEventService } from 'teleport/services/userEvent'; + +import { AwsOidc } from './AwsOidc'; + +test('render', async () => { + jest + .spyOn(userEventService, 'captureIntegrationEnrollEvent') + .mockImplementation(); + render( + + + + ); + + expect(screen.getByText(/Set up your AWS account/i)).toBeInTheDocument(); + expect( + screen.getByLabelText(/Give this AWS integration a name/i) + ).toBeInTheDocument(); + expect( + screen.getByLabelText( + /Give a name for an AWS IAM role this integration will create/i + ) + ).toBeInTheDocument(); +}); + +test('generate command', async () => { + const user = userEvent.setup({ delay: null }); + jest + .spyOn(userEventService, 'captureIntegrationEnrollEvent') + .mockImplementation(); + + window.prompt = jest.fn(); + + render( + + + + ); + + const pluginConfig = { + name: 'integration-name', + roleName: 'integration-role-name', + }; + + expect(screen.getByText(/Set up your AWS account/i)).toBeInTheDocument(); + fireEvent.change(screen.getByLabelText(/Give this AWS integration a name/i), { + target: { value: pluginConfig.name }, + }); + fireEvent.change( + screen.getByLabelText( + /Give a name for an AWS IAM role this integration will create/i + ), + { + target: { value: pluginConfig.roleName }, + } + ); + + fireEvent.click(screen.getByRole('button', { name: /Generate Command/i })); + + const commandBoxEl = screen.getByText(/AWS CloudShell/i, { exact: false }); + await waitFor(() => { + expect(commandBoxEl).toBeInTheDocument(); + }); + + // the first element found shows AWS tags added by OIDC integraiton. + // second element is the command copy box. + await user.click(screen.getAllByTestId('btn-copy')[1]); + const clipboardText = await navigator.clipboard.readText(); + expect(clipboardText).toContain(`integrationName=${pluginConfig.name}`); + expect(clipboardText).toContain(`role=${pluginConfig.roleName}`); +}); diff --git a/web/packages/teleport/src/Integrations/Enroll/AwsOidc/AwsOidc.tsx b/web/packages/teleport/src/Integrations/Enroll/AwsOidc/AwsOidc.tsx index 26cbbd77acc18..b823bd87df412 100644 --- a/web/packages/teleport/src/Integrations/Enroll/AwsOidc/AwsOidc.tsx +++ b/web/packages/teleport/src/Integrations/Enroll/AwsOidc/AwsOidc.tsx @@ -16,114 +16,40 @@ * along with this program. If not, see . */ -import React, { useEffect, useState } from 'react'; -import { Link as InternalRouteLink } from 'react-router-dom'; -import { useLocation } from 'react-router'; -import styled from 'styled-components'; -import { Box, ButtonSecondary, Text, Link, Flex, ButtonPrimary } from 'design'; +import { Box, ButtonPrimary, ButtonSecondary, Flex, Link, Text } from 'design'; import * as Icons from 'design/Icon'; +import { Link as InternalRouteLink } from 'react-router-dom'; import FieldInput from 'shared/components/FieldInput'; +import Validation from 'shared/components/Validation'; import { requiredIamRoleName } from 'shared/components/Validation/rules'; -import Validation, { Validator } from 'shared/components/Validation'; -import useAttempt from 'shared/hooks/useAttemptNext'; +import styled from 'styled-components'; -import { - IntegrationEnrollEvent, - IntegrationEnrollEventData, - IntegrationEnrollKind, - userEventService, -} from 'teleport/services/userEvent'; +import { TextSelectCopyMulti } from 'teleport/components/TextSelectCopy'; +import cfg from 'teleport/config'; import { Header } from 'teleport/Discover/Shared'; +import { + ShowConfigurationScript, + RoleArnInput, +} from 'teleport/Integrations/shared'; import { AWS_RESOURCE_GROUPS_TAG_EDITOR_LINK } from 'teleport/Discover/Shared/const'; -import { DiscoverUrlLocationState } from 'teleport/Discover/useDiscover'; -import { TextSelectCopyMulti } from 'teleport/components/TextSelectCopy'; import useStickyClusterId from 'teleport/useStickyClusterId'; -import { - Integration, - IntegrationKind, - integrationService, -} from 'teleport/services/integrations'; -import cfg from 'teleport/config'; - import { FinishDialog } from './FinishDialog'; +import { useAwsOidcIntegration } from './useAwsOidcIntegration'; export function AwsOidc() { - const [integrationName, setIntegrationName] = useState(''); - const [roleArn, setRoleArn] = useState(''); - const [roleName, setRoleName] = useState(''); - const [scriptUrl, setScriptUrl] = useState(''); - const [createdIntegration, setCreatedIntegration] = useState(); - const { attempt, run } = useAttempt(''); - + const { + integrationConfig, + setIntegrationConfig, + scriptUrl, + setScriptUrl, + handleOnCreate, + createdIntegration, + createIntegrationAttempt, + generateAwsOidcConfigIdpScript, + } = useAwsOidcIntegration(); const { clusterId } = useStickyClusterId(); - const location = useLocation(); - - const [eventData] = useState({ - id: crypto.randomUUID(), - kind: IntegrationEnrollKind.AwsOidc, - }); - - useEffect(() => { - // If a user came from the discover wizard, - // discover wizard will send of appropriate events. - if (location.state?.discover) { - return; - } - - emitEvent(IntegrationEnrollEvent.Started); - // Only send event once on init. - // eslint-disable-next-line react-hooks/exhaustive-deps - }, []); - - function handleOnCreate(validator: Validator) { - if (!validator.validate()) { - return; - } - - run(() => - integrationService - .createIntegration({ - name: integrationName, - subKind: IntegrationKind.AwsOidc, - awsoidc: { - roleArn, - }, - }) - .then(res => { - setCreatedIntegration(res); - - if (location.state?.discover) { - return; - } - emitEvent(IntegrationEnrollEvent.Complete); - }) - ); - } - - function emitEvent(event: IntegrationEnrollEvent) { - userEventService.captureIntegrationEnrollEvent({ - event, - eventData, - }); - } - - function generateAwsOidcConfigIdpScript(validator: Validator) { - if (!validator.validate()) { - return; - } - - validator.reset(); - - const newScriptUrl = cfg.getAwsOidcConfigureIdpScriptUrl({ - integrationName, - roleName, - }); - - setScriptUrl(newScriptUrl); - } - return (
Set up your AWS account
@@ -168,7 +94,7 @@ export function AwsOidc() { `\n` + `teleport.dev/origin: integration_awsoidc\n` + `teleport.dev/integration: ` + - integrationName, + integrationConfig.name, }, ]} /> @@ -178,23 +104,33 @@ export function AwsOidc() { {({ validator }) => ( <> - + Step 1 setIntegrationName(e.target.value)} + onChange={e => + setIntegrationConfig({ + ...integrationConfig, + name: e.target.value, + }) + } disabled={!!scriptUrl} /> setRoleName(e.target.value)} + value={integrationConfig.roleName} + placeholder="Integration role name" + label="Give a name for an AWS IAM role this integration will create" + onChange={e => + setIntegrationConfig({ + ...integrationConfig, + roleName: e.target.value, + }) + } disabled={!!scriptUrl} /> @@ -218,64 +154,41 @@ export function AwsOidc() { {scriptUrl && ( <> - + Step 2 - - Open{' '} - - AWS CloudShell - {' '} - and copy and paste the command that configures the - permissions for you: - - - - + - + Step 3 - After configuring is finished, go to your{' '} - - IAM Role dashboard - {' '} - and copy and paste the ARN below. - setRoleArn(e.target.value)} - disabled={attempt.status === 'processing'} - toolTipContent={`Unique AWS resource identifier and uses the format: arn:aws:iam:::role/`} + + setIntegrationConfig({ + ...integrationConfig, + roleArn: v, + }) + } + disabled={createIntegrationAttempt.status === 'processing'} /> )} - {attempt.status === 'failed' && ( + {createIntegrationAttempt.status === 'error' && ( - Error: {attempt.statusText} + + Error: {createIntegrationAttempt.statusText} + )} handleOnCreate(validator)} disabled={ - !scriptUrl || attempt.status === 'processing' || !roleArn + !scriptUrl || + createIntegrationAttempt.status === 'processing' || + !integrationConfig.roleArn } > Create Integration @@ -303,24 +216,6 @@ const Container = styled(Box)` padding: ${p => p.theme.space[3]}px; `; -const requiredRoleArn = (roleName: string) => (roleArn: string) => () => { - const regex = new RegExp( - '^arn:aws.*:iam::\\d{12}:role\\/(' + roleName + ')$' - ); - - if (regex.test(roleArn)) { - return { - valid: true, - }; - } - - return { - valid: false, - message: - 'invalid role ARN, double check you copied and pasted the correct output', - }; -}; - const RouteLink = styled(InternalRouteLink)` color: ${({ theme }) => theme.colors.buttons.link.default}; diff --git a/web/packages/teleport/src/Integrations/Enroll/AwsOidc/useAwsOidcIntegration.tsx b/web/packages/teleport/src/Integrations/Enroll/AwsOidc/useAwsOidcIntegration.tsx new file mode 100644 index 0000000000000..0eb98758b80b2 --- /dev/null +++ b/web/packages/teleport/src/Integrations/Enroll/AwsOidc/useAwsOidcIntegration.tsx @@ -0,0 +1,138 @@ +/** + * 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 { useEffect, useState } from 'react'; +import { useLocation } from 'react-router'; +import { Validator } from 'shared/components/Validation'; +import { useAsync } from 'shared/hooks/useAsync'; + +import { DiscoverUrlLocationState } from 'teleport/Discover/useDiscover'; +import { + IntegrationEnrollEvent, + IntegrationEnrollEventData, + IntegrationEnrollKind, + userEventService, +} from 'teleport/services/userEvent'; +import cfg from 'teleport/config'; +import { + Integration, + IntegrationCreateRequest, + IntegrationKind, + integrationService, +} from 'teleport/services/integrations'; + +type integrationConfig = { + name: string; + roleName: string; + roleArn: string; +}; + +export function useAwsOidcIntegration() { + const [integrationConfig, setIntegrationConfig] = useState( + { + name: '', + roleName: '', + roleArn: '', + } + ); + const [scriptUrl, setScriptUrl] = useState(''); + const [createdIntegration, setCreatedIntegration] = useState(); + + const location = useLocation(); + + const [eventData] = useState({ + id: crypto.randomUUID(), + kind: IntegrationEnrollKind.AwsOidc, + }); + + useEffect(() => { + // If a user came from the discover wizard, + // discover wizard will send of appropriate events. + if (location.state?.discover) { + return; + } + + emitEvent(IntegrationEnrollEvent.Started); + // Only send event once on init. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + function emitEvent(event: IntegrationEnrollEvent) { + userEventService.captureIntegrationEnrollEvent({ + event, + eventData, + }); + } + + const [createIntegrationAttempt, runCreateIntegration] = useAsync( + async (req: IntegrationCreateRequest) => { + const resp = await integrationService.createIntegration(req); + setCreatedIntegration(resp); + return resp; + } + ); + + async function handleOnCreate(validator: Validator) { + if (!validator.validate()) { + return; + } + + const [, err] = await runCreateIntegration({ + name: integrationConfig.name, + subKind: IntegrationKind.AwsOidc, + awsoidc: { + roleArn: integrationConfig.roleArn, + }, + }); + if (err) { + return; + } + + if (location.state?.discover) { + return; + } + emitEvent(IntegrationEnrollEvent.Complete); + } + + function generateAwsOidcConfigIdpScript(validator: Validator) { + if (!validator.validate()) { + return; + } + + validator.reset(); + + const newScriptUrl = cfg.getAwsOidcConfigureIdpScriptUrl({ + integrationName: integrationConfig.name, + roleName: integrationConfig.roleName, + }); + + setScriptUrl(newScriptUrl); + } + + return { + integrationConfig, + setIntegrationConfig, + scriptUrl, + setScriptUrl, + createdIntegration, + handleOnCreate, + runCreateIntegration, + generateAwsOidcConfigIdpScript, + createIntegrationAttempt, + }; +} diff --git a/web/packages/teleport/src/Integrations/shared/RoleArnInput.story.tsx b/web/packages/teleport/src/Integrations/shared/RoleArnInput.story.tsx new file mode 100644 index 0000000000000..7807e8d14ad5c --- /dev/null +++ b/web/packages/teleport/src/Integrations/shared/RoleArnInput.story.tsx @@ -0,0 +1,93 @@ +/** + * 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 { ButtonSecondary } from 'design/Button'; +import Validation from 'shared/components/Validation'; + +import { StyledBox } from 'teleport/Discover/Shared'; + +import { RoleArnInput } from './RoleArnInput'; + +export default { + title: 'Teleport/Integrations/Shared/AwsOidc/RoleArnInput', +}; + +export const Enabled = () => { + const [roleArn, setRoleArn] = useState(''); + return ( + + + + + + ); +}; + +export const Disabled = () => { + const [roleArn, setRoleArn] = useState( + 'arn:aws:iam::1234567890:role/test-role' + ); + return ( + + + + + + ); +}; + +export const Error = () => { + const [roleArn, setRoleArn] = useState(''); + return ( + + {({ validator }) => ( + <> + + + + { + if (!validator.validate()) { + return; + } + }} + > + Test Validation + + + )} + + ); +}; diff --git a/web/packages/teleport/src/Integrations/shared/RoleArnInput.tsx b/web/packages/teleport/src/Integrations/shared/RoleArnInput.tsx new file mode 100644 index 0000000000000..00be6a478445f --- /dev/null +++ b/web/packages/teleport/src/Integrations/shared/RoleArnInput.tsx @@ -0,0 +1,65 @@ +/** + * 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 { Link, Text } from 'design'; +import FieldInput from 'shared/components/FieldInput'; +import { requiredMatchingRoleNameAndRoleArn } from 'shared/components/Validation/rules'; + +export function RoleArnInput({ + description, + roleName, + roleArn, + setRoleArn, + disabled, +}: { + description?: React.ReactNode; + roleName: string; + roleArn: string; + setRoleArn: (arn: string) => void; + disabled: boolean; +}) { + return ( + <> + {description || ( + + Once Teleport completes setting up OIDC identity provider and creating + a role named "{roleName}" in AWS cloud shell (step 2), go to your{' '} + + IAM Role dashboard + {' '} + and copy and paste the role ARN below. Teleport will use this role to + identity itself to AWS. + + )} + setRoleArn(e.target.value)} + disabled={disabled} + toolTipContent={`Unique AWS resource identifier and uses the format: arn:aws:iam:::role/`} + /> + + ); +} diff --git a/web/packages/teleport/src/Integrations/shared/ShowConfigurationScript.story.tsx b/web/packages/teleport/src/Integrations/shared/ShowConfigurationScript.story.tsx new file mode 100644 index 0000000000000..f2e11d1c52afe --- /dev/null +++ b/web/packages/teleport/src/Integrations/shared/ShowConfigurationScript.story.tsx @@ -0,0 +1,48 @@ +/** + * 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 { Text } from 'design'; + +import { StyledBox } from 'teleport/Discover/Shared'; + +import { ShowConfigurationScript } from './ShowConfigurationScript'; + +export default { + title: 'Teleport/Integrations/Shared/AwsOidc/ShowConfigurationScript', +}; + +export const Enabled = () => { + return ( + + + + ); +}; + +export const CustomDescription = () => { + const description = Custom description; + + return ( + + + + ); +}; diff --git a/web/packages/teleport/src/Integrations/shared/ShowConfigurationScript.tsx b/web/packages/teleport/src/Integrations/shared/ShowConfigurationScript.tsx new file mode 100644 index 0000000000000..25c9b75568ff3 --- /dev/null +++ b/web/packages/teleport/src/Integrations/shared/ShowConfigurationScript.tsx @@ -0,0 +1,58 @@ +/** + * 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 { Box, Link, Text } from 'design'; + +import { TextSelectCopyMulti } from 'teleport/components/TextSelectCopy'; + +export function ShowConfigurationScript({ + scriptUrl, + description, +}: { + scriptUrl: string; + description?: React.ReactNode; +}) { + return ( + <> + {description || ( + + Open{' '} + + AWS CloudShell + {' '} + and copy and paste the command provided below. Upon executing in the + AWS Shell, the command will download and execute Teleport binary that + configures Teleport as an OIDC identity provider for AWS and creates + an IAM role required for the integration. + + )} + + + + + ); +} diff --git a/web/packages/teleport/src/Integrations/shared/index.ts b/web/packages/teleport/src/Integrations/shared/index.ts new file mode 100644 index 0000000000000..d41606b55ba01 --- /dev/null +++ b/web/packages/teleport/src/Integrations/shared/index.ts @@ -0,0 +1,20 @@ +/** + * 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 . + */ + +export { ShowConfigurationScript } from './ShowConfigurationScript'; +export { RoleArnInput } from './RoleArnInput';