diff --git a/web/packages/teleport/src/Discover/Database/DeployService/AutoDeploy/AutoDeploy.test.tsx b/web/packages/teleport/src/Discover/Database/DeployService/AutoDeploy/AutoDeploy.test.tsx index c239c9914c97c..2d30d48045077 100644 --- a/web/packages/teleport/src/Discover/Database/DeployService/AutoDeploy/AutoDeploy.test.tsx +++ b/web/packages/teleport/src/Discover/Database/DeployService/AutoDeploy/AutoDeploy.test.tsx @@ -75,6 +75,8 @@ const mocKIntegration: Integration = { resourceType: 'integration', spec: { roleArn: `doncare/${awsoidcRoleArn}`, + issuerS3Bucket: '', + issuerS3Prefix: '', }, statusCode: IntegrationStatusCode.Running, }; diff --git a/web/packages/teleport/src/Discover/Database/EnrollRdsDatabase/EnrollRdsDatabaseEnroll.story.tsx b/web/packages/teleport/src/Discover/Database/EnrollRdsDatabase/EnrollRdsDatabaseEnroll.story.tsx index bbabeccea0e4f..727ccf647463c 100644 --- a/web/packages/teleport/src/Discover/Database/EnrollRdsDatabase/EnrollRdsDatabaseEnroll.story.tsx +++ b/web/packages/teleport/src/Discover/Database/EnrollRdsDatabase/EnrollRdsDatabaseEnroll.story.tsx @@ -172,6 +172,8 @@ const Component = () => { resourceType: 'integration', spec: { roleArn: 'arn:aws:iam::123456789012:role/test-role-arn', + issuerS3Bucket: '', + issuerS3Prefix: '', }, statusCode: IntegrationStatusCode.Running, }, diff --git a/web/packages/teleport/src/Discover/Fixtures/databases.tsx b/web/packages/teleport/src/Discover/Fixtures/databases.tsx index 4a1d735ed6b9d..04acbe8685539 100644 --- a/web/packages/teleport/src/Discover/Fixtures/databases.tsx +++ b/web/packages/teleport/src/Discover/Fixtures/databases.tsx @@ -88,6 +88,8 @@ export function getDbMeta(): DbMeta { resourceType: 'integration', spec: { roleArn: 'arn:aws:iam::123456789012:role/test-role-arn', + issuerS3Bucket: '', + issuerS3Prefix: '', }, statusCode: IntegrationStatusCode.Running, }, diff --git a/web/packages/teleport/src/Discover/Server/CreateEc2Ice/CreateEc2Ice.story.tsx b/web/packages/teleport/src/Discover/Server/CreateEc2Ice/CreateEc2Ice.story.tsx index 2259eefd09fe4..b62b26911cad9 100644 --- a/web/packages/teleport/src/Discover/Server/CreateEc2Ice/CreateEc2Ice.story.tsx +++ b/web/packages/teleport/src/Discover/Server/CreateEc2Ice/CreateEc2Ice.story.tsx @@ -279,6 +279,8 @@ const Component = () => { resourceType: 'integration', spec: { roleArn: 'arn-123', + issuerS3Bucket: '', + issuerS3Prefix: '', }, statusCode: IntegrationStatusCode.Running, }, diff --git a/web/packages/teleport/src/Discover/Server/EnrollEc2Instance/EnrollEc2Instance.story.tsx b/web/packages/teleport/src/Discover/Server/EnrollEc2Instance/EnrollEc2Instance.story.tsx index 87afc3e4d0ed5..0f6a46186a992 100644 --- a/web/packages/teleport/src/Discover/Server/EnrollEc2Instance/EnrollEc2Instance.story.tsx +++ b/web/packages/teleport/src/Discover/Server/EnrollEc2Instance/EnrollEc2Instance.story.tsx @@ -110,6 +110,8 @@ const Component = () => { resourceType: 'integration', spec: { roleArn: 'arn-123', + issuerS3Bucket: '', + issuerS3Prefix: '', }, statusCode: IntegrationStatusCode.Running, }, diff --git a/web/packages/teleport/src/Discover/Server/EnrollEc2Instance/EnrollEc2Instance.test.tsx b/web/packages/teleport/src/Discover/Server/EnrollEc2Instance/EnrollEc2Instance.test.tsx index 4b2817dc38e97..e844affe3a12c 100644 --- a/web/packages/teleport/src/Discover/Server/EnrollEc2Instance/EnrollEc2Instance.test.tsx +++ b/web/packages/teleport/src/Discover/Server/EnrollEc2Instance/EnrollEc2Instance.test.tsx @@ -51,6 +51,8 @@ describe('test EnrollEc2Instance.tsx', () => { resourceType: 'integration', spec: { roleArn: 'arn-123', + issuerS3Bucket: '', + issuerS3Prefix: '', }, statusCode: IntegrationStatusCode.Running, }, diff --git a/web/packages/teleport/src/Integrations/EditAwsOidcIntegrationDialog.test.tsx b/web/packages/teleport/src/Integrations/EditAwsOidcIntegrationDialog.test.tsx new file mode 100644 index 0000000000000..42e376d9ed322 --- /dev/null +++ b/web/packages/teleport/src/Integrations/EditAwsOidcIntegrationDialog.test.tsx @@ -0,0 +1,194 @@ +/** + * 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 { + Integration, + IntegrationKind, + IntegrationStatusCode, +} from 'teleport/services/integrations'; + +import { EditAwsOidcIntegrationDialog } from './EditAwsOidcIntegrationDialog'; + +test('edit without s3 fields', async () => { + render( + null} + edit={() => null} + integration={{ + resourceType: 'integration', + kind: IntegrationKind.AwsOidc, + name: 'some-integration-name', + spec: { + roleArn: 'arn:aws:iam::123456789012:role/johndoe', + issuerS3Bucket: '', + issuerS3Prefix: '', + }, + statusCode: IntegrationStatusCode.Running, + }} + /> + ); + + // Initial state. + expect(screen.getByText(/required/i)).toBeInTheDocument(); + expect(screen.queryByTestId('scriptbox')).not.toBeInTheDocument(); + expect(screen.queryByTestId('checkbox')).not.toBeInTheDocument(); + expect(screen.getByRole('button', { name: /save/i })).toBeDisabled(); + + // Click on generate command: + // - script rendered + // - checkbox to confirm user has ran command + // - edit button replaces generate command button + // - save button still disabled + fireEvent.click(screen.getByRole('button', { name: /generate command/i })); + screen.getByRole('button', { name: /edit/i }); + expect(screen.getByRole('button', { name: /save/i })).toBeDisabled(); + expect( + screen.queryByRole('button', { name: /generate command/i }) + ).not.toBeInTheDocument(); + expect(screen.getByTestId('checkbox')).toBeInTheDocument(); + expect(screen.getByTestId('scriptbox')).toBeInTheDocument(); + + // Click on checkbox should enable save button and disable edit button. + fireEvent.click(screen.getByRole('checkbox')); + expect(screen.getByRole('button', { name: /save/i })).toBeEnabled(); + expect(screen.getByRole('button', { name: /edit/i })).toBeDisabled(); + + // Unchecking the checkbox should disable save button. + fireEvent.click(screen.getByRole('checkbox')); + expect(screen.getByRole('button', { name: /save/i })).toBeDisabled(); + + // Click on edit, should replace it with generate command + fireEvent.click(screen.getByRole('button', { name: /edit/i })); + expect( + screen.getByRole('button', { name: /generate command/i }) + ).toBeEnabled(); +}); + +test('edit with s3 fields', async () => { + render( + null} + edit={() => null} + integration={integration} + /> + ); + + // Initial state. + expect(screen.queryByText(/required/i)).not.toBeInTheDocument(); + expect(screen.queryByTestId('scriptbox')).not.toBeInTheDocument(); + expect(screen.queryByTestId('checkbox')).not.toBeInTheDocument(); + expect(screen.getByRole('button', { name: /save/i })).toBeDisabled(); + expect( + screen.queryByRole('button', { name: /generate command/i }) + ).not.toBeInTheDocument(); + + // Changing role arn should not render generate command. + fireEvent.change(screen.getByPlaceholderText(/arn:aws:iam:/i), { + target: { value: 'something else' }, + }); + expect(screen.getByRole('button', { name: /save/i })).toBeEnabled(); + expect( + screen.queryByRole('button', { name: /generate command/i }) + ).not.toBeInTheDocument(); + + // Changing the s3 fields should render generate command. + fireEvent.change(screen.getByPlaceholderText(/bucket/i), { + target: { value: 's3-bucket-something' }, + }); + fireEvent.click(screen.getByRole('button', { name: /generate command/i })); + expect(screen.getByRole('button', { name: /save/i })).toBeDisabled(); +}); + +test('edit invalid fields', async () => { + render( + null} + edit={() => null} + integration={integration} + /> + ); + + expect(screen.getByRole('button', { name: /save/i })).toBeDisabled(); + + // invalid role arn + fireEvent.change(screen.getByPlaceholderText(/arn:aws:iam:/i), { + target: { value: 'role something else' }, + }); + + fireEvent.click(screen.getByRole('button', { name: /save/i })); + expect(screen.getByText(/invalid role ARN format/i)).toBeInTheDocument(); + + // invalid s3 fields + fireEvent.change(screen.getByPlaceholderText(/bucket/i), { + target: { value: '' }, + }); + fireEvent.change(screen.getByPlaceholderText(/prefix/i), { + target: { value: '' }, + }); + fireEvent.click(screen.getByRole('button', { name: /generate command/i })); + expect(screen.queryAllByText(/required/i)).toHaveLength(2); +}); + +test('edit submit', async () => { + const mockEditFn = jest.fn(); + render( + null} + edit={mockEditFn} + integration={integration} + /> + ); + + expect(screen.getByRole('button', { name: /save/i })).toBeDisabled(); + + // change role arn + fireEvent.change(screen.getByPlaceholderText(/arn:aws:iam:/i), { + target: { value: 'arn:aws:iam::123456789011:role/other' }, + }); + + // change s3 fields + fireEvent.change(screen.getByPlaceholderText(/bucket/i), { + target: { value: 'other-bucket' }, + }); + fireEvent.change(screen.getByPlaceholderText(/prefix/i), { + target: { value: 'other-prefix' }, + }); + + fireEvent.click(screen.getByRole('button', { name: /generate command/i })); + fireEvent.click(screen.getByRole('checkbox')); + fireEvent.click(screen.getByRole('button', { name: /save/i })); + + expect(mockEditFn).toHaveBeenCalledWith({ + roleArn: 'arn:aws:iam::123456789011:role/other', + s3Bucket: 'other-bucket', + s3Prefix: 'other-prefix', + }); +}); + +const integration: Integration = { + resourceType: 'integration', + kind: IntegrationKind.AwsOidc, + name: 'some-integration-name', + spec: { + roleArn: 'arn:aws:iam::123456789012:role/johndoe', + issuerS3Bucket: 's3-bucket', + issuerS3Prefix: 's3-prefix', + }, + statusCode: IntegrationStatusCode.Running, +}; diff --git a/web/packages/teleport/src/Integrations/EditAwsOidcIntegrationDialog.tsx b/web/packages/teleport/src/Integrations/EditAwsOidcIntegrationDialog.tsx new file mode 100644 index 0000000000000..46adec5ca21d4 --- /dev/null +++ b/web/packages/teleport/src/Integrations/EditAwsOidcIntegrationDialog.tsx @@ -0,0 +1,258 @@ +/** + * Teleport + * Copyright (C) 2023 Gravitational, Inc. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +import React, { useState } from 'react'; +import styled from 'styled-components'; +import { + ButtonSecondary, + ButtonPrimary, + ButtonBorder, + Alert, + Text, + Box, + Flex, + Link, +} from 'design'; +import Dialog, { + DialogHeader, + DialogTitle, + DialogContent, + DialogFooter, +} from 'design/DialogConfirmation'; +import useAttempt from 'shared/hooks/useAttemptNext'; +import FieldInput from 'shared/components/FieldInput'; +import Validation, { Validator } from 'shared/components/Validation'; +import { requiredRoleArn } from 'shared/components/Validation/rules'; +import { ToolTipInfo } from 'shared/components/ToolTip'; +import { CheckboxInput } from 'design/Checkbox'; + +import { TextSelectCopyMulti } from 'teleport/components/TextSelectCopy'; +import { Integration } from 'teleport/services/integrations'; +import cfg from 'teleport/config'; + +import { EditableIntegrationFields } from './Operations/useIntegrationOperation'; +import { S3BucketConfiguration } from './Enroll/AwsOidc/S3BucketConfiguration'; +import { + getDefaultS3BucketName, + getDefaultS3PrefixName, +} from './Enroll/AwsOidc/Shared/utils'; + +type Props = { + close(): void; + edit(req: EditableIntegrationFields): Promise; + integration: Integration; +}; + +export function EditAwsOidcIntegrationDialog(props: Props) { + const { close, edit, integration } = props; + const { attempt, run } = useAttempt(); + + const [roleArn, setRoleArn] = useState(integration.spec.roleArn); + const [s3Bucket, setS3Bucket] = useState( + () => integration.spec.issuerS3Bucket || getDefaultS3BucketName() + ); + const [s3Prefix, setS3Prefix] = useState( + () => + integration.spec.issuerS3Prefix || + getDefaultS3PrefixName(integration.spec.roleArn.split(':role/')[1]) + ); + + const [scriptUrl, setScriptUrl] = useState(''); + const [confirmed, setConfirmed] = useState(false); + + function handleEdit(validator: Validator) { + if (!validator.validate()) { + return; + } + + run(() => edit({ roleArn, s3Bucket, s3Prefix })); + } + + function generateAwsOidcConfigIdpScript(validator: Validator) { + if (!validator.validate()) { + return; + } + + validator.reset(); + + const roleName = roleArn.split(':role/')[1]; + const newScriptUrl = cfg.getAwsOidcConfigureIdpScriptUrl({ + integrationName: integration.name, + roleName, + s3Bucket: s3Bucket, + s3Prefix: s3Prefix, + }); + + setScriptUrl(newScriptUrl); + } + + const isProcessing = attempt.status === 'processing'; + const requiresS3 = + !integration.spec.issuerS3Bucket || !integration.spec.issuerS3Prefix; + const showGenerateCommand = + requiresS3 || + integration.spec.issuerS3Bucket !== s3Bucket || + integration.spec.issuerS3Prefix !== s3Prefix; + + return ( + + {({ validator }) => ( + + + Edit Integration + + + {attempt.status === 'failed' && ( + + )} + + setRoleArn(e.target.value)} + placeholder="arn:aws:iam:::role/" + toolTipContent={ + + Role ARN can be found in the format:
+ {`arn:aws:iam:::role/`} +
+ } + disabled={scriptUrl} + /> + + {requiresS3 && ( + + Required + + This integration does not have Amazon S3 configured + + + )} + + {scriptUrl && ( + + Configure the required permission in your AWS account. + + Open{' '} + + AWS CloudShell + {' '} + and copy and paste the command that configures the + permissions for you: + + + + + + )} + {scriptUrl && ( + setScriptUrl('')} + disabled={confirmed} + > + Edit + + )} + {showGenerateCommand && !scriptUrl && ( + generateAwsOidcConfigIdpScript(validator)} + disabled={!s3Bucket || !s3Prefix || !roleArn} + > + Generate Command + + )} + +
+ + {showGenerateCommand && scriptUrl && ( + + { + setConfirmed(e.target.checked); + }} + /> + I have ran the command + + )} + handleEdit(validator)} + > + Save + + + Cancel + + +
+ )} +
+ ); +} + +const S3BucketBox = styled(Box)` + border-radius: ${p => p.theme.space[1]}px; + border: 2px solid + ${p => { + if (p.requiresS3) { + return p.theme.colors.warning.main; + } + return p.theme.colors.spotBackground[1]; + }}; + background-color: ${p => { + if (p.requiresS3) { + return p.theme.colors.interactive.tonal.alert[0]; + } + return p.theme.colors.spotBackground[0]; + }}; +`; diff --git a/web/packages/teleport/src/Integrations/EditIntegrationDialog.tsx b/web/packages/teleport/src/Integrations/EditIntegrationDialog.tsx deleted file mode 100644 index f470315e2d690..0000000000000 --- a/web/packages/teleport/src/Integrations/EditIntegrationDialog.tsx +++ /dev/null @@ -1,102 +0,0 @@ -/** - * Copyright 2023 Gravitational, Inc. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -import React, { useState } from 'react'; -import { ButtonSecondary, ButtonPrimary, Alert, Text } from 'design'; -import Dialog, { - DialogHeader, - DialogTitle, - DialogContent, - DialogFooter, -} from 'design/DialogConfirmation'; -import useAttempt from 'shared/hooks/useAttemptNext'; -import FieldInput from 'shared/components/FieldInput'; -import Validation, { Validator } from 'shared/components/Validation'; -import { requiredRoleArn } from 'shared/components/Validation/rules'; - -import { Integration } from 'teleport/services/integrations'; - -import { EditableIntegrationFields } from './Operations/useIntegrationOperation'; - -type Props = { - close(): void; - edit(req: EditableIntegrationFields): Promise; - integration: Integration; -}; - -export function EditIntegrationDialog(props: Props) { - const { close, edit, integration } = props; - const { attempt, run } = useAttempt(); - - const [roleArn, setRoleArn] = useState(integration.spec.roleArn); - - const isProcessing = attempt.status === 'processing'; - - function handleEdit(validator: Validator) { - if (!validator.validate()) { - return; - } - - run(() => edit({ roleArn })); - } - - return ( - - {({ validator }) => ( - - - Edit Integration - - - {attempt.status === 'failed' && ( - - )} - - setRoleArn(e.target.value)} - toolTipContent={ - - Role ARN can be found in the format:
- {`arn:aws:iam:::role/`} -
- } - /> -
- - handleEdit(validator)} - > - Save - - - Cancel - - -
- )} -
- ); -} diff --git a/web/packages/teleport/src/Integrations/Enroll/AwsOidc/AwsOidc.tsx b/web/packages/teleport/src/Integrations/Enroll/AwsOidc/AwsOidc.tsx index 41fd9828b2bec..a5f7cc11001b9 100644 --- a/web/packages/teleport/src/Integrations/Enroll/AwsOidc/AwsOidc.tsx +++ b/web/packages/teleport/src/Integrations/Enroll/AwsOidc/AwsOidc.tsx @@ -21,10 +21,7 @@ import styled from 'styled-components'; import { Box, ButtonSecondary, Text, Link, Flex, ButtonPrimary } from 'design'; import * as Icons from 'design/Icon'; import FieldInput from 'shared/components/FieldInput'; -import { - requiredField, - requiredIamRoleName, -} from 'shared/components/Validation/rules'; +import { requiredIamRoleName } from 'shared/components/Validation/rules'; import Validation, { Validator } from 'shared/components/Validation'; import useAttempt from 'shared/hooks/useAttemptNext'; @@ -46,12 +43,20 @@ import { import cfg from 'teleport/config'; import { FinishDialog } from './FinishDialog'; +import { S3BucketConfiguration } from './S3BucketConfiguration'; +import { + getDefaultS3BucketName, + requiredPrefixName, + validPrefixNameToolTipContent, +} from './Shared/utils'; export function AwsOidc() { const [integrationName, setIntegrationName] = useState(''); const [roleArn, setRoleArn] = useState(''); const [roleName, setRoleName] = useState(''); const [scriptUrl, setScriptUrl] = useState(''); + const [s3Bucket, setS3Bucket] = useState(() => getDefaultS3BucketName()); + const [s3Prefix, setS3Prefix] = useState(''); const [createdIntegration, setCreatedIntegration] = useState(); const { attempt, run } = useAttempt(''); @@ -86,6 +91,8 @@ export function AwsOidc() { subKind: IntegrationKind.AwsOidc, awsoidc: { roleArn, + issuerS3Bucket: s3Bucket, + issuerS3Prefix: s3Prefix, }, }) .then(res => { @@ -116,6 +123,8 @@ export function AwsOidc() { const newScriptUrl = cfg.getAwsOidcConfigureIdpScriptUrl({ integrationName, roleName, + s3Bucket, + s3Prefix, }); setScriptUrl(newScriptUrl); @@ -156,25 +165,39 @@ export function AwsOidc() { Step 1 - setIntegrationName(e.target.value)} - disabled={!!scriptUrl} - /> - setRoleName(e.target.value)} - disabled={!!scriptUrl} - /> + + setIntegrationName(e.target.value)} + disabled={!!scriptUrl} + onBlur={() => { + // Help come up with a default prefix name for user. + if (!s3Prefix) { + setS3Prefix(`${integrationName}-oidc-idp`); + } + }} + toolTipContent={validPrefixNameToolTipContent('Integration')} + /> + setRoleName(e.target.value)} + disabled={!!scriptUrl} + /> + + {scriptUrl ? ( setScriptUrl('')}> Edit diff --git a/web/packages/teleport/src/Integrations/Enroll/AwsOidc/FinishDialog.story.tsx b/web/packages/teleport/src/Integrations/Enroll/AwsOidc/FinishDialog.story.tsx index 2f3feeb636395..53d2b0a62520c 100644 --- a/web/packages/teleport/src/Integrations/Enroll/AwsOidc/FinishDialog.story.tsx +++ b/web/packages/teleport/src/Integrations/Enroll/AwsOidc/FinishDialog.story.tsx @@ -36,7 +36,11 @@ export const Story = () => ( kind: IntegrationKind.AwsOidc, name: 'some-integration-name', statusCode: IntegrationStatusCode.Running, - spec: { roleArn: 'some-role-arn' }, + spec: { + roleArn: 'some-role-arn', + issuerS3Bucket: '', + issuerS3Prefix: '', + }, }} /> @@ -51,7 +55,11 @@ export const FinishDialogueDiscover = () => ( kind: IntegrationKind.AwsOidc, name: 'some-integration-name', statusCode: IntegrationStatusCode.Running, - spec: { roleArn: 'some-role-arn' }, + spec: { + roleArn: 'some-role-arn', + issuerS3Bucket: '', + issuerS3Prefix: '', + }, }} /> diff --git a/web/packages/teleport/src/Integrations/Enroll/AwsOidc/S3BucketConfiguration.tsx b/web/packages/teleport/src/Integrations/Enroll/AwsOidc/S3BucketConfiguration.tsx new file mode 100644 index 0000000000000..0a8164a248d0f --- /dev/null +++ b/web/packages/teleport/src/Integrations/Enroll/AwsOidc/S3BucketConfiguration.tsx @@ -0,0 +1,82 @@ +/** + * Teleport + * Copyright (C) 2023 Gravitational, Inc. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +import React from 'react'; +import { Text, Flex } from 'design'; +import FieldInput from 'shared/components/FieldInput'; +import { ToolTipInfo } from 'shared/components/ToolTip'; + +import { + requiredBucketName, + requiredPrefixName, + validPrefixNameToolTipContent, +} from './Shared/utils'; + +export function S3BucketConfiguration({ + s3Bucket, + setS3Bucket, + s3Prefix, + setS3Prefix, + disabled, +}: { + s3Bucket: string; + setS3Bucket(s: string): void; + s3Prefix: string; + setS3Prefix(s: string): void; + disabled: boolean; +}) { + return ( + <> + + Amazon S3 Location + + Teleport will create and use Amazon S3 Bucket as this integration's + issuer and will publicly host two files: one for the OpenID + configuration and another one for the public key. + + + + setS3Bucket(e.target.value.trim())} + disabled={disabled} + toolTipContent={ + + Bucket name can consist only of lowercase letters and numbers. + Hyphens (-) are allowed in between letters and numbers. + + } + /> + setS3Prefix(e.target.value.trim())} + disabled={disabled} + toolTipContent={validPrefixNameToolTipContent('Prefix')} + /> + + + ); +} diff --git a/web/packages/teleport/src/Integrations/Enroll/AwsOidc/Shared/Shared.tsx b/web/packages/teleport/src/Integrations/Enroll/AwsOidc/Shared/Shared.tsx new file mode 100644 index 0000000000000..82f059ddbc15b --- /dev/null +++ b/web/packages/teleport/src/Integrations/Enroll/AwsOidc/Shared/Shared.tsx @@ -0,0 +1,37 @@ +/** + * 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 { Validator } from 'shared/components/Validation'; + +import cfg, { UrlAwsOidcConfigureIdp } from 'teleport/config'; + +export function generateAwsOidcConfigIdpScript( + validator: Validator, + setScriptUrl: (s: string) => void, + params: UrlAwsOidcConfigureIdp +) { + if (!validator.validate()) { + return; + } + + validator.reset(); + + const newScriptUrl = cfg.getAwsOidcConfigureIdpScriptUrl(params); + + setScriptUrl(newScriptUrl); +} diff --git a/web/packages/teleport/src/Integrations/Enroll/AwsOidc/Shared/utils.test.ts b/web/packages/teleport/src/Integrations/Enroll/AwsOidc/Shared/utils.test.ts new file mode 100644 index 0000000000000..8dc382b0b0e60 --- /dev/null +++ b/web/packages/teleport/src/Integrations/Enroll/AwsOidc/Shared/utils.test.ts @@ -0,0 +1,89 @@ +/** + * 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 cfg from 'teleport/config'; + +import { + getDefaultS3BucketName, + getDefaultS3PrefixName, + requiredPrefixName, + requiredBucketName, +} from './utils'; + +const defaultProxyCluster = cfg.proxyCluster; + +afterEach(() => { + cfg.proxyCluster = defaultProxyCluster; +}); + +test('getDefaultS3BucketName', () => { + cfg.proxyCluster = 'llama#42.slkej'; + expect(getDefaultS3BucketName()).toBe(''); + + cfg.proxyCluster = 'llama.cloud.gravitational-io'; + expect(getDefaultS3BucketName()).toBe('llama-cloud-gravitational-io'); +}); + +test('getDefaultS3PrefixName', () => { + expect(getDefaultS3PrefixName('')).toBe(''); + expect(getDefaultS3PrefixName('sdf@$@#sdf')).toBe(''); + + cfg.proxyCluster = 'llama.cloud.gravitational-io'; + expect(getDefaultS3PrefixName('int-name')).toBe('int-name-oidc-idp'); +}); + +describe('requiredPrefixName', () => { + test.each` + input | valid + ${''} | ${false} + ${Array.from('x'.repeat(64))} | ${false} + ${'-sdf'} | ${false} + ${'sdfs-'} | ${false} + ${'_sdf'} | ${false} + ${'sdfd_'} | ${false} + ${'..sdf'} | ${false} + ${'sdf.'} | ${false} + ${'sdlfkjs/dfsd'} | ${false} + ${'Asd09f-_.sdfDFs1'} | ${true} + `('validity of input($input) should be ($valid)', ({ input, valid }) => { + const result = requiredPrefixName(input)(); + expect(result.valid).toEqual(valid); + }); +}); + +describe('requiredBucketName', () => { + test.each` + input | valid + ${''} | ${false} + ${Array.from('x'.repeat(64))} | ${false} + ${Array.from('x'.repeat(2))} | ${false} + ${'-sdf'} | ${false} + ${'sdfs-'} | ${false} + ${'sdfds_sdf'} | ${false} + ${'xn--sdf'} | ${false} + ${'sthree-sdf'} | ${false} + ${'sthree-configurator-dfs'} | ${false} + ${'sdf-s3alias'} | ${false} + ${'sdf--ol-s3'} | ${false} + ${'Asd09f-sdfDFs1'} | ${false} + ${'sdf0-dfs0'} | ${true} + `('validity of input($input) should be ($valid)', ({ input, valid }) => { + const result = requiredBucketName(input)(); + expect(result.valid).toEqual(valid); + }); +}); diff --git a/web/packages/teleport/src/Integrations/Enroll/AwsOidc/Shared/utils.ts b/web/packages/teleport/src/Integrations/Enroll/AwsOidc/Shared/utils.ts new file mode 100644 index 0000000000000..915a32b6b7a96 --- /dev/null +++ b/web/packages/teleport/src/Integrations/Enroll/AwsOidc/Shared/utils.ts @@ -0,0 +1,139 @@ +/** + * 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 { Rule } from 'shared/components/Validation/rules'; + +import cfg from 'teleport/config'; + +// Must start and end with lowercase letters or numbers. +// Can have hyphens in between start and end. +const bucketNameRegex = new RegExp(/^[a-z0-9][a-z0-9-]*[a-z0-9]$/); +export const requiredBucketName: Rule = inputVal => () => { + if (!inputVal) { + return { + valid: false, + message: 'required', + }; + } + + if (inputVal.length < 3 || inputVal.length > 63) { + return { + valid: false, + message: 'name should be 3-63 characters', + }; + } + + if (!bucketNameRegex.test(inputVal)) { + return { + valid: false, + message: 'name is in a invalid format', + }; + } + + if (inputVal.startsWith('xn--')) { + return { + valid: false, + message: 'cannot start with "xn--"', + }; + } + + if (inputVal.startsWith('sthree-')) { + return { + valid: false, + message: 'cannot start with "sthree-"', + }; + } + + if (inputVal.startsWith('sthree-configurator')) { + return { + valid: false, + message: 'cannot start with "sthree-configurator"', + }; + } + + if (inputVal.endsWith('-s3alias')) { + return { + valid: false, + message: 'cannot end with "-s3alias"', + }; + } + + if (inputVal.endsWith('--ol-s3')) { + return { + valid: false, + message: 'cannot end with "--ol-s3"', + }; + } + + return { + valid: true, + }; +}; + +// Must start and end with letters or numbers. +// Can have hyphens, underscores, and periods in between start and end. +const prefixNameRegex = new RegExp(/^[a-zA-Z0-9][a-zA-Z0-9-_.]*[a-zA-Z0-9]$/); +export const requiredPrefixName: Rule = inputVal => () => { + if (!inputVal) { + return { + valid: false, + message: 'required', + }; + } + + // Just a random hard cap. + if (inputVal.length > 63) { + return { + valid: false, + message: 'name can be max 63 characters long', + }; + } + + if (!prefixNameRegex.test(inputVal)) { + return { + valid: false, + message: 'name is in a invalid format', + }; + } + + return { + valid: true, + }; +}; + +export function getDefaultS3BucketName() { + const modifiedClusterName = cfg.proxyCluster.replaceAll('.', '-'); + if (bucketNameRegex.test(modifiedClusterName)) { + return modifiedClusterName; + } + + return ''; +} + +export function getDefaultS3PrefixName(integrationName: string) { + if (!integrationName || !prefixNameRegex.test(integrationName)) { + return ''; + } + + return `${integrationName}-oidc-idp`; +} + +export function validPrefixNameToolTipContent(fieldName: string) { + return `${fieldName} name can consist only of letters and numbers. \ + Hyphens (-), dots (.), and underscores (_) are allowed in between letters and numbers.`; +} diff --git a/web/packages/teleport/src/Integrations/IntegrationList.tsx b/web/packages/teleport/src/Integrations/IntegrationList.tsx index 93a3894d996a5..b00e01c6035e1 100644 --- a/web/packages/teleport/src/Integrations/IntegrationList.tsx +++ b/web/packages/teleport/src/Integrations/IntegrationList.tsx @@ -103,7 +103,11 @@ export function IntegrationList(props: Props) { ); } - if (item.resourceType === 'integration') { + if ( + item.resourceType === 'integration' && + // Currently, only AWSOIDC supports editing. + item.kind === IntegrationKind.AwsOidc + ) { return ( @@ -177,6 +181,26 @@ export function IntegrationList(props: Props) { } const StatusCell = ({ item }: { item: IntegrationLike }) => { + if ( + item.resourceType === 'integration' && + item.kind === IntegrationKind.AwsOidc && + (!item.spec.issuerS3Bucket || !item.spec.issuerS3Prefix) + ) { + return ( + + + + Integration needs updating + + + Requires setting up a Amazon S3 Bucket. Click on 'OPTIONS' and + 'Edit...' and fill out the 'S3 Location' input fields. + + + + + ); + } const status = getStatus(item); const statusDescription = getStatusCodeDescription(item.statusCode); diff --git a/web/packages/teleport/src/Integrations/Integrations.story.tsx b/web/packages/teleport/src/Integrations/Integrations.story.tsx index ed79f552eca61..1e579aa51187b 100644 --- a/web/packages/teleport/src/Integrations/Integrations.story.tsx +++ b/web/packages/teleport/src/Integrations/Integrations.story.tsx @@ -23,7 +23,7 @@ import { import { IntegrationList } from './IntegrationList'; import { DeleteIntegrationDialog } from './RemoveIntegrationDialog'; -import { EditIntegrationDialog } from './EditIntegrationDialog'; +import { EditAwsOidcIntegrationDialog } from './EditAwsOidcIntegrationDialog'; import { plugins, integrations } from './fixtures'; export default { @@ -44,16 +44,40 @@ export function DeleteDialog() { ); } -export function EditDialog() { +export function EditDialogWithoutS3() { return ( - null} edit={() => null} integration={{ resourceType: 'integration', kind: IntegrationKind.AwsOidc, name: 'some-integration-name', - spec: { roleArn: 'arn:aws:iam::123456789012:roles/johndoe' }, + spec: { + roleArn: 'arn:aws:iam::123456789012:role/johndoe', + issuerS3Bucket: '', + issuerS3Prefix: '', + }, + statusCode: IntegrationStatusCode.Running, + }} + /> + ); +} + +export function EditDialogWithS3() { + return ( + null} + edit={() => null} + integration={{ + resourceType: 'integration', + kind: IntegrationKind.AwsOidc, + name: 'some-integration-name', + spec: { + roleArn: 'arn:aws:iam::123456789012:role/johndoe', + issuerS3Bucket: 'named-bucket', + issuerS3Prefix: 'named-prefix', + }, statusCode: IntegrationStatusCode.Running, }} /> diff --git a/web/packages/teleport/src/Integrations/Operations/IntegrationOperations.tsx b/web/packages/teleport/src/Integrations/Operations/IntegrationOperations.tsx index 75a1e07a98f3d..de49a8aba4008 100644 --- a/web/packages/teleport/src/Integrations/Operations/IntegrationOperations.tsx +++ b/web/packages/teleport/src/Integrations/Operations/IntegrationOperations.tsx @@ -19,7 +19,7 @@ import React from 'react'; import { Integration } from 'teleport/services/integrations'; import { DeleteIntegrationDialog } from '../RemoveIntegrationDialog'; -import { EditIntegrationDialog } from '../EditIntegrationDialog'; +import { EditAwsOidcIntegrationDialog } from '../EditAwsOidcIntegrationDialog'; import { OperationType, @@ -53,7 +53,7 @@ export function IntegrationOperations({ if (operation === 'edit') { return ( - { const params: UrlAwsOidcConfigureIdp = { integrationName: 'int-name', roleName: 'role-arn', + s3Bucket: 's3-bucket', + s3Prefix: 's3-prefix', }; const base = 'http://localhost/webapi/scripts/integrations/configure/awsoidc-idp.sh?'; - const expected = `integrationName=${'int-name'}&role=${'role-arn'}`; + const expected = `integrationName=int-name&role=role-arn&s3Bucket=s3-bucket&s3Prefix=s3-prefix`; expect(cfg.getAwsOidcConfigureIdpScriptUrl(params)).toBe( `${base}${expected}` ); diff --git a/web/packages/teleport/src/config.ts b/web/packages/teleport/src/config.ts index ff541c1d52b10..260da1dec2d23 100644 --- a/web/packages/teleport/src/config.ts +++ b/web/packages/teleport/src/config.ts @@ -263,7 +263,7 @@ const cfg = { thumbprintPath: '/v1/webapi/thumbprint', awsConfigureIamScriptOidcIdpPath: - '/webapi/scripts/integrations/configure/awsoidc-idp.sh?integrationName=:integrationName&role=:roleName', + '/webapi/scripts/integrations/configure/awsoidc-idp.sh?integrationName=:integrationName&role=:roleName&s3Bucket=:s3Bucket&s3Prefix=:s3Prefix', awsConfigureIamScriptDeployServicePath: '/webapi/scripts/integrations/configure/deployservice-iam.sh?integrationName=:integrationName&awsRegion=:region&role=:awsOidcRoleArn&taskRole=:taskRoleArn', awsConfigureIamScriptListDatabasesPath: @@ -1075,6 +1075,8 @@ export interface UrlDeployServiceIamConfigureScriptParams { export interface UrlAwsOidcConfigureIdp { integrationName: string; roleName: string; + s3Bucket: string; + s3Prefix: string; } export interface UrlAwsConfigureIamScriptParams { diff --git a/web/packages/teleport/src/services/integrations/integrations.ts b/web/packages/teleport/src/services/integrations/integrations.ts index 41ed78deba2e7..25fa7134a453e 100644 --- a/web/packages/teleport/src/services/integrations/integrations.ts +++ b/web/packages/teleport/src/services/integrations/integrations.ts @@ -230,6 +230,8 @@ function makeIntegration(json: any): Integration { kind: subKind, spec: { roleArn: awsoidc?.roleArn, + issuerS3Bucket: awsoidc?.issuerS3Bucket, + issuerS3Prefix: awsoidc?.issuerS3Prefix, }, // The integration resource does not have a "status" field, but is // a required field for the table that lists both plugin and diff --git a/web/packages/teleport/src/services/integrations/types.ts b/web/packages/teleport/src/services/integrations/types.ts index 9b70129e9f051..91ec7f79fd65e 100644 --- a/web/packages/teleport/src/services/integrations/types.ts +++ b/web/packages/teleport/src/services/integrations/types.ts @@ -53,6 +53,8 @@ export enum IntegrationKind { } export type IntegrationSpecAwsOidc = { roleArn: string; + issuerS3Prefix: string; + issuerS3Bucket: string; }; export enum IntegrationStatusCode { @@ -268,6 +270,8 @@ export type ListAwsRdsDatabaseResponse = { export type IntegrationUpdateRequest = { awsoidc: { roleArn: string; + issuerS3Bucket: string; + issuerS3Prefix: string; }; };