diff --git a/web/packages/teleport/src/Discover/Database/ConnectAwsAccount/ConnectAwsAccount.tsx b/web/packages/teleport/src/Discover/Database/ConnectAwsAccount/ConnectAwsAccount.tsx index 8684ac3a934fa..18b5cfe69b651 100644 --- a/web/packages/teleport/src/Discover/Database/ConnectAwsAccount/ConnectAwsAccount.tsx +++ b/web/packages/teleport/src/Discover/Database/ConnectAwsAccount/ConnectAwsAccount.tsx @@ -27,13 +27,14 @@ import { } from 'design'; import FieldSelect from 'shared/components/FieldSelect'; import useAttempt from 'shared/hooks/useAttemptNext'; -import { Option } from 'shared/components/Select'; +import { Option as BaseOption } from 'shared/components/Select'; import Validation, { Validator } from 'shared/components/Validation'; import { requiredField } from 'shared/components/Validation/rules'; import TextEditor from 'shared/components/TextEditor'; import cfg from 'teleport/config'; import { + Integration, IntegrationKind, integrationService, } from 'teleport/services/integrations'; @@ -48,6 +49,8 @@ import { useDiscover, } from '../../useDiscover'; +type Option = BaseOption; + export function ConnectAwsAccount() { const { storeUser } = useTeleport(); const { @@ -86,7 +89,7 @@ export function ConnectAwsAccount() { const options = res.items.map(i => { if (i.kind === 'aws-oidc') { return { - value: i.name, + value: i, label: i.name, }; } @@ -149,7 +152,7 @@ export function ConnectAwsAccount() { updateAgentMeta({ ...(agentMeta as DbMeta), - integrationName: selectedAwsIntegration.value, + integration: selectedAwsIntegration.value, }); nextStep(); diff --git a/web/packages/teleport/src/Discover/Database/CreateDatabase/useCreateDatabase.test.tsx b/web/packages/teleport/src/Discover/Database/CreateDatabase/useCreateDatabase.test.tsx index 1a9f276536037..0a41f7fbeba44 100644 --- a/web/packages/teleport/src/Discover/Database/CreateDatabase/useCreateDatabase.test.tsx +++ b/web/packages/teleport/src/Discover/Database/CreateDatabase/useCreateDatabase.test.tsx @@ -32,6 +32,10 @@ import { DatabaseEngine, DatabaseLocation, } from 'teleport/Discover/SelectResource'; +import { + IamPolicyStatus, + CreateDatabaseRequest, +} from 'teleport/services/databases'; import { useCreateDatabase, @@ -39,8 +43,6 @@ import { WAITING_TIMEOUT, } from './useCreateDatabase'; -import type { CreateDatabaseRequest } from 'teleport/services/databases'; - const dbLabels = [ { name: 'env', value: 'prod' }, { name: 'os', value: 'mac' }, @@ -312,7 +314,7 @@ describe('registering new databases, mainly error checking', () => { jest.clearAllMocks(); }); - test('with matching service, activates polling', async () => { + test('polling until result returns (non aws)', async () => { const { result } = renderHook(() => useCreateDatabase(), { wrapper, }); @@ -342,6 +344,96 @@ describe('registering new databases, mainly error checking', () => { expect(discoverCtx.nextStep).toHaveBeenCalledWith(2); }); + test('continue polling when poll result returns with iamPolicyStatus field set to "pending"', async () => { + jest.spyOn(teleCtx.databaseService, 'fetchDatabases').mockResolvedValue({ + agents: [ + { + name: 'new-db', + aws: { iamPolicyStatus: IamPolicyStatus.Pending }, + } as any, + ], + }); + const { result } = renderHook(() => useCreateDatabase(), { + wrapper, + }); + + await act(async () => { + result.current.registerDatabase(newDatabaseReq); + }); + expect(teleCtx.databaseService.createDatabase).toHaveBeenCalledTimes(1); + expect(teleCtx.databaseService.fetchDatabaseServices).toHaveBeenCalledTimes( + 1 + ); + + // The first result will not have the aws marker we are looking for. + // Polling should continue. + await act(async () => jest.advanceTimersByTime(3000)); + expect(teleCtx.databaseService.fetchDatabases).toHaveBeenCalledTimes(1); + expect(discoverCtx.updateAgentMeta).not.toHaveBeenCalled(); + + // Set the marker we are looking for in the next api reply. + jest.clearAllMocks(); + jest.spyOn(teleCtx.databaseService, 'fetchDatabases').mockResolvedValue({ + agents: [ + { + name: 'new-db', + aws: { iamPolicyStatus: IamPolicyStatus.Success }, + } as any, + ], + }); + + // The second poll result has the marker that should cancel polling. + await act(async () => jest.advanceTimersByTime(3000)); + expect(teleCtx.databaseService.fetchDatabases).toHaveBeenCalledTimes(1); + expect(discoverCtx.updateAgentMeta).toHaveBeenCalledWith({ + resourceName: 'db-name', + db: { + name: 'new-db', + aws: { iamPolicyStatus: IamPolicyStatus.Success }, + }, + serviceDeployedMethod: 'skipped', + }); + + result.current.nextStep(); + // Skips both deploy service AND IAM policy step. + expect(discoverCtx.nextStep).toHaveBeenCalledWith(3); + }); + + test('stops polling when poll result returns with iamPolicyStatus field set to "unspecified"', async () => { + jest.spyOn(teleCtx.databaseService, 'fetchDatabases').mockResolvedValue({ + agents: [ + { + name: 'new-db', + aws: { iamPolicyStatus: IamPolicyStatus.Unspecified }, + } as any, + ], + }); + const { result } = renderHook(() => useCreateDatabase(), { + wrapper, + }); + + await act(async () => { + result.current.registerDatabase(newDatabaseReq); + }); + expect(teleCtx.databaseService.createDatabase).toHaveBeenCalledTimes(1); + expect(teleCtx.databaseService.fetchDatabaseServices).toHaveBeenCalledTimes( + 1 + ); + + await act(async () => jest.advanceTimersByTime(3000)); + expect(teleCtx.databaseService.fetchDatabases).toHaveBeenCalledTimes(1); + expect(discoverCtx.updateAgentMeta).toHaveBeenCalledWith({ + resourceName: 'db-name', + db: { + name: 'new-db', + aws: { iamPolicyStatus: IamPolicyStatus.Unspecified }, + }, + }); + + result.current.nextStep(); + expect(discoverCtx.nextStep).toHaveBeenCalledWith(2); + }); + test('when there are no services, skips polling', async () => { jest .spyOn(teleCtx.databaseService, 'fetchDatabaseServices') diff --git a/web/packages/teleport/src/Discover/Database/CreateDatabase/useCreateDatabase.ts b/web/packages/teleport/src/Discover/Database/CreateDatabase/useCreateDatabase.ts index 9f48a22ea4cc1..1f7bbd195cfa0 100644 --- a/web/packages/teleport/src/Discover/Database/CreateDatabase/useCreateDatabase.ts +++ b/web/packages/teleport/src/Discover/Database/CreateDatabase/useCreateDatabase.ts @@ -23,6 +23,8 @@ import { useDiscover } from 'teleport/Discover/useDiscover'; import { usePoll } from 'teleport/Discover/Shared/usePoll'; import { compareByString } from 'teleport/lib/util'; import { ApiError } from 'teleport/services/api/parseError'; +import { DatabaseLocation } from 'teleport/Discover/SelectResource'; +import { IamPolicyStatus } from 'teleport/services/databases'; import { matchLabels } from '../common'; @@ -67,6 +69,8 @@ export function useCreateDatabase() { // backend error or network failure) const [createdDb, setCreatedDb] = useState(); + const isAws = resourceSpec.dbMeta.location === DatabaseLocation.Aws; + const dbPollingResult = usePoll( signal => fetchDatabaseServer(signal), pollActive, // does not poll on init, since the value is false. @@ -115,11 +119,17 @@ export function useCreateDatabase() { resourceName: createdDb.name, agentMatcherLabels: dbPollingResult.labels, db: dbPollingResult, + serviceDeployedMethod: + dbPollingResult.aws?.iamPolicyStatus === IamPolicyStatus.Success + ? 'skipped' + : undefined, // User has to deploy a service (can be auto or manual) }); setAttempt({ status: 'success' }); }, [dbPollingResult]); + // fetchDatabaseServer is the callback that is run every interval by the poller. + // The poller will stop polling once a result returns (a dbServer). function fetchDatabaseServer(signal: AbortSignal) { const request = { search: createdDb.name, @@ -129,8 +139,21 @@ export function useCreateDatabase() { .fetchDatabases(clusterId, request, signal) .then(res => { if (res.agents.length) { - return res.agents[0]; + const dbServer = res.agents[0]; + if ( + !isAws || // If not AWS, then we return the first thing we get back. + // If AWS and aws.iamPolicyStatus is undefined or non-pending, + // return the dbServer. + dbServer.aws?.iamPolicyStatus !== IamPolicyStatus.Pending + ) { + return dbServer; + } } + // Returning nothing here will continue the polling. + // Either no result came back back yet or + // a result did come back but we are waiting for a specific + // marker to appear in the result. Specifically for AWS dbs, + // we wait for a non-pending flag to appear. return null; }); } @@ -285,6 +308,21 @@ export function useCreateDatabase() { emitErrorEvent(`${preErrMsg}${message}`); } + function handleNextStep() { + if (dbPollingResult) { + if ( + isAws && + dbPollingResult.aws?.iamPolicyStatus === IamPolicyStatus.Success + ) { + // Skips the deploy db service step AND setting up IAM policy step. + return nextStep(3); + } + // Skips the deploy database service step. + return nextStep(2); + } + nextStep(); // Goes to deploy database service step. + } + const access = ctx.storeUser.getDatabaseAccess(); return { createdDb, @@ -298,9 +336,7 @@ export function useCreateDatabase() { dbLocation: resourceSpec.dbMeta.location, isDbCreateErr, prevStep, - // If there was a result from database polling, then - // allow user to skip the next step. - nextStep: dbPollingResult ? () => nextStep(2) : () => nextStep(), + nextStep: handleNextStep, }; } diff --git a/web/packages/teleport/src/Discover/Database/DeployService/AutoDeploy/AutoDeploy.story.tsx b/web/packages/teleport/src/Discover/Database/DeployService/AutoDeploy/AutoDeploy.story.tsx index 6bb8562147a53..3e2e283876bd1 100644 --- a/web/packages/teleport/src/Discover/Database/DeployService/AutoDeploy/AutoDeploy.story.tsx +++ b/web/packages/teleport/src/Discover/Database/DeployService/AutoDeploy/AutoDeploy.story.tsx @@ -31,6 +31,7 @@ import { DiscoverProvider, DiscoverContextState, } from 'teleport/Discover/useDiscover'; +import { IntegrationStatusCode } from 'teleport/services/integrations'; import { AutoDeploy } from './AutoDeploy'; @@ -69,6 +70,15 @@ const Provider = props => { agentMatcherLabels: [], db: {} as any, selectedAwsRdsDb: { region: 'us-east-1' } as any, + integration: { + kind: 'aws-oidc', + name: 'integration/aws-oidc', + resourceType: 'integration', + spec: { + roleArn: 'arn-123', + }, + statusCode: IntegrationStatusCode.Running, + }, ...props.agentMeta, }, currentStep: 0, 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 new file mode 100644 index 0000000000000..09ddeddb1783f --- /dev/null +++ b/web/packages/teleport/src/Discover/Database/DeployService/AutoDeploy/AutoDeploy.test.tsx @@ -0,0 +1,222 @@ +/** + * 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 from 'react'; +import { MemoryRouter } from 'react-router'; +import { render, screen, fireEvent, act } from 'design/utils/testing'; + +import { ContextProvider } from 'teleport'; +import { + AwsRdsDatabase, + Integration, + IntegrationKind, + IntegrationStatusCode, + Regions, + integrationService, +} from 'teleport/services/integrations'; +import { createTeleportContext } from 'teleport/mocks/contexts'; +import cfg from 'teleport/config'; +import TeleportContext from 'teleport/teleportContext'; +import { + DbMeta, + DiscoverContextState, + DiscoverProvider, +} from 'teleport/Discover/useDiscover'; +import { + DatabaseEngine, + DatabaseLocation, +} from 'teleport/Discover/SelectResource'; +import { FeaturesContextProvider } from 'teleport/FeaturesContext'; +import { PingTeleportProvider } from 'teleport/Discover/Shared/PingTeleportContext'; +import { ResourceKind } from 'teleport/Discover/Shared'; +import { SHOW_HINT_TIMEOUT } from 'teleport/Discover/Shared/useShowHint'; + +import { AutoDeploy } from './AutoDeploy'; + +const mockDbLabels = [{ name: 'env', value: 'prod' }]; + +const integrationName = 'aws-oidc-integration'; +const region: Regions = 'us-east-2'; +const awsoidcRoleArn = 'role-arn'; + +const mockAwsRdsDb: AwsRdsDatabase = { + engine: 'postgres', + name: 'rds-1', + uri: 'endpoint-1', + status: 'available', + labels: mockDbLabels, + accountId: 'account-id-1', + resourceId: 'resource-id-1', + region: region, + subnets: ['subnet1', 'subnet2'], +}; + +const mocKIntegration: Integration = { + kind: IntegrationKind.AwsOidc, + name: integrationName, + resourceType: 'integration', + spec: { + roleArn: `doncare/${awsoidcRoleArn}`, + }, + statusCode: IntegrationStatusCode.Running, +}; + +describe('test AutoDeploy.tsx', () => { + jest.useFakeTimers(); + + const teleCtx = createTeleportContext(); + const discoverCtx: DiscoverContextState = { + agentMeta: { + resourceName: 'db1', + integration: mocKIntegration, + selectedAwsRdsDb: mockAwsRdsDb, + agentMatcherLabels: mockDbLabels, + } as DbMeta, + currentStep: 0, + nextStep: jest.fn(x => x), + prevStep: () => null, + onSelectResource: () => null, + resourceSpec: { + dbMeta: { + location: DatabaseLocation.Aws, + engine: DatabaseEngine.AuroraMysql, + }, + } as any, + viewConfig: null, + exitFlow: null, + indexedViews: [], + setResourceSpec: () => null, + updateAgentMeta: jest.fn(x => x), + emitErrorEvent: () => null, + emitEvent: () => null, + eventState: null, + }; + + beforeEach(() => { + jest.spyOn(integrationService, 'deployAwsOidcService').mockResolvedValue({ + clusterArn: 'cluster-arn', + serviceArn: 'service-arn', + taskDefinitionArn: 'task-definition', + serviceDashboardUrl: 'dashboard-url', + }); + + jest.spyOn(teleCtx.databaseService, 'fetchDatabases').mockResolvedValue({ + agents: [], + }); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + test('init: labels are rendered, command is not rendered yet', () => { + renderAutoDeploy(teleCtx, discoverCtx); + + expect(screen.getByText(/env: prod/i)).toBeInTheDocument(); + expect(screen.queryByText(/copy\/paste/i)).not.toBeInTheDocument(); + expect(screen.queryByText(/curl/i)).not.toBeInTheDocument(); + }); + + test('clicking button renders command', () => { + renderAutoDeploy(teleCtx, discoverCtx); + + fireEvent.click(screen.getByText(/generate command/i)); + + expect(screen.getByText(/copy\/paste/i)).toBeInTheDocument(); + expect( + screen.getByText( + /integrationName=aws-oidc-integration&awsRegion=us-east-2&role=role-arn&taskRole=TeleportDatabaseAccess/i + ) + ).toBeInTheDocument(); + }); + + test('invalid role name', () => { + renderAutoDeploy(teleCtx, discoverCtx); + + expect( + screen.queryByText(/name can only contain/i) + ).not.toBeInTheDocument(); + + // add invalid characters in role name + const inputEl = screen.getByPlaceholderText(/TeleportDatabaseAccess/i); + fireEvent.change(inputEl, { target: { value: 'invalidname!@#!$!%' } }); + + fireEvent.click(screen.getByText(/generate command/i)); + expect(screen.getByText(/name can only contain/i)).toBeInTheDocument(); + + // change back to valid name + fireEvent.change(inputEl, { target: { value: 'llama' } }); + expect( + screen.queryByText(/name can only contain/i) + ).not.toBeInTheDocument(); + }); + + test('deploy hint states', async () => { + renderAutoDeploy(teleCtx, discoverCtx); + + fireEvent.click(screen.getByText(/Deploy Teleport Service/i)); + + // test initial loading state + await screen.findByText( + /Teleport is currently deploying a Database Service/i + ); + + // test waiting state + act(() => jest.advanceTimersByTime(SHOW_HINT_TIMEOUT + 1)); + + expect( + screen.getByText( + /We're still in the process of creating your Database Service/i + ) + ).toBeInTheDocument(); + + // test success state + jest.spyOn(teleCtx.databaseService, 'fetchDatabases').mockResolvedValue({ + agents: [{} as any], // the result doesn't matter, just need size one array. + }); + + act(() => jest.advanceTimersByTime(TEST_PING_INTERVAL + 1)); + await screen.findByText(/Successfully created/i); + }); +}); + +const TEST_PING_INTERVAL = 1000 * 60 * 5; // 5 minutes + +function renderAutoDeploy( + ctx: TeleportContext, + discoverCtx: DiscoverContextState +) { + return render( + + + + + + + + + + + + ); +} diff --git a/web/packages/teleport/src/Discover/Database/DeployService/AutoDeploy/AutoDeploy.tsx b/web/packages/teleport/src/Discover/Database/DeployService/AutoDeploy/AutoDeploy.tsx index eefd23c6efd06..821f48e1a415d 100644 --- a/web/packages/teleport/src/Discover/Database/DeployService/AutoDeploy/AutoDeploy.tsx +++ b/web/packages/teleport/src/Discover/Database/DeployService/AutoDeploy/AutoDeploy.tsx @@ -20,7 +20,6 @@ import { Box, ButtonSecondary, Link, Text } from 'design'; import * as Icons from 'design/Icon'; import FieldInput from 'shared/components/FieldInput'; import Validation, { Validator } from 'shared/components/Validation'; -import { requiredField } from 'shared/components/Validation/rules'; import useAttempt from 'shared/hooks/useAttemptNext'; import { TextSelectCopyMulti } from 'teleport/components/TextSelectCopy'; @@ -35,7 +34,12 @@ import { integrationService, } from 'teleport/services/integrations'; import { useDiscover, DbMeta } from 'teleport/Discover/useDiscover'; -import { DiscoverEventStatus } from 'teleport/services/userEvent'; +import { + DiscoverEventStatus, + DiscoverServiceDeployMethod, + DiscoverServiceDeployType, +} from 'teleport/services/userEvent'; +import cfg from 'teleport/config'; import { ActionButtons, @@ -69,7 +73,7 @@ export function AutoDeploy({ toggleDeployMethod }: DeployServiceProp) { const [labels, setLabels] = useState([ { name: '*', value: '*', isFixed: dbLabels.length === 0 }, ]); - const agent = agentMeta as DbMeta; + const dbMeta = agentMeta as DbMeta; useEffect(() => { // Turn off error once user changes labels. @@ -78,7 +82,7 @@ export function AutoDeploy({ toggleDeployMethod }: DeployServiceProp) { } }, [labels]); - function handleDeploy(validator: Validator) { + function handleDeploy(validator) { if (!validator.validate()) { return; } @@ -91,10 +95,10 @@ export function AutoDeploy({ toggleDeployMethod }: DeployServiceProp) { setShowLabelMatchErr(false); setAttempt({ status: 'processing' }); integrationService - .deployAwsOidcService(agent.integrationName, { + .deployAwsOidcService(dbMeta.integration?.name, { deploymentMode: 'database-service', - region: agent.selectedAwsRdsDb?.region, - subnetIds: agent.selectedAwsRdsDb?.subnets, + region: dbMeta.selectedAwsRdsDb?.region, + subnetIds: dbMeta.selectedAwsRdsDb?.subnets, taskRoleArn, databaseAgentMatcherLabels: labels, }) @@ -114,9 +118,13 @@ export function AutoDeploy({ toggleDeployMethod }: DeployServiceProp) { function handleOnProceed() { nextStep(2); // skip the IAM policy view emitEvent( - { stepStatus: DiscoverEventStatus.Success } - // TODO(lisa) uncomment after backend handles this field - // { deployMethod: 'auto' } + { stepStatus: DiscoverEventStatus.Success }, + { + serviceDeploy: { + method: DiscoverServiceDeployMethod.Auto, + type: DiscoverServiceDeployType.AmazonEcs, + }, + } ); } @@ -148,7 +156,7 @@ export function AutoDeploy({ toggleDeployMethod }: DeployServiceProp) { {/* step one */} @@ -156,6 +164,8 @@ export function AutoDeploy({ toggleDeployMethod }: DeployServiceProp) { taskRoleArn={taskRoleArn} setTaskRoleArn={setTaskRoleArn} disabled={isProcessing} + dbMeta={dbMeta} + validator={validator} /> {/* step two */} @@ -169,7 +179,7 @@ export function AutoDeploy({ toggleDeployMethod }: DeployServiceProp) { showLabelMatchErr={showLabelMatchErr} dbLabels={dbLabels} autoFocus={false} - region={agent.selectedAwsRdsDb?.region} + region={dbMeta.selectedAwsRdsDb?.region} /> Encountered Error: {attempt.statusText} + + Note: If this is your first attempt, it might be that + AWS has not finished propagating changes from{' '} + Step 1. Try waiting a minute before attempting + again. + )} @@ -227,10 +243,10 @@ const Heading = ({ Teleport needs a database service to be able to connect to your database. Teleport can configure the permissions required to spin up an - ECS Fargate container (0.xxx vCPU, 1GB memory) in your Amazon account - with the ability to access databases in this region ( - {region}). You will only need to do this once for all - databases per geographical region.
+ ECS Fargate container (2vCPU, 4GB memory) in your Amazon account with + the ability to access databases in this region ({region}). + You will only need to do this once per geographical region. +

Want to deploy a database service manually from one of your existing servers?{' '} @@ -247,49 +263,84 @@ const CreateAccessRole = ({ taskRoleArn, setTaskRoleArn, disabled, + dbMeta, + validator, }: { taskRoleArn: string; setTaskRoleArn(r: string): void; disabled: boolean; + dbMeta: DbMeta; + validator: Validator; }) => { + const [scriptUrl, setScriptUrl] = useState(''); + const { integration, selectedAwsRdsDb } = dbMeta; + + function generateAutoConfigScript() { + if (!validator.validate()) { + return; + } + + const newScriptUrl = cfg.getDeployServiceIamConfigureScriptUrl({ + integrationName: integration.name, + region: selectedAwsRdsDb.region, + // arn's are formatted as `don-care-about-this-part/role-arn`. + // We are splitting by slash and getting the last element. + awsOidcRoleArn: integration.spec.roleArn.split('/').pop(), + taskRoleArn, + }); + + setScriptUrl(newScriptUrl); + } + return ( Step 1 - Create an Access Role for the Database Service + + Name a Task Role ARN for this Database Service and generate a configure + command. This command will configure the required permissions in your + AWS account. + setTaskRoleArn(e.target.value)} - toolTipContent="Lorem ipsume dolores" + toolTipContent={`Amazon Resource Names (ARNs) uniquely identify AWS \ + resources. In this case you will naming an IAM role that this \ + deployed service will be using`} /> - - Then open{' '} - - Amazon CloudShell - {' '} - and copy/paste the following command to create an access role for your - database service: - - - - + + {scriptUrl ? 'Regenerate Command' : 'Generate Command'} + + {scriptUrl && ( + <> + + Open{' '} + + Amazon CloudShell + {' '} + and copy/paste the following command: + + + + + + )} ); }; @@ -374,3 +425,21 @@ const StyledBox = styled(Box)` padding: ${props => `${props.theme.space[3]}px`}; border-radius: ${props => `${props.theme.space[2]}px`}; `; + +// ROLE_ARN_REGEX uses the same regex matcher used in the backend: +// https://github.com/gravitational/teleport/blob/2cba82cb332e769ebc8a658d32ff24ddda79daff/api/utils/aws/identifiers.go#L43 +// +// Regex checks for alphanumerics and select few characters. +export const ROLE_ARN_REGEX = /^[\w+=,.@-]+$/; +const roleArnMatcher = value => () => { + const isValid = value.match(ROLE_ARN_REGEX); + if (!isValid) { + return { + valid: false, + message: 'name can only contain characters @ = , . + - and alphanumerics', + }; + } + return { + valid: true, + }; +}; diff --git a/web/packages/teleport/src/Discover/Database/DeployService/ManualDeploy/ManualDeploy.tsx b/web/packages/teleport/src/Discover/Database/DeployService/ManualDeploy/ManualDeploy.tsx index 7ba40608ba900..2f4d7c21aa47e 100644 --- a/web/packages/teleport/src/Discover/Database/DeployService/ManualDeploy/ManualDeploy.tsx +++ b/web/packages/teleport/src/Discover/Database/DeployService/ManualDeploy/ManualDeploy.tsx @@ -40,7 +40,11 @@ import { import { CommandBox } from 'teleport/Discover/Shared/CommandBox'; import { DbMeta, useDiscover } from 'teleport/Discover/useDiscover'; import { DatabaseLocation } from 'teleport/Discover/SelectResource'; -import { DiscoverEventStatus } from 'teleport/services/userEvent'; +import { + DiscoverEventStatus, + DiscoverServiceDeployMethod, + DiscoverServiceDeployType, +} from 'teleport/services/userEvent'; import { ActionButtons, @@ -153,9 +157,13 @@ export function ManualDeploy(props: { nextStep(); emitEvent( - { stepStatus: DiscoverEventStatus.Success } - // TODO(lisa) uncomment after backend handles this field - // { deployMethod: 'manual' } + { stepStatus: DiscoverEventStatus.Success }, + { + serviceDeploy: { + method: DiscoverServiceDeployMethod.Manual, + type: DiscoverServiceDeployType.InstallScript, + }, + } ); } diff --git a/web/packages/teleport/src/Discover/Database/EnrollRdsDatabase/EnrollRdsDatabase.test.tsx b/web/packages/teleport/src/Discover/Database/EnrollRdsDatabase/EnrollRdsDatabase.test.tsx index fc4667e7f91f6..28859fb6d37b3 100644 --- a/web/packages/teleport/src/Discover/Database/EnrollRdsDatabase/EnrollRdsDatabase.test.tsx +++ b/web/packages/teleport/src/Discover/Database/EnrollRdsDatabase/EnrollRdsDatabase.test.tsx @@ -21,6 +21,7 @@ import { render, screen, fireEvent } from 'design/utils/testing'; import { ContextProvider } from 'teleport'; import { AwsRdsDatabase, + IntegrationStatusCode, integrationService, } from 'teleport/services/integrations'; import { createTeleportContext } from 'teleport/mocks/contexts'; @@ -41,7 +42,17 @@ import { EnrollRdsDatabase } from './EnrollRdsDatabase'; describe('test EnrollRdsDatabase.tsx', () => { const ctx = createTeleportContext(); const discoverCtx: DiscoverContextState = { - agentMeta: {} as any, + agentMeta: { + integration: { + kind: 'aws-oidc', + name: 'aws-oidc-integration', + resourceType: 'integration', + spec: { + roleArn: 'arn-123', + }, + statusCode: IntegrationStatusCode.Running, + }, + } as any, currentStep: 0, nextStep: jest.fn(x => x), prevStep: () => null, diff --git a/web/packages/teleport/src/Discover/Database/EnrollRdsDatabase/EnrollRdsDatabase.tsx b/web/packages/teleport/src/Discover/Database/EnrollRdsDatabase/EnrollRdsDatabase.tsx index e74cb4861dca3..c713059055649 100644 --- a/web/packages/teleport/src/Discover/Database/EnrollRdsDatabase/EnrollRdsDatabase.tsx +++ b/web/packages/teleport/src/Discover/Database/EnrollRdsDatabase/EnrollRdsDatabase.tsx @@ -98,7 +98,7 @@ export function EnrollRdsDatabase() { } async function fetchDatabases(data: TableData) { - const integrationName = (agentMeta as DbMeta).integrationName; + const integrationName = (agentMeta as DbMeta).integration.name; setTableData({ ...data, fetchStatus: 'loading' }); setFetchDbAttempt({ status: 'processing' }); diff --git a/web/packages/teleport/src/Discover/Database/IamPolicy/IamPolicy.tsx b/web/packages/teleport/src/Discover/Database/IamPolicy/IamPolicy.tsx index 1996b4daa9202..9143845274664 100644 --- a/web/packages/teleport/src/Discover/Database/IamPolicy/IamPolicy.tsx +++ b/web/packages/teleport/src/Discover/Database/IamPolicy/IamPolicy.tsx @@ -52,6 +52,8 @@ export function IamPolicyView({ Teleport needs AWS IAM permissions to be able to discover and register RDS instances and configure IAM authentications. +
+ Optional if you already have an IAM policy configured.
{attempt.status === 'failed' ? ( <> @@ -115,10 +117,13 @@ export function IamPolicyView({ )} )} - + + + nextStep(0)} /> + ); } diff --git a/web/packages/teleport/src/Discover/Database/SetupAccess/SetupAccess.story.tsx b/web/packages/teleport/src/Discover/Database/SetupAccess/SetupAccess.story.tsx index 16e54dd4e87e1..b2cf2a8ba7912 100644 --- a/web/packages/teleport/src/Discover/Database/SetupAccess/SetupAccess.story.tsx +++ b/web/packages/teleport/src/Discover/Database/SetupAccess/SetupAccess.story.tsx @@ -88,6 +88,7 @@ const props: State = { status: 'success', statusText: '', }, + agentMeta: {} as any, onProceed: () => null, onPrev: () => null, fetchUserTraits: () => null, diff --git a/web/packages/teleport/src/Discover/Database/SetupAccess/SetupAccess.tsx b/web/packages/teleport/src/Discover/Database/SetupAccess/SetupAccess.tsx index 31ee345a103bb..87925f72fe702 100644 --- a/web/packages/teleport/src/Discover/Database/SetupAccess/SetupAccess.tsx +++ b/web/packages/teleport/src/Discover/Database/SetupAccess/SetupAccess.tsx @@ -29,6 +29,7 @@ import { } from 'teleport/Discover/Shared/SetupAccess'; import { Mark } from 'teleport/Discover/Shared'; import { TextSelectCopyMulti } from 'teleport/components/TextSelectCopy'; +import { DbMeta } from 'teleport/Discover/useDiscover'; import { DatabaseEngine, DatabaseLocation } from '../../SelectResource'; @@ -47,6 +48,8 @@ export function SetupAccess(props: State) { getFixedOptions, getSelectableOptions, resourceSpec, + onPrev, + agentMeta, ...restOfProps } = props; const [nameInputValue, setNameInputValue] = useState(''); @@ -105,6 +108,8 @@ export function SetupAccess(props: State) { const headerSubtitle = 'Allow access from your Database names and users to interact with your Database.'; + const dbMeta = agentMeta as DbMeta; + return ( } + // Don't allow going back to previous screen when deploy db + // service got skipped or user auto deployed the db service. + onPrev={dbMeta.serviceDeployedMethod === 'manual' ? onPrev : null} > Database Users diff --git a/web/packages/teleport/src/Discover/Database/common.tsx b/web/packages/teleport/src/Discover/Database/common.tsx index b21d5303d022a..1ddd678d9eac6 100644 --- a/web/packages/teleport/src/Discover/Database/common.tsx +++ b/web/packages/teleport/src/Discover/Database/common.tsx @@ -23,9 +23,10 @@ import { LabelsCreater, Mark, TextIcon } from 'teleport/Discover/Shared'; import { Regions } from 'teleport/services/integrations'; // serviceDeployedMethod is a flag to determine if user opted to -// deploy database service automagically (teleport deploys for user) -// or manually (user has their own server). -export type ServiceDeployMethod = 'auto' | 'manual'; +// deploy database service automagically (teleport deploys for user), +// manually (user has their own server), or deploying service was +// skipped due to an existing one. +export type ServiceDeployMethod = 'auto' | 'manual' | 'skipped'; export const Labels = ({ labels, diff --git a/web/packages/teleport/src/Discover/Database/index.tsx b/web/packages/teleport/src/Discover/Database/index.tsx index 85fa4a741c9a3..e9fd2655c7e64 100644 --- a/web/packages/teleport/src/Discover/Database/index.tsx +++ b/web/packages/teleport/src/Discover/Database/index.tsx @@ -26,6 +26,7 @@ import { import { CreateDatabase } from 'teleport/Discover/Database/CreateDatabase'; import { SetupAccess } from 'teleport/Discover/Database/SetupAccess'; +import { DeployService } from 'teleport/Discover/Database/DeployService'; import { ManualDeploy } from 'teleport/Discover/Database/DeployService/ManualDeploy'; import { MutualTls } from 'teleport/Discover/Database/MutualTls'; import { TestConnection } from 'teleport/Discover/Database/TestConnection'; @@ -68,8 +69,9 @@ export const DatabaseResource: ResourceViewConfig = { }, { title: 'Deploy Database Service', - component: ManualDeploy, + component: DeployService, eventName: DiscoverEvent.DeployService, + manuallyEmitSuccessEvent: true, }, { title: 'Configure IAM Policy', diff --git a/web/packages/teleport/src/Discover/Kubernetes/SetupAccess/SetupAccess.story.tsx b/web/packages/teleport/src/Discover/Kubernetes/SetupAccess/SetupAccess.story.tsx index 8eb8b0446a77a..3511e24979814 100644 --- a/web/packages/teleport/src/Discover/Kubernetes/SetupAccess/SetupAccess.story.tsx +++ b/web/packages/teleport/src/Discover/Kubernetes/SetupAccess/SetupAccess.story.tsx @@ -56,6 +56,7 @@ const props: State = { status: 'success', statusText: '', }, + agentMeta: {} as any, onProceed: () => null, onPrev: () => null, fetchUserTraits: () => null, diff --git a/web/packages/teleport/src/Discover/Server/SetupAccess/SetupAccess.story.tsx b/web/packages/teleport/src/Discover/Server/SetupAccess/SetupAccess.story.tsx index 2c5424daedbaa..68bb0ae3033c5 100644 --- a/web/packages/teleport/src/Discover/Server/SetupAccess/SetupAccess.story.tsx +++ b/web/packages/teleport/src/Discover/Server/SetupAccess/SetupAccess.story.tsx @@ -56,6 +56,7 @@ const props: State = { status: 'success', statusText: '', }, + agentMeta: {} as any, onProceed: () => null, onPrev: () => null, fetchUserTraits: () => null, diff --git a/web/packages/teleport/src/Discover/Shared/ActionButtons.tsx b/web/packages/teleport/src/Discover/Shared/ActionButtons.tsx index a2c581dc09072..ed10018434009 100644 --- a/web/packages/teleport/src/Discover/Shared/ActionButtons.tsx +++ b/web/packages/teleport/src/Discover/Shared/ActionButtons.tsx @@ -84,6 +84,7 @@ export const AlternateInstructionButton: React.FC<{ onClick={onClick} css={` padding-left: 1px; + padding-right: 1px; color: ${p => p.theme.colors.buttons.link.default}; text-decoration: underline; font-weight: normal; diff --git a/web/packages/teleport/src/Discover/Shared/SetupAccess/useUserTraits.ts b/web/packages/teleport/src/Discover/Shared/SetupAccess/useUserTraits.ts index 145142df3265a..523f606a8936d 100644 --- a/web/packages/teleport/src/Discover/Shared/SetupAccess/useUserTraits.ts +++ b/web/packages/teleport/src/Discover/Shared/SetupAccess/useUserTraits.ts @@ -298,6 +298,7 @@ export function useUserTraits(props: AgentStepProps) { dynamicTraits, staticTraits, resourceSpec: props.resourceSpec, + agentMeta: props.agentMeta, }; } diff --git a/web/packages/teleport/src/Discover/Shared/useShowHint.ts b/web/packages/teleport/src/Discover/Shared/useShowHint.ts index d753e6f3b18dd..fb61a0ad9c612 100644 --- a/web/packages/teleport/src/Discover/Shared/useShowHint.ts +++ b/web/packages/teleport/src/Discover/Shared/useShowHint.ts @@ -16,7 +16,7 @@ import { useEffect, useState } from 'react'; -const SHOW_HINT_TIMEOUT = 1000 * 60 * 5; // 5 minutes +export const SHOW_HINT_TIMEOUT = 1000 * 60 * 5; // 5 minutes export function useShowHint(enabled: boolean) { const [showHint, setShowHint] = useState(false); diff --git a/web/packages/teleport/src/Discover/useDiscover.tsx b/web/packages/teleport/src/Discover/useDiscover.tsx index 26b15088e6c25..bebdd64ba0265 100644 --- a/web/packages/teleport/src/Discover/useDiscover.tsx +++ b/web/packages/teleport/src/Discover/useDiscover.tsx @@ -44,7 +44,10 @@ import type { Kube } from 'teleport/services/kube'; import type { Database } from 'teleport/services/databases'; import type { AgentLabel } from 'teleport/services/agents'; import type { ResourceSpec } from './SelectResource'; -import type { AwsRdsDatabase } from 'teleport/services/integrations'; +import type { + AwsRdsDatabase, + Integration, +} from 'teleport/services/integrations'; export interface DiscoverContextState { agentMeta: AgentMeta; @@ -97,9 +100,8 @@ export type DiscoverUrlLocationState = { resourceSpec: ResourceSpec; currentStep: number; }; - // integrationName is the name of the created integration - // resource name (eg: integration subkind "aws-oidc") - integrationName: string; + // integration is the created aws-oidc integration + integration: Integration; }; const discoverContext = React.createContext(null); @@ -226,9 +228,9 @@ export function DiscoverProvider({ // The location.state.discover should contain all the state that allows // the user to resume from where they left of. function resumeDiscoverFlow() { - const { discover, integrationName } = location.state; + const { discover, integration } = location.state; - updateAgentMeta({ integrationName } as DbMeta); + updateAgentMeta({ integration } as DbMeta); startDiscoverFlow( discover.resourceSpec, @@ -470,9 +472,9 @@ export type NodeMeta = BaseMeta & { export type DbMeta = BaseMeta & { // TODO(lisa): when we can enroll multiple RDS's, turn this into an array? // The enroll event expects num count of enrolled RDS's, update accordingly. - db: Database; - integrationName?: string; - selectedAwsRdsDb: AwsRdsDatabase; + db?: Database; + integration?: Integration; + selectedAwsRdsDb?: AwsRdsDatabase; // serviceDeployedMethod flag will be undefined if user skipped // deploying service (service already existed). serviceDeployedMethod?: ServiceDeployMethod; diff --git a/web/packages/teleport/src/Integrations/Enroll/AwsOidc/instructions/Instructions.story.tsx b/web/packages/teleport/src/Integrations/Enroll/AwsOidc/instructions/Instructions.story.tsx index e9f5d49b78edb..0f5e06072041b 100644 --- a/web/packages/teleport/src/Integrations/Enroll/AwsOidc/instructions/Instructions.story.tsx +++ b/web/packages/teleport/src/Integrations/Enroll/AwsOidc/instructions/Instructions.story.tsx @@ -17,6 +17,12 @@ import React from 'react'; import { MemoryRouter } from 'react-router'; +import { + Integration, + IntegrationKind, + IntegrationStatusCode, +} from 'teleport/services/integrations'; + import { FirstStageInstructions } from './FirstStageInstructions'; import { SecondStageInstructions } from './SecondStageInstructions'; import { ThirdStageInstructions } from './ThirdStageInstructions'; @@ -50,7 +56,7 @@ export const Step7 = () => ( export const ConfirmDialog = () => ( null} /> @@ -61,7 +67,7 @@ export const ConfirmDialogFromDiscover = () => ( initialEntries={[{ state: { discover: {} } as DiscoverUrlLocationState }]} > null} /> @@ -77,3 +83,13 @@ const props: CommonInstructionsProps = { }, clusterPublicUri: 'gravitationalwashington.cloud.gravitional.io:4444', }; + +const mockIntegration: Integration = { + kind: IntegrationKind.AwsOidc, + name: 'aws-oidc-integration', + resourceType: 'integration', + spec: { + roleArn: 'arn-123', + }, + statusCode: IntegrationStatusCode.Running, +}; diff --git a/web/packages/teleport/src/Integrations/Enroll/AwsOidc/instructions/SeventhStageInstructions.tsx b/web/packages/teleport/src/Integrations/Enroll/AwsOidc/instructions/SeventhStageInstructions.tsx index 1528219178089..5b4dd82f72d1d 100644 --- a/web/packages/teleport/src/Integrations/Enroll/AwsOidc/instructions/SeventhStageInstructions.tsx +++ b/web/packages/teleport/src/Integrations/Enroll/AwsOidc/instructions/SeventhStageInstructions.tsx @@ -36,6 +36,7 @@ import { } from 'shared/components/Validation/rules'; import { + Integration, IntegrationKind, integrationService, } from 'teleport/services/integrations'; @@ -51,7 +52,7 @@ export function SeventhStageInstructions( props: PreviousStepProps & { emitEvent: EmitEvent } ) { const { attempt, setAttempt } = useAttempt(''); - const [showConfirmBox, setShowConfirmBox] = useState(false); + const [createdIntegration, setCreatedIntegration] = useState(); const [roleArn, setRoleArn] = useState(props.awsOidc.roleArn); const [name, setName] = useState(props.awsOidc.integrationName); @@ -67,7 +68,7 @@ export function SeventhStageInstructions( subKind: IntegrationKind.AwsOidc, awsoidc: { roleArn }, }) - .then(() => setShowConfirmBox(true)) + .then(setCreatedIntegration) .catch((err: Error) => setAttempt({ status: 'failed', statusText: err.message }) ); @@ -134,9 +135,9 @@ export function SeventhStageInstructions( )} - {showConfirmBox && ( + {createdIntegration && ( )} @@ -145,10 +146,10 @@ export function SeventhStageInstructions( } export function SuccessfullyAddedIntegrationDialog({ - integrationName, + integration, emitEvent, }: { - integrationName: string; + integration: Integration; emitEvent: EmitEvent; }) { const location = useLocation(); @@ -174,7 +175,7 @@ export function SuccessfullyAddedIntegrationDialog({ - AWS integration "{integrationName}" successfully added + AWS integration "{integration.name}" successfully added @@ -185,7 +186,7 @@ export function SuccessfullyAddedIntegrationDialog({ to={{ pathname: cfg.routes.discover, state: { - integrationName, + integration, discover: location.state.discover, }, }} diff --git a/web/packages/teleport/src/config.test.ts b/web/packages/teleport/src/config.test.ts new file mode 100644 index 0000000000000..9f6e296bbfe60 --- /dev/null +++ b/web/packages/teleport/src/config.test.ts @@ -0,0 +1,32 @@ +/** + * 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 cfg, { UrlDeployServiceIamConfigureScriptParams } from './config'; + +test('getDeployServiceIamConfigureScriptPath formatting', async () => { + const params: UrlDeployServiceIamConfigureScriptParams = { + integrationName: 'int-name', + region: 'us-east-1', + awsOidcRoleArn: 'oidc-arn', + taskRoleArn: 'task-arn', + }; + const base = + 'http://localhost/webapi/scripts/integrations/configure/deployservice-iam.sh?'; + const expected = `integrationName=${'int-name'}&awsRegion=${'us-east-1'}&role=${'oidc-arn'}&taskRole=${'task-arn'}`; + expect(cfg.getDeployServiceIamConfigureScriptUrl(params)).toBe( + `${base}${expected}` + ); +}); diff --git a/web/packages/teleport/src/config.ts b/web/packages/teleport/src/config.ts index 1196b7c207199..0adf91b5b730c 100644 --- a/web/packages/teleport/src/config.ts +++ b/web/packages/teleport/src/config.ts @@ -31,7 +31,7 @@ import type { import type { SortType } from 'teleport/services/agents'; import type { RecordingType } from 'teleport/services/recordings'; import type { WebauthnAssertionResponse } from './services/auth'; - +import type { Regions } from './services/integrations'; import type { ParticipantMode } from 'teleport/services/session'; const cfg = { @@ -187,6 +187,9 @@ const cfg = { nodeScriptPath: '/scripts/:token/install-node.sh', appNodeScriptPath: '/scripts/:token/install-app.sh?name=:name&uri=:uri', + deployServiceIamConfigureScriptPath: + '/webapi/scripts/integrations/configure/deployservice-iam.sh?integrationName=:integrationName&awsRegion=:region&role=:awsOidcRoleArn&taskRole=:taskRoleArn', + mfaRequired: '/v1/webapi/sites/:clusterId/mfa/required', mfaLoginBegin: '/v1/webapi/mfa/login/begin', // creates authnenticate challenge with user and password mfaLoginFinish: '/v1/webapi/mfa/login/finishsession', // creates a web session @@ -347,6 +350,15 @@ const cfg = { return cfg.baseUrl + generatePath(cfg.api.nodeScriptPath, { token }); }, + getDeployServiceIamConfigureScriptUrl( + p: UrlDeployServiceIamConfigureScriptParams + ) { + return ( + cfg.baseUrl + + generatePath(cfg.api.deployServiceIamConfigureScriptPath, { ...p }) + ); + }, + getDbScriptUrl(token: string) { return cfg.baseUrl + generatePath(cfg.api.dbScriptPath, { token }); }, @@ -842,4 +854,11 @@ export interface UrlIntegrationExecuteRequestParams { action: 'aws-oidc/list_databases'; } +export interface UrlDeployServiceIamConfigureScriptParams { + integrationName: string; + region: Regions; + awsOidcRoleArn: string; + taskRoleArn: string; +} + export default cfg; diff --git a/web/packages/teleport/src/services/databases/databases.test.ts b/web/packages/teleport/src/services/databases/databases.test.ts index b6182ff77584a..410630d155b7f 100644 --- a/web/packages/teleport/src/services/databases/databases.test.ts +++ b/web/packages/teleport/src/services/databases/databases.test.ts @@ -17,7 +17,7 @@ limitations under the License. import api from 'teleport/services/api'; import DatabaseService from './databases'; -import { Database } from './types'; +import { Database, IamPolicyStatus } from './types'; test('correct formatting of database fetch response', async () => { jest.spyOn(api, 'get').mockResolvedValue(mockResponse); @@ -46,6 +46,7 @@ test('correct formatting of database fetch response', async () => { region: 'us-west-1', subnets: ['sn1', 'sn2'], }, + iamPolicyStatus: IamPolicyStatus.Success, }, }, { @@ -182,6 +183,7 @@ const mockResponse = { region: 'us-west-1', subnets: ['sn1', 'sn2'], }, + iam_policy_status: 'IAM_POLICY_STATUS_SUCCESS', }, }, // non-aws self-hosted diff --git a/web/packages/teleport/src/services/databases/makeDatabase.ts b/web/packages/teleport/src/services/databases/makeDatabase.ts index 3c8cb4c33080f..2d35eae6c6ccd 100644 --- a/web/packages/teleport/src/services/databases/makeDatabase.ts +++ b/web/packages/teleport/src/services/databases/makeDatabase.ts @@ -34,6 +34,7 @@ export function makeDatabase(json: any): Database { region: aws.rds?.region, subnets: aws.rds?.subnets || [], }, + iamPolicyStatus: aws.iam_policy_status, }; } diff --git a/web/packages/teleport/src/services/databases/types.ts b/web/packages/teleport/src/services/databases/types.ts index 68d04a8099b11..17e99296f26a5 100644 --- a/web/packages/teleport/src/services/databases/types.ts +++ b/web/packages/teleport/src/services/databases/types.ts @@ -20,8 +20,18 @@ import { AgentLabel } from 'teleport/services/agents'; import { AwsRdsDatabase, RdsEngine } from '../integrations'; +export enum IamPolicyStatus { + // Unspecified flag is most likely a result + // from an older service that do not set this state + Unspecified = 'IAM_POLICY_STATUS_UNSPECIFIED', + Pending = 'IAM_POLICY_STATUS_PENDING', + Failed = 'IAM_POLICY_STATUS_FAILED', + Success = 'IAM_POLICY_STATUS_SUCCESS', +} + export type Aws = { rds: Pick; + iamPolicyStatus: IamPolicyStatus; }; export interface Database { diff --git a/web/packages/teleport/src/services/integrations/integrations.ts b/web/packages/teleport/src/services/integrations/integrations.ts index f8413f834bd89..ff2bda30f9361 100644 --- a/web/packages/teleport/src/services/integrations/integrations.ts +++ b/web/packages/teleport/src/services/integrations/integrations.ts @@ -46,8 +46,8 @@ export const integrationService = { }); }, - createIntegration(req: IntegrationCreateRequest): Promise { - return api.post(cfg.getIntegrationsUrl(), req); + createIntegration(req: IntegrationCreateRequest): Promise { + return api.post(cfg.getIntegrationsUrl(), req).then(makeIntegration); }, updateIntegration(