mirror of
https://github.com/gravitational/teleport.git
synced 2026-10-12 02:15:01 +00:00
WebDiscover: Add AWS Management Console as a guided flow (#41569)
* Pass integration field with apps * Add new endpoints, update types, add regex * Add yaml template for app access for access info * Define aws console as a selectable resource * AwsAccount step also fetches apps if awsConsole is detected * Implement create app server * Implement setup access view * Implement test connection view * Define aws console flow * Add tsh cli command for accessing aws cli * Address CR * Improve language * Address CR part 2
This commit is contained in:
@@ -58,6 +58,9 @@ type App struct {
|
||||
SAMLApp bool `json:"samlApp,omitempty"`
|
||||
// RequireRequest indicates if a returned resource is only accessible after an access request
|
||||
RequiresRequest bool `json:"requiresRequest,omitempty"`
|
||||
// Integration is the integration name that must be used to access this Application.
|
||||
// Only applicable to AWS App Access.
|
||||
Integration string `json:"integration,omitempty"`
|
||||
}
|
||||
|
||||
// UserGroupAndDescription is a user group name and its description.
|
||||
@@ -135,6 +138,7 @@ func MakeApp(app types.Application, c MakeAppsConfig) App {
|
||||
UserGroups: userGroupAndDescriptions,
|
||||
SAMLApp: false,
|
||||
RequiresRequest: c.RequiresRequest,
|
||||
Integration: app.GetIntegration(),
|
||||
}
|
||||
|
||||
if app.IsAWSConsole() {
|
||||
|
||||
@@ -32,7 +32,7 @@ export type Props = {
|
||||
hideSelectedOptions?: boolean;
|
||||
controlShouldRenderValue?: boolean;
|
||||
maxMenuHeight?: number;
|
||||
onChange(e: Option<any, any> | Option<any, any>[]): void;
|
||||
onChange(e: Option<any, any> | Option<any, any>[], action?: ActionMeta): void;
|
||||
onKeyDown?(e: KeyboardEvent | React.KeyboardEvent): void;
|
||||
value: null | Option<any, any> | Option<any, any>[];
|
||||
isMulti?: boolean;
|
||||
@@ -92,7 +92,7 @@ export type GroupOption = {
|
||||
};
|
||||
|
||||
export type ActionMeta = {
|
||||
action: 'set-value' | 'input-change' | 'input-blur' | 'menu-close';
|
||||
action: 'set-value' | 'input-change' | 'input-blur' | 'menu-close' | 'clear';
|
||||
};
|
||||
|
||||
/**
|
||||
|
||||
@@ -16,6 +16,8 @@
|
||||
* along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import { IAM_ROLE_NAME_REGEX } from 'teleport/services/integrations/aws';
|
||||
|
||||
/**
|
||||
* The result of validating a field.
|
||||
*/
|
||||
@@ -95,13 +97,6 @@ const requiredConfirmedPassword =
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* 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
|
||||
*
|
||||
* The regex checks for alphanumerics and select few characters.
|
||||
*/
|
||||
const IAM_ROLE_NAME_REGEX = /^[\w+=,.@-]+$/;
|
||||
const isIamRoleNameValid = roleName => {
|
||||
return (
|
||||
roleName && roleName.length <= 64 && roleName.match(IAM_ROLE_NAME_REGEX)
|
||||
|
||||
+51
@@ -0,0 +1,51 @@
|
||||
/**
|
||||
* 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 <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import { Text, Flex, ButtonPrimary } from 'design';
|
||||
import * as Icons from 'design/Icon';
|
||||
import Dialog, { DialogContent } from 'design/DialogConfirmation';
|
||||
|
||||
export function AppCreatedDialog({
|
||||
toNextStep,
|
||||
appName,
|
||||
}: {
|
||||
toNextStep: () => void;
|
||||
appName: string;
|
||||
}) {
|
||||
return (
|
||||
<Dialog disableEscapeKeyDown={false} open={true}>
|
||||
<DialogContent
|
||||
width="460px"
|
||||
alignItems="center"
|
||||
mb={0}
|
||||
textAlign="center"
|
||||
>
|
||||
<Flex mb={5}>
|
||||
<Icons.Check size="small" ml={1} mr={2} color="success.main" />
|
||||
<Text>
|
||||
Successfully created an application server named "{appName}".
|
||||
</Text>
|
||||
</Flex>
|
||||
<ButtonPrimary width="100%" onClick={() => toNextStep()}>
|
||||
Next
|
||||
</ButtonPrimary>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
+144
@@ -0,0 +1,144 @@
|
||||
/**
|
||||
* 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 <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import { MemoryRouter } from 'react-router';
|
||||
import { initialize, mswLoader } from 'msw-storybook-addon';
|
||||
import { rest } from 'msw';
|
||||
import { Info } from 'design/Alert';
|
||||
|
||||
import { ContextProvider } from 'teleport';
|
||||
import cfg from 'teleport/config';
|
||||
import { createTeleportContext } from 'teleport/mocks/contexts';
|
||||
import {
|
||||
DiscoverProvider,
|
||||
DiscoverContextState,
|
||||
} from 'teleport/Discover/useDiscover';
|
||||
import {
|
||||
IntegrationKind,
|
||||
IntegrationStatusCode,
|
||||
} from 'teleport/services/integrations';
|
||||
import { ResourceKind } from 'teleport/Discover/Shared';
|
||||
import { DiscoverEventResource } from 'teleport/services/userEvent';
|
||||
|
||||
import { CreateAppAccess } from './CreateAppAccess';
|
||||
|
||||
initialize();
|
||||
|
||||
export default {
|
||||
title: 'Teleport/Discover/Application/AwsConsole/CreateApp',
|
||||
loaders: [mswLoader],
|
||||
};
|
||||
|
||||
export const Success = () => <Component />;
|
||||
Success.parameters = {
|
||||
msw: {
|
||||
handlers: [
|
||||
rest.post(cfg.api.awsAppAccessPath, (req, res, ctx) =>
|
||||
res(ctx.json({ name: 'app-1' }))
|
||||
),
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
export const Loading = () => {
|
||||
cfg.isCloud = true;
|
||||
return <Component />;
|
||||
};
|
||||
Loading.parameters = {
|
||||
msw: {
|
||||
handlers: [
|
||||
rest.post(cfg.api.awsAppAccessPath, (req, res, ctx) =>
|
||||
res(ctx.delay('infinite'))
|
||||
),
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
export const Failed = () => <Component />;
|
||||
Failed.parameters = {
|
||||
msw: {
|
||||
handlers: [
|
||||
rest.post(cfg.api.awsAppAccessPath, (req, res, ctx) =>
|
||||
res(
|
||||
ctx.status(403),
|
||||
ctx.json({ message: 'Some kind of error message' })
|
||||
)
|
||||
),
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
const Component = () => {
|
||||
const ctx = createTeleportContext();
|
||||
const discoverCtx: DiscoverContextState = {
|
||||
agentMeta: {
|
||||
resourceName: 'aws-console',
|
||||
agentMatcherLabels: [],
|
||||
awsIntegration: {
|
||||
kind: IntegrationKind.AwsOidc,
|
||||
name: 'some-oidc-name',
|
||||
resourceType: 'integration',
|
||||
spec: {
|
||||
roleArn: 'arn:aws:iam::123456789012:role/test-role-arn',
|
||||
issuerS3Bucket: '',
|
||||
issuerS3Prefix: '',
|
||||
},
|
||||
statusCode: IntegrationStatusCode.Running,
|
||||
},
|
||||
},
|
||||
currentStep: 0,
|
||||
nextStep: () => null,
|
||||
prevStep: () => null,
|
||||
onSelectResource: () => null,
|
||||
resourceSpec: {
|
||||
name: '',
|
||||
kind: ResourceKind.Application,
|
||||
icon: null,
|
||||
keywords: '',
|
||||
event: DiscoverEventResource.ApplicationHttp,
|
||||
appMeta: {
|
||||
awsConsole: true,
|
||||
},
|
||||
},
|
||||
exitFlow: () => null,
|
||||
viewConfig: null,
|
||||
indexedViews: [],
|
||||
setResourceSpec: () => null,
|
||||
updateAgentMeta: () => null,
|
||||
emitErrorEvent: () => null,
|
||||
emitEvent: () => null,
|
||||
eventState: null,
|
||||
};
|
||||
|
||||
cfg.proxyCluster = 'localhost';
|
||||
return (
|
||||
<MemoryRouter
|
||||
initialEntries={[
|
||||
{ pathname: cfg.routes.discover, state: { entity: 'application' } },
|
||||
]}
|
||||
>
|
||||
<ContextProvider ctx={ctx}>
|
||||
<DiscoverProvider mockCtx={discoverCtx}>
|
||||
<Info>Devs: Click next to see next state</Info>
|
||||
<CreateAppAccess />
|
||||
</DiscoverProvider>
|
||||
</ContextProvider>
|
||||
</MemoryRouter>
|
||||
);
|
||||
};
|
||||
+135
@@ -0,0 +1,135 @@
|
||||
/**
|
||||
* 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 <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import { MemoryRouter } from 'react-router';
|
||||
import { render, screen, userEvent } from 'design/utils/testing';
|
||||
|
||||
import { ContextProvider } from 'teleport';
|
||||
import {
|
||||
IntegrationKind,
|
||||
IntegrationStatusCode,
|
||||
integrationService,
|
||||
} from 'teleport/services/integrations';
|
||||
import { createTeleportContext } from 'teleport/mocks/contexts';
|
||||
import cfg from 'teleport/config';
|
||||
import TeleportContext from 'teleport/teleportContext';
|
||||
import {
|
||||
DiscoverContextState,
|
||||
DiscoverProvider,
|
||||
} from 'teleport/Discover/useDiscover';
|
||||
import { FeaturesContextProvider } from 'teleport/FeaturesContext';
|
||||
|
||||
import {
|
||||
DiscoverEventResource,
|
||||
userEventService,
|
||||
} from 'teleport/services/userEvent';
|
||||
import { app } from 'teleport/Discover/AwsMangementConsole/fixtures';
|
||||
import { ResourceKind } from 'teleport/Discover/Shared';
|
||||
|
||||
import { CreateAppAccess } from './CreateAppAccess';
|
||||
|
||||
beforeEach(() => {
|
||||
jest.spyOn(integrationService, 'createAwsAppAccess').mockResolvedValue(app);
|
||||
jest
|
||||
.spyOn(userEventService, 'captureDiscoverEvent')
|
||||
.mockResolvedValue(undefined as never);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.restoreAllMocks();
|
||||
});
|
||||
|
||||
test('create app access', async () => {
|
||||
const { ctx, discoverCtx } = getMockedContexts();
|
||||
|
||||
renderCreateAppAccess(ctx, discoverCtx);
|
||||
await screen.findByText(/bash/i);
|
||||
|
||||
userEvent.click(screen.getByRole('button', { name: /next/i }));
|
||||
await screen.findByText(/aws-console/i);
|
||||
expect(integrationService.createAwsAppAccess).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
function getMockedContexts() {
|
||||
const ctx = createTeleportContext();
|
||||
const discoverCtx: DiscoverContextState = {
|
||||
agentMeta: {
|
||||
resourceName: 'aws-console',
|
||||
agentMatcherLabels: [],
|
||||
awsIntegration: {
|
||||
kind: IntegrationKind.AwsOidc,
|
||||
name: 'some-oidc-name',
|
||||
resourceType: 'integration',
|
||||
spec: {
|
||||
roleArn: 'arn:aws:iam::123456789012:role/test-role-arn',
|
||||
issuerS3Bucket: '',
|
||||
issuerS3Prefix: '',
|
||||
},
|
||||
statusCode: IntegrationStatusCode.Running,
|
||||
},
|
||||
},
|
||||
currentStep: 0,
|
||||
nextStep: jest.fn(),
|
||||
prevStep: () => null,
|
||||
onSelectResource: () => null,
|
||||
resourceSpec: {
|
||||
kind: ResourceKind.Application,
|
||||
appMeta: { awsConsole: true },
|
||||
name: '',
|
||||
icon: undefined,
|
||||
keywords: '',
|
||||
event: DiscoverEventResource.ApplicationHttp,
|
||||
},
|
||||
exitFlow: () => null,
|
||||
viewConfig: null,
|
||||
indexedViews: [],
|
||||
setResourceSpec: () => null,
|
||||
updateAgentMeta: () => null,
|
||||
emitErrorEvent: () => null,
|
||||
emitEvent: jest.fn(),
|
||||
eventState: null,
|
||||
};
|
||||
|
||||
jest
|
||||
.spyOn(userEventService, 'captureDiscoverEvent')
|
||||
.mockResolvedValue(undefined as never);
|
||||
|
||||
return { ctx, discoverCtx };
|
||||
}
|
||||
|
||||
function renderCreateAppAccess(
|
||||
ctx: TeleportContext,
|
||||
discoverCtx: DiscoverContextState
|
||||
) {
|
||||
return render(
|
||||
<MemoryRouter
|
||||
initialEntries={[
|
||||
{ pathname: cfg.routes.discover, state: { entity: 'application' } },
|
||||
]}
|
||||
>
|
||||
<ContextProvider ctx={ctx}>
|
||||
<FeaturesContextProvider value={[]}>
|
||||
<DiscoverProvider mockCtx={discoverCtx}>
|
||||
<CreateAppAccess />
|
||||
</DiscoverProvider>
|
||||
</FeaturesContextProvider>
|
||||
</ContextProvider>
|
||||
</MemoryRouter>
|
||||
);
|
||||
}
|
||||
+148
@@ -0,0 +1,148 @@
|
||||
/**
|
||||
* 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 <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import styled from 'styled-components';
|
||||
import { Box, Text, Flex, Link } from 'design';
|
||||
import TextEditor from 'shared/components/TextEditor';
|
||||
import { Danger } from 'design/Alert';
|
||||
import { ToolTipInfo } from 'shared/components/ToolTip';
|
||||
import { useAsync } from 'shared/hooks/useAsync';
|
||||
|
||||
import { TextSelectCopyMulti } from 'teleport/components/TextSelectCopy';
|
||||
import { useDiscover } from 'teleport/Discover/useDiscover';
|
||||
import { integrationService } from 'teleport/services/integrations';
|
||||
import cfg from 'teleport/config';
|
||||
import { Container } from 'teleport/Discover/Shared/CommandBox';
|
||||
import { splitAwsIamArn } from 'teleport/services/integrations/aws';
|
||||
|
||||
import { ActionButtons, Header, Mark } from '../../Shared';
|
||||
|
||||
import { AppCreatedDialog } from './AppCreatedDialog';
|
||||
|
||||
const IAM_POLICY_NAME = 'AWSAppAccess';
|
||||
|
||||
export function CreateAppAccess() {
|
||||
const { agentMeta, updateAgentMeta, emitErrorEvent, nextStep } =
|
||||
useDiscover();
|
||||
const { awsIntegration } = agentMeta;
|
||||
|
||||
const [attempt, createApp] = useAsync(async () => {
|
||||
try {
|
||||
const app = await integrationService.createAwsAppAccess(
|
||||
awsIntegration.name
|
||||
);
|
||||
updateAgentMeta({
|
||||
...agentMeta,
|
||||
app,
|
||||
resourceName: app.name,
|
||||
});
|
||||
} catch (err) {
|
||||
emitErrorEvent(err.message);
|
||||
throw err;
|
||||
}
|
||||
});
|
||||
|
||||
const iamRoleName = splitAwsIamArn(
|
||||
agentMeta.awsIntegration.spec.roleArn
|
||||
).arnResourceName;
|
||||
const scriptUrl = cfg.getAwsIamConfigureScriptAppAccessUrl({
|
||||
iamRoleName,
|
||||
});
|
||||
|
||||
return (
|
||||
<Box maxWidth="800px">
|
||||
<Header>Enable Access to AWS with Teleport Application Access</Header>
|
||||
<Text mt={1} mb={3}>
|
||||
An application will be created that will use the selected AWS OIDC
|
||||
Integration <Mark>{agentMeta.awsIntegration.name}</Mark> for proxying
|
||||
access to AWS Management Console, AWS CLI, and AWS APIs.
|
||||
</Text>
|
||||
{attempt.status === 'error' && (
|
||||
<Danger mt={3}>{attempt.statusText}</Danger>
|
||||
)}
|
||||
<Container>
|
||||
<Flex alignItems="center" gap={1} mb={1}>
|
||||
<Text bold>First configure your AWS IAM permissions</Text>
|
||||
<ToolTipInfo sticky={true} maxWidth={450}>
|
||||
The following IAM permissions will be added as an inline policy
|
||||
named <Mark>{IAM_POLICY_NAME}</Mark> to IAM role{' '}
|
||||
<Mark>{iamRoleName}</Mark>
|
||||
<Box mb={2}>
|
||||
<EditorWrapper $height={250}>
|
||||
<TextEditor
|
||||
readOnly={true}
|
||||
data={[{ content: inlinePolicyJson, type: 'json' }]}
|
||||
bg="levels.deep"
|
||||
/>
|
||||
</EditorWrapper>
|
||||
</Box>
|
||||
</ToolTipInfo>
|
||||
</Flex>
|
||||
<Text typography="subtitle1" mb={1}>
|
||||
Run the command below on your{' '}
|
||||
<Link
|
||||
href="https://console.aws.amazon.com/cloudshell/home"
|
||||
target="_blank"
|
||||
>
|
||||
AWS CloudShell
|
||||
</Link>{' '}
|
||||
to configure your IAM permissions.
|
||||
</Text>
|
||||
<TextSelectCopyMulti
|
||||
lines={[{ text: `bash -c "$(curl '${scriptUrl}')"` }]}
|
||||
/>
|
||||
</Container>
|
||||
|
||||
<ActionButtons
|
||||
onProceed={createApp}
|
||||
disableProceed={
|
||||
attempt.status === 'processing' || attempt.status === 'success'
|
||||
}
|
||||
/>
|
||||
{attempt.status === 'success' && (
|
||||
<AppCreatedDialog
|
||||
toNextStep={nextStep}
|
||||
appName={agentMeta.resourceName}
|
||||
/>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
const EditorWrapper = styled(Flex)`
|
||||
flex-directions: column;
|
||||
height: ${p => p.$height}px;
|
||||
margin-top: ${p => p.theme.space[3]}px;
|
||||
width: 450px;
|
||||
`;
|
||||
|
||||
const inlinePolicyJson = `{
|
||||
"Version": "2012-10-17",
|
||||
"Statement": [
|
||||
{
|
||||
"Sid": "AssumeTaggedRole",
|
||||
"Effect": "Allow",
|
||||
"Action": "sts:AssumeRole",
|
||||
"Resource": "*",
|
||||
"Condition": {
|
||||
"StringEquals": {"iam:ResourceTag/teleport.dev/integration": "true"}
|
||||
}
|
||||
}
|
||||
]
|
||||
}`;
|
||||
+216
@@ -0,0 +1,216 @@
|
||||
/**
|
||||
* 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 <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import { MemoryRouter } from 'react-router';
|
||||
import { initialize, mswLoader } from 'msw-storybook-addon';
|
||||
import { rest } from 'msw';
|
||||
import { AwsRole } from 'shared/services/apps';
|
||||
|
||||
import { ContextProvider } from 'teleport';
|
||||
import cfg from 'teleport/config';
|
||||
import { createTeleportContext, getAcl } from 'teleport/mocks/contexts';
|
||||
import {
|
||||
DiscoverProvider,
|
||||
DiscoverContextState,
|
||||
} from 'teleport/Discover/useDiscover';
|
||||
import {
|
||||
IntegrationKind,
|
||||
IntegrationStatusCode,
|
||||
} from 'teleport/services/integrations';
|
||||
import { ResourceKind } from 'teleport/Discover/Shared';
|
||||
import { DiscoverEventResource } from 'teleport/services/userEvent';
|
||||
|
||||
import { app } from '../fixtures';
|
||||
|
||||
import { SetupAccess } from './SetupAccess';
|
||||
|
||||
export default {
|
||||
title: 'Teleport/Discover/Application/AwsConsole/SetupAccess',
|
||||
loaders: [mswLoader],
|
||||
};
|
||||
|
||||
initialize();
|
||||
|
||||
const awsRoles: AwsRole[] = [
|
||||
{
|
||||
name: 'test1',
|
||||
display: 'test1',
|
||||
arn: 'arn:aws:iam::123456789012:role/test1',
|
||||
accountId: '123456789012',
|
||||
},
|
||||
{
|
||||
name: 'test2',
|
||||
display: 'test2',
|
||||
arn: 'arn:aws:iam::123456789012:role/test2',
|
||||
accountId: '123456789012',
|
||||
},
|
||||
];
|
||||
|
||||
const defaultUserGet = rest.get(cfg.api.userWithUsernamePath, (req, res, ctx) =>
|
||||
res(
|
||||
ctx.json({
|
||||
name: 'user-1',
|
||||
roles: [],
|
||||
authType: 'local',
|
||||
isLocal: true,
|
||||
traits: {
|
||||
awsRoleArns: [],
|
||||
},
|
||||
allTraits: {},
|
||||
})
|
||||
)
|
||||
);
|
||||
|
||||
export const NoTraits = () => (
|
||||
<MemoryRouter>
|
||||
<Provider awsRoles={[]}>
|
||||
<SetupAccess />
|
||||
</Provider>
|
||||
</MemoryRouter>
|
||||
);
|
||||
NoTraits.parameters = {
|
||||
msw: {
|
||||
handlers: [defaultUserGet],
|
||||
},
|
||||
};
|
||||
|
||||
export const WithTraits = () => (
|
||||
<MemoryRouter>
|
||||
<Provider awsRoles={awsRoles}>
|
||||
<SetupAccess />
|
||||
</Provider>
|
||||
</MemoryRouter>
|
||||
);
|
||||
WithTraits.parameters = {
|
||||
msw: {
|
||||
handlers: [
|
||||
rest.get(cfg.api.userWithUsernamePath, (req, res, ctx) =>
|
||||
res(
|
||||
ctx.json({
|
||||
name: 'user-1',
|
||||
roles: [],
|
||||
authType: 'local',
|
||||
isLocal: true,
|
||||
traits: {
|
||||
awsRoleArns: ['arn:aws:iam::123456789012:role/dynamic1'],
|
||||
},
|
||||
allTraits: {},
|
||||
})
|
||||
)
|
||||
),
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
export const NoAccess = () => (
|
||||
<MemoryRouter>
|
||||
<Provider awsRoles={awsRoles} noAccess={true}>
|
||||
<SetupAccess />
|
||||
</Provider>
|
||||
</MemoryRouter>
|
||||
);
|
||||
NoAccess.parameters = {
|
||||
msw: {
|
||||
handlers: [defaultUserGet],
|
||||
},
|
||||
};
|
||||
|
||||
export const SsoUser = () => (
|
||||
<MemoryRouter>
|
||||
<Provider awsRoles={awsRoles} isSso={true}>
|
||||
<SetupAccess />
|
||||
</Provider>
|
||||
</MemoryRouter>
|
||||
);
|
||||
SsoUser.parameters = {
|
||||
msw: {
|
||||
handlers: [defaultUserGet],
|
||||
},
|
||||
};
|
||||
|
||||
const Provider = ({
|
||||
children,
|
||||
awsRoles,
|
||||
noAccess = false,
|
||||
isSso = false,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
awsRoles: AwsRole[];
|
||||
noAccess?: boolean;
|
||||
isSso?: boolean;
|
||||
}) => {
|
||||
const ctx = createTeleportContext();
|
||||
if (noAccess) {
|
||||
ctx.storeUser.state.acl = getAcl({ noAccess: true });
|
||||
}
|
||||
if (isSso) {
|
||||
ctx.storeUser.state.authType = 'sso';
|
||||
}
|
||||
const discoverCtx: DiscoverContextState = {
|
||||
prevStep: () => {},
|
||||
nextStep: () => {},
|
||||
agentMeta: {
|
||||
app: {
|
||||
...app,
|
||||
awsRoles,
|
||||
},
|
||||
awsIntegration: {
|
||||
resourceType: 'integration',
|
||||
kind: IntegrationKind.AwsOidc,
|
||||
name: 'some-aws-oidc-name',
|
||||
statusCode: IntegrationStatusCode.Running,
|
||||
spec: {
|
||||
roleArn: 'arn:aws:iam::123456789012:role/some-iam-role-name',
|
||||
issuerS3Bucket: '',
|
||||
issuerS3Prefix: '',
|
||||
},
|
||||
},
|
||||
},
|
||||
currentStep: 0,
|
||||
onSelectResource: () => null,
|
||||
resourceSpec: {
|
||||
kind: ResourceKind.Application,
|
||||
appMeta: { awsConsole: true },
|
||||
name: '',
|
||||
icon: undefined,
|
||||
keywords: '',
|
||||
event: DiscoverEventResource.ApplicationHttp,
|
||||
},
|
||||
exitFlow: () => null,
|
||||
viewConfig: null,
|
||||
indexedViews: [],
|
||||
setResourceSpec: () => null,
|
||||
updateAgentMeta: () => null,
|
||||
emitErrorEvent: () => null,
|
||||
emitEvent: () => null,
|
||||
eventState: null,
|
||||
};
|
||||
|
||||
return (
|
||||
<MemoryRouter
|
||||
initialEntries={[
|
||||
{ pathname: cfg.routes.discover, state: { entity: 'server' } },
|
||||
]}
|
||||
>
|
||||
<ContextProvider ctx={ctx}>
|
||||
<DiscoverProvider mockCtx={discoverCtx}>{children}</DiscoverProvider>
|
||||
</ContextProvider>
|
||||
</MemoryRouter>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,178 @@
|
||||
/**
|
||||
* 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 <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { LabelInput, Text, Link } from 'design';
|
||||
import { useTheme } from 'styled-components';
|
||||
import { Cross } from 'design/Icon';
|
||||
|
||||
import { OutlineInfo } from 'design/Alert/Alert';
|
||||
import { FieldSelectCreatable } from 'shared/components/FieldSelect';
|
||||
import Validation, { Validator } from 'shared/components/Validation';
|
||||
|
||||
import { Option } from 'teleport/Discover/Shared/SelectCreatable';
|
||||
import { IAM_ROLE_ARN_REGEX } from 'teleport/services/integrations/aws';
|
||||
import {
|
||||
useUserTraits,
|
||||
SetupAccessWrapper,
|
||||
} from 'teleport/Discover/Shared/SetupAccess';
|
||||
import { Mark } from 'teleport/Discover/Shared';
|
||||
import { styles } from 'teleport/Discover/Shared/SelectCreatable/SelectCreatable';
|
||||
import { AWS_TAG_INFO_LINK } from 'teleport/Discover/Shared/const';
|
||||
|
||||
export function SetupAccess() {
|
||||
const {
|
||||
onProceed,
|
||||
initSelectedOptions,
|
||||
getFixedOptions,
|
||||
getSelectableOptions,
|
||||
...restOfProps
|
||||
} = useUserTraits();
|
||||
|
||||
const theme = useTheme();
|
||||
|
||||
const [selectedArns, setSelectedArns] = useState<Option[]>([]);
|
||||
const [inputValue, setInputValue] = useState('');
|
||||
|
||||
useEffect(() => {
|
||||
if (restOfProps.attempt.status === 'success') {
|
||||
setSelectedArns(initSelectedOptions('awsRoleArns'));
|
||||
}
|
||||
}, [restOfProps.attempt.status]);
|
||||
|
||||
function handleKeyDown(event: React.KeyboardEvent, validator: Validator) {
|
||||
if (!inputValue) return;
|
||||
switch (event.key) {
|
||||
case 'Enter':
|
||||
case 'Tab':
|
||||
if (!validator.validate()) return;
|
||||
setSelectedArns(prevArns => [
|
||||
...prevArns,
|
||||
{ value: inputValue, label: inputValue },
|
||||
]);
|
||||
setInputValue('');
|
||||
event.preventDefault();
|
||||
}
|
||||
}
|
||||
|
||||
function handleOnProceed(validator: Validator) {
|
||||
if (!validator.validate()) return;
|
||||
onProceed({ awsRoleArns: selectedArns });
|
||||
}
|
||||
|
||||
const canAddTraits = !restOfProps.isSsoUser && restOfProps.canEditUser;
|
||||
const headerSubtitle = 'Allow access to AWS Management Console.';
|
||||
const hasTraits = selectedArns.length > 0;
|
||||
|
||||
const preContent = (
|
||||
<OutlineInfo mt={-3} mb={3} linkColor="buttons.link.default">
|
||||
<Text>
|
||||
Only{' '}
|
||||
<Link target="_blank" href={AWS_TAG_INFO_LINK}>
|
||||
IAM roles with tag
|
||||
</Link>{' '}
|
||||
key <Mark>teleport.dev/integration</Mark> and value <Mark>true</Mark>{' '}
|
||||
are allowed to be used by the integration.
|
||||
</Text>
|
||||
</OutlineInfo>
|
||||
);
|
||||
|
||||
return (
|
||||
<Validation>
|
||||
{({ validator }) => (
|
||||
<SetupAccessWrapper
|
||||
{...restOfProps}
|
||||
headerSubtitle={headerSubtitle}
|
||||
traitKind="ARN"
|
||||
traitDescription=""
|
||||
hasTraits={hasTraits}
|
||||
onProceed={() => handleOnProceed(validator)}
|
||||
preContent={preContent}
|
||||
onPrev={null}
|
||||
>
|
||||
<FieldSelectCreatable
|
||||
css={`
|
||||
${LabelInput} {
|
||||
font-size: ${p => p.theme.fontSizes[2]}px;
|
||||
padding-bottom: ${p => p.theme.space[1]}px;
|
||||
}
|
||||
`}
|
||||
mb={1}
|
||||
components={{
|
||||
DropdownIndicator: null,
|
||||
CrossIcon: () => <Cross />,
|
||||
}}
|
||||
stylesConfig={styles(theme)}
|
||||
rule={validArns}
|
||||
label="Copy and paste IAM role ARNs that you want to assume when accessing the
|
||||
AWS console"
|
||||
inputValue={inputValue}
|
||||
onInputChange={setInputValue}
|
||||
onKeyDown={(e: React.KeyboardEvent) => handleKeyDown(e, validator)}
|
||||
isMulti
|
||||
isSearchable
|
||||
isClearable={selectedArns.some(v => !v.isFixed)}
|
||||
placeholder="Copy and paste ARNs"
|
||||
value={selectedArns}
|
||||
isDisabled={!canAddTraits}
|
||||
onChange={(value: Option[], action) => {
|
||||
if (action.action === 'clear') {
|
||||
setSelectedArns(getFixedOptions('awsRoleArns'));
|
||||
} else {
|
||||
setSelectedArns(value || []);
|
||||
}
|
||||
}}
|
||||
options={getSelectableOptions('awsRoleArns')}
|
||||
autoFocus
|
||||
toolTipContent={
|
||||
<>
|
||||
ARN is found in the format{' '}
|
||||
<Mark>{`arn:aws:iam::<AWS_ACCOUNT_ID>:role/<NAME_OF_ROLE>`}</Mark>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
</SetupAccessWrapper>
|
||||
)}
|
||||
</Validation>
|
||||
);
|
||||
}
|
||||
|
||||
export const validArns = (createdArns: Option[]) => () => {
|
||||
if (!createdArns) {
|
||||
return {
|
||||
valid: true,
|
||||
};
|
||||
}
|
||||
|
||||
const badFilters = createdArns.filter(o => {
|
||||
// Ignore statically (role) configured arns since
|
||||
// user cannot fix it on this screen.
|
||||
return !o.isFixed && !IAM_ROLE_ARN_REGEX.test(o.value);
|
||||
});
|
||||
|
||||
if (badFilters.length > 0) {
|
||||
return {
|
||||
valid: false,
|
||||
message: `The following ARNs have invalid format: ${badFilters
|
||||
.map(f => f.value)
|
||||
.join(', ')}`,
|
||||
};
|
||||
}
|
||||
|
||||
return { valid: true };
|
||||
};
|
||||
+92
@@ -0,0 +1,92 @@
|
||||
/**
|
||||
* 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 <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import { MemoryRouter } from 'react-router';
|
||||
|
||||
import { ContextProvider } from 'teleport';
|
||||
import cfg from 'teleport/config';
|
||||
import { createTeleportContext } from 'teleport/mocks/contexts';
|
||||
import {
|
||||
DiscoverProvider,
|
||||
DiscoverContextState,
|
||||
} from 'teleport/Discover/useDiscover';
|
||||
|
||||
import { app } from '../fixtures';
|
||||
|
||||
import { TestConnection as Comp } from './TestConnection';
|
||||
|
||||
export default {
|
||||
title: 'Teleport/Discover/Application/AwsConsole',
|
||||
};
|
||||
|
||||
export const TestConnection = () => (
|
||||
<Provider>
|
||||
<Comp />
|
||||
</Provider>
|
||||
);
|
||||
|
||||
const Provider = ({ children }) => {
|
||||
const ctx = createTeleportContext();
|
||||
const discoverCtx: DiscoverContextState = {
|
||||
prevStep: () => {},
|
||||
nextStep: () => {},
|
||||
agentMeta: {
|
||||
app: {
|
||||
...app,
|
||||
awsRoles: [
|
||||
{
|
||||
name: 'static-arn1',
|
||||
arn: 'arn:aws:iam::123456789012:role/static-arn1',
|
||||
display: 'static-arn1',
|
||||
accountId: '123456789012',
|
||||
},
|
||||
{
|
||||
name: 'static-arn2',
|
||||
arn: 'arn:aws:iam::123456789012:role/static-arn2',
|
||||
display: 'static-arn2',
|
||||
accountId: '123456789012',
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
currentStep: 0,
|
||||
onSelectResource: () => null,
|
||||
resourceSpec: undefined,
|
||||
exitFlow: () => null,
|
||||
viewConfig: null,
|
||||
indexedViews: [],
|
||||
setResourceSpec: () => null,
|
||||
updateAgentMeta: () => null,
|
||||
emitErrorEvent: () => null,
|
||||
emitEvent: () => null,
|
||||
eventState: null,
|
||||
};
|
||||
|
||||
return (
|
||||
<MemoryRouter
|
||||
initialEntries={[
|
||||
{ pathname: cfg.routes.discover, state: { entity: 'server' } },
|
||||
]}
|
||||
>
|
||||
<ContextProvider ctx={ctx}>
|
||||
<DiscoverProvider mockCtx={discoverCtx}>{children}</DiscoverProvider>
|
||||
</ContextProvider>
|
||||
</MemoryRouter>
|
||||
);
|
||||
};
|
||||
+137
@@ -0,0 +1,137 @@
|
||||
/**
|
||||
* 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 <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import React, { useState } from 'react';
|
||||
import { Text, Box, Link } from 'design';
|
||||
import Select from 'shared/components/Select';
|
||||
import { OutlineInfo } from 'design/Alert/Alert';
|
||||
import { TextSelectCopy } from 'shared/components/TextSelectCopy';
|
||||
|
||||
import cfg from 'teleport/config';
|
||||
import { generateTshLoginCommand, openNewTab } from 'teleport/lib/util';
|
||||
import {
|
||||
Header,
|
||||
ActionButtons,
|
||||
HeaderSubtitle,
|
||||
StyledBox,
|
||||
Mark,
|
||||
} from 'teleport/Discover/Shared';
|
||||
import useTeleport from 'teleport/useTeleport';
|
||||
import { splitAwsIamArn } from 'teleport/services/integrations/aws';
|
||||
import { AWS_TAG_INFO_LINK } from 'teleport/Discover/Shared/const';
|
||||
|
||||
import { AppMeta, useDiscover } from '../../useDiscover';
|
||||
|
||||
import type { Option } from 'shared/components/Select';
|
||||
|
||||
export function TestConnection() {
|
||||
const ctx = useTeleport();
|
||||
const { username, authType } = ctx.storeUser.state;
|
||||
const clusterId = ctx.storeUser.getClusterId();
|
||||
|
||||
const { nextStep, prevStep, agentMeta } = useDiscover();
|
||||
const { app } = agentMeta as AppMeta;
|
||||
|
||||
const arnOpts = app.awsRoles.map(({ arn }) => ({ value: arn, label: arn }));
|
||||
const [selectedOpt, setSelectedOpt] = useState<Option>();
|
||||
|
||||
function launchUrl(arn: string) {
|
||||
const { fqdn, clusterId, publicAddr } = app;
|
||||
const appUrl = cfg.getAppLauncherRoute({
|
||||
fqdn,
|
||||
clusterId,
|
||||
publicAddr,
|
||||
arn,
|
||||
});
|
||||
|
||||
openNewTab(appUrl);
|
||||
}
|
||||
|
||||
let arnResourceName = '<IAM-Role-Name>';
|
||||
const splittedArn = splitAwsIamArn(selectedOpt?.value);
|
||||
if (splittedArn.arnResourceName) {
|
||||
arnResourceName = splittedArn.arnResourceName;
|
||||
}
|
||||
const tshCmd = `tsh apps login --aws-role ${arnResourceName} ${app.name}`;
|
||||
|
||||
return (
|
||||
<Box>
|
||||
<Header>Test Connection</Header>
|
||||
<HeaderSubtitle>
|
||||
Optionally, verify that you can successfully connect to the application
|
||||
you just added.
|
||||
</HeaderSubtitle>
|
||||
<StyledBox mb={5}>
|
||||
<Text bold mb={3}>
|
||||
Access the AWS Management Console
|
||||
</Text>
|
||||
<Text typography="subtitle1" mb={2}>
|
||||
Select the AWS role ARN to test.
|
||||
</Text>
|
||||
<Text typography="subtitle1" mb={2}>
|
||||
AWS Management Console will launch in another tab. You should see your
|
||||
Teleport user name as a federated login with the selected role in the
|
||||
top-right corner of the AWS Console.
|
||||
</Text>
|
||||
<Box width="500px">
|
||||
<Select
|
||||
value={selectedOpt}
|
||||
options={arnOpts}
|
||||
onChange={(o: Option) => {
|
||||
setSelectedOpt(o);
|
||||
launchUrl(o.value);
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
</StyledBox>
|
||||
<StyledBox mb={5}>
|
||||
<Text bold mb={3}>
|
||||
Access the AWS CLI
|
||||
</Text>
|
||||
<Box mb={2}>
|
||||
Log into your Teleport cluster
|
||||
<TextSelectCopy
|
||||
mt="1"
|
||||
text={generateTshLoginCommand({
|
||||
authType,
|
||||
username,
|
||||
clusterId,
|
||||
})}
|
||||
/>
|
||||
</Box>
|
||||
<Box mb={2}>
|
||||
Connect to your application
|
||||
<TextSelectCopy mt="1" text={tshCmd} />
|
||||
</Box>
|
||||
</StyledBox>
|
||||
<OutlineInfo mb={3} linkColor="buttons.link.default" width="800px">
|
||||
<Text>
|
||||
If the connection can't be established, ensure the IAM role you are
|
||||
trying to assume is{' '}
|
||||
<Link target="_blank" href={AWS_TAG_INFO_LINK}>
|
||||
tagged
|
||||
</Link>{' '}
|
||||
with key <Mark>teleport.dev/integration</Mark> and value{' '}
|
||||
<Mark>true</Mark>.
|
||||
</Text>
|
||||
</OutlineInfo>
|
||||
|
||||
<ActionButtons onProceed={nextStep} lastStep={true} onPrev={prevStep} />
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
/**
|
||||
* 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 <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import { App } from 'teleport/services/apps';
|
||||
|
||||
export const app: App = {
|
||||
kind: 'app',
|
||||
id: 'id',
|
||||
name: 'Jenkins',
|
||||
launchUrl: '',
|
||||
awsRoles: [],
|
||||
userGroups: [],
|
||||
samlApp: false,
|
||||
uri: 'https://jenkins.teleport-proxy.com',
|
||||
publicAddr: 'jenkins.teleport-proxy.com',
|
||||
description: 'This is a Jenkins app',
|
||||
awsConsole: true,
|
||||
labels: [
|
||||
{ name: 'env', value: 'prod' },
|
||||
{ name: 'cluster', value: 'one' },
|
||||
],
|
||||
clusterId: 'one',
|
||||
fqdn: 'jenkins.one',
|
||||
};
|
||||
@@ -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 <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import { AwsAccount, ResourceKind, Finished } from 'teleport/Discover/Shared';
|
||||
import { ResourceViewConfig } from 'teleport/Discover/flow';
|
||||
import { ResourceSpec } from 'teleport/Discover/SelectResource';
|
||||
|
||||
import { DiscoverEvent } from 'teleport/services/userEvent';
|
||||
|
||||
import { CreateAppAccess } from './CreateAppAccess/CreateAppAccess';
|
||||
import { SetupAccess } from './SetupAccess/SetupAccess';
|
||||
import { TestConnection } from './TestConnection/TestConnection';
|
||||
|
||||
export const AwsMangementConsole: ResourceViewConfig<ResourceSpec> = {
|
||||
kind: ResourceKind.Application,
|
||||
shouldPrompt(currentStep) {
|
||||
return currentStep !== 0;
|
||||
},
|
||||
views() {
|
||||
return [
|
||||
{
|
||||
title: 'Connect AWS Account',
|
||||
component: AwsAccount,
|
||||
eventName: DiscoverEvent.IntegrationAWSOIDCConnectEvent,
|
||||
},
|
||||
{
|
||||
title: 'Create Applicaton Server',
|
||||
component: CreateAppAccess,
|
||||
// TODO(lisa) define a create application aws step
|
||||
},
|
||||
{
|
||||
title: 'Set Up Access',
|
||||
component: SetupAccess,
|
||||
eventName: DiscoverEvent.PrincipalsConfigure,
|
||||
},
|
||||
{
|
||||
title: 'Test Connection',
|
||||
component: TestConnection,
|
||||
eventName: DiscoverEvent.TestConnection,
|
||||
manuallyEmitSuccessEvent: true,
|
||||
},
|
||||
{
|
||||
title: 'Finished',
|
||||
component: Finished,
|
||||
eventName: DiscoverEvent.Completed,
|
||||
hide: true,
|
||||
},
|
||||
];
|
||||
},
|
||||
};
|
||||
@@ -175,7 +175,7 @@ export function SelectResource({ onSelect }: SelectResourceProps) {
|
||||
const pretitle = getResourcePretitle(r);
|
||||
|
||||
let resourceCardProps;
|
||||
if (r.kind === ResourceKind.Application) {
|
||||
if (r.kind === ResourceKind.Application && r.isDialog) {
|
||||
resourceCardProps = {
|
||||
onClick: () => {
|
||||
if (r.hasAccess) {
|
||||
|
||||
+103
@@ -1186,6 +1186,40 @@ exports[`render with all access 1`] = `
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
class="c7"
|
||||
data-testid="0"
|
||||
>
|
||||
<div
|
||||
class="c8"
|
||||
>
|
||||
Guided
|
||||
</div>
|
||||
<div
|
||||
class="c9 c10"
|
||||
>
|
||||
<div
|
||||
class="c11 c12"
|
||||
width="24px"
|
||||
>
|
||||
<img
|
||||
class="c13"
|
||||
height="24px"
|
||||
src="file_stub"
|
||||
width="23.9px"
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
class="c14"
|
||||
>
|
||||
<div
|
||||
class="c16"
|
||||
>
|
||||
AWS CLI/Console Access
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
class="c7"
|
||||
data-testid="4"
|
||||
@@ -3113,6 +3147,41 @@ exports[`render with no access 1`] = `
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
class="c7"
|
||||
data-testid="0"
|
||||
>
|
||||
<div
|
||||
class="c8 c9"
|
||||
data-testid="tooltip"
|
||||
>
|
||||
Lacking Permissions
|
||||
</div>
|
||||
<div
|
||||
class="c10 c11"
|
||||
>
|
||||
<div
|
||||
class="c12 c13"
|
||||
width="24px"
|
||||
>
|
||||
<img
|
||||
class="c14"
|
||||
height="24px"
|
||||
src="file_stub"
|
||||
width="23.9px"
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
class="c15"
|
||||
>
|
||||
<div
|
||||
class="c17"
|
||||
>
|
||||
AWS CLI/Console Access
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<a
|
||||
class="c18 c7"
|
||||
data-testid="1"
|
||||
@@ -5207,6 +5276,40 @@ exports[`render with partial access 1`] = `
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
class="c7"
|
||||
data-testid="0"
|
||||
>
|
||||
<div
|
||||
class="c8"
|
||||
>
|
||||
Guided
|
||||
</div>
|
||||
<div
|
||||
class="c9 c10"
|
||||
>
|
||||
<div
|
||||
class="c11 c12"
|
||||
width="24px"
|
||||
>
|
||||
<img
|
||||
class="c13"
|
||||
height="24px"
|
||||
src="file_stub"
|
||||
width="23.9px"
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
class="c14"
|
||||
>
|
||||
<div
|
||||
class="c16"
|
||||
>
|
||||
AWS CLI/Console Access
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
class="c7"
|
||||
data-testid="4"
|
||||
|
||||
@@ -109,6 +109,14 @@ export const APPLICATIONS: ResourceSpec[] = [
|
||||
isDialog: true,
|
||||
event: DiscoverEventResource.ApplicationHttp,
|
||||
},
|
||||
{
|
||||
name: 'AWS CLI/Console Access',
|
||||
kind: ResourceKind.Application,
|
||||
keywords: 'application aws cli console access',
|
||||
icon: 'Aws',
|
||||
event: DiscoverEventResource.ApplicationHttp,
|
||||
appMeta: { awsConsole: true },
|
||||
},
|
||||
];
|
||||
|
||||
export const WINDOWS_DESKTOPS: ResourceSpec[] = [
|
||||
|
||||
@@ -78,6 +78,7 @@ export enum SamlServiceProviderPreset {
|
||||
export interface ResourceSpec {
|
||||
dbMeta?: { location: DatabaseLocation; engine: DatabaseEngine };
|
||||
nodeMeta?: { location: ServerLocation };
|
||||
appMeta?: { awsConsole?: boolean };
|
||||
kubeMeta?: { location: KubeLocation };
|
||||
samlMeta?: { preset: SamlServiceProviderPreset };
|
||||
name: string;
|
||||
|
||||
@@ -0,0 +1,127 @@
|
||||
/**
|
||||
* 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 <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import { MemoryRouter } from 'react-router';
|
||||
|
||||
import { initialize, mswLoader } from 'msw-storybook-addon';
|
||||
import { rest } from 'msw';
|
||||
|
||||
import { ContextProvider } from 'teleport';
|
||||
import cfg from 'teleport/config';
|
||||
import { createTeleportContext, getAcl } from 'teleport/mocks/contexts';
|
||||
import {
|
||||
DiscoverProvider,
|
||||
DiscoverContextState,
|
||||
} from 'teleport/Discover/useDiscover';
|
||||
|
||||
import { AwsAccount } from './AwsAccount';
|
||||
|
||||
initialize();
|
||||
export default {
|
||||
title: 'Teleport/Discover/Shared/AwsAccount',
|
||||
loaders: [mswLoader],
|
||||
};
|
||||
|
||||
const handlers = [
|
||||
rest.get(cfg.getIntegrationsUrl(), (req, res, ctx) =>
|
||||
res(
|
||||
ctx.json({
|
||||
items: [
|
||||
{
|
||||
name: 'aws-oidc-1',
|
||||
subKind: 'aws-oidc',
|
||||
awsoidc: {
|
||||
roleArn: 'arn:aws:iam::123456789012:role/test1',
|
||||
},
|
||||
},
|
||||
],
|
||||
})
|
||||
)
|
||||
),
|
||||
rest.get(cfg.api.unifiedResourcesPath, (req, res, ctx) =>
|
||||
res(ctx.json({ agents: [{ name: 'app1' }] }))
|
||||
),
|
||||
];
|
||||
|
||||
export const Success = () => <Component />;
|
||||
Success.parameters = {
|
||||
msw: {
|
||||
handlers,
|
||||
},
|
||||
};
|
||||
|
||||
export const Loading = () => <Component />;
|
||||
Loading.parameters = {
|
||||
msw: {
|
||||
handlers: [
|
||||
rest.get(cfg.getIntegrationsUrl(), (req, res, ctx) =>
|
||||
res(ctx.delay('infinite'))
|
||||
),
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
export const Failed = () => <Component />;
|
||||
Failed.parameters = {
|
||||
msw: {
|
||||
handlers: [
|
||||
rest.post(cfg.getIntegrationsUrl(), (req, res, ctx) =>
|
||||
res(ctx.status(403), ctx.json({ message: 'some kind of error' }))
|
||||
),
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
export const NoPerm = () => <Component noAccess={true} />;
|
||||
|
||||
const Component = ({ noAccess = false }: { noAccess?: boolean }) => {
|
||||
const ctx = createTeleportContext();
|
||||
ctx.storeUser.state.acl = getAcl({ noAccess });
|
||||
const discoverCtx: DiscoverContextState = {
|
||||
agentMeta: {},
|
||||
currentStep: 0,
|
||||
nextStep: () => null,
|
||||
prevStep: () => null,
|
||||
onSelectResource: () => null,
|
||||
resourceSpec: {} as any,
|
||||
exitFlow: () => null,
|
||||
viewConfig: null,
|
||||
indexedViews: [],
|
||||
setResourceSpec: () => null,
|
||||
updateAgentMeta: () => null,
|
||||
emitErrorEvent: () => null,
|
||||
emitEvent: () => null,
|
||||
eventState: null,
|
||||
};
|
||||
|
||||
cfg.proxyCluster = 'localhost';
|
||||
return (
|
||||
<MemoryRouter
|
||||
initialEntries={[
|
||||
{ pathname: cfg.routes.discover, state: { entity: 'server' } },
|
||||
]}
|
||||
>
|
||||
<ContextProvider ctx={ctx}>
|
||||
<DiscoverProvider mockCtx={discoverCtx}>
|
||||
<AwsAccount />
|
||||
</DiscoverProvider>
|
||||
</ContextProvider>
|
||||
</MemoryRouter>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,192 @@
|
||||
/**
|
||||
* 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 <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import { MemoryRouter } from 'react-router';
|
||||
import { render, screen } from 'design/utils/testing';
|
||||
|
||||
import { ContextProvider } from 'teleport';
|
||||
import {
|
||||
IntegrationKind,
|
||||
IntegrationStatusCode,
|
||||
integrationService,
|
||||
} from 'teleport/services/integrations';
|
||||
import { createTeleportContext, getAcl } from 'teleport/mocks/contexts';
|
||||
import cfg from 'teleport/config';
|
||||
import TeleportContext from 'teleport/teleportContext';
|
||||
import {
|
||||
DiscoverContextState,
|
||||
DiscoverProvider,
|
||||
} from 'teleport/Discover/useDiscover';
|
||||
import { FeaturesContextProvider } from 'teleport/FeaturesContext';
|
||||
|
||||
import {
|
||||
DiscoverEventResource,
|
||||
userEventService,
|
||||
} from 'teleport/services/userEvent';
|
||||
import ResourceService from 'teleport/services/resources';
|
||||
import { app } from 'teleport/Discover/AwsMangementConsole/fixtures';
|
||||
import { ResourceSpec } from 'teleport/Discover/SelectResource';
|
||||
|
||||
import { ResourceKind } from '../ResourceKind';
|
||||
|
||||
import { AwsAccount } from './AwsAccount';
|
||||
|
||||
beforeEach(() => {
|
||||
jest.spyOn(integrationService, 'fetchIntegrations').mockResolvedValue({
|
||||
items: [
|
||||
{
|
||||
resourceType: 'integration',
|
||||
name: 'aws-oidc-1',
|
||||
kind: IntegrationKind.AwsOidc,
|
||||
spec: {
|
||||
roleArn: 'arn:aws:iam::123456789012:role/test1',
|
||||
issuerS3Bucket: '',
|
||||
issuerS3Prefix: '',
|
||||
},
|
||||
statusCode: IntegrationStatusCode.Running,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
jest
|
||||
.spyOn(ResourceService.prototype, 'fetchUnifiedResources')
|
||||
.mockResolvedValue({
|
||||
agents: [app],
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.restoreAllMocks();
|
||||
});
|
||||
|
||||
test('non application resource kind', async () => {
|
||||
const { ctx, discoverCtx } = getMockedContexts({
|
||||
kind: ResourceKind.Server,
|
||||
name: '',
|
||||
icon: undefined,
|
||||
keywords: '',
|
||||
event: DiscoverEventResource.Server,
|
||||
});
|
||||
|
||||
renderAwsAccount(ctx, discoverCtx);
|
||||
await screen.findByText(/aws Integrations/i);
|
||||
|
||||
expect(
|
||||
ResourceService.prototype.fetchUnifiedResources
|
||||
).not.toHaveBeenCalled();
|
||||
expect(integrationService.fetchIntegrations).toHaveBeenCalledTimes(1);
|
||||
expect(screen.getByRole('button', { name: /next/i })).toBeEnabled();
|
||||
});
|
||||
|
||||
test('with application resource kind for aws console', async () => {
|
||||
const { ctx, discoverCtx } = getMockedContexts({
|
||||
kind: ResourceKind.Application,
|
||||
appMeta: { awsConsole: true },
|
||||
name: '',
|
||||
icon: undefined,
|
||||
keywords: '',
|
||||
event: DiscoverEventResource.ApplicationHttp,
|
||||
});
|
||||
|
||||
renderAwsAccount(ctx, discoverCtx);
|
||||
await screen.findByText(/aws Integrations/i);
|
||||
|
||||
expect(ResourceService.prototype.fetchUnifiedResources).toHaveBeenCalledTimes(
|
||||
1
|
||||
);
|
||||
expect(integrationService.fetchIntegrations).toHaveBeenCalledTimes(1);
|
||||
expect(screen.getByRole('button', { name: /next/i })).toBeEnabled();
|
||||
});
|
||||
|
||||
test('missing permissions for integrations', async () => {
|
||||
const { ctx, discoverCtx } = getMockedContexts({
|
||||
kind: ResourceKind.Application,
|
||||
appMeta: { awsConsole: true },
|
||||
name: '',
|
||||
icon: undefined,
|
||||
keywords: '',
|
||||
event: DiscoverEventResource.ApplicationHttp,
|
||||
});
|
||||
|
||||
ctx.storeUser.state.acl = getAcl({ noAccess: true });
|
||||
|
||||
renderAwsAccount(ctx, discoverCtx);
|
||||
|
||||
expect(
|
||||
screen.getByText(/required permissions for integrating/i)
|
||||
).toBeInTheDocument();
|
||||
expect(screen.queryByText(/aws integrations/i)).not.toBeInTheDocument();
|
||||
|
||||
expect(
|
||||
ResourceService.prototype.fetchUnifiedResources
|
||||
).not.toHaveBeenCalled();
|
||||
expect(integrationService.fetchIntegrations).not.toHaveBeenCalled();
|
||||
|
||||
expect(
|
||||
screen.queryByRole('button', { name: /next/i })
|
||||
).not.toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: /back/i })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
function getMockedContexts(resourceSpec: ResourceSpec) {
|
||||
const ctx = createTeleportContext();
|
||||
const discoverCtx: DiscoverContextState = {
|
||||
agentMeta: {},
|
||||
currentStep: 0,
|
||||
nextStep: jest.fn(),
|
||||
prevStep: () => null,
|
||||
onSelectResource: () => null,
|
||||
resourceSpec: resourceSpec,
|
||||
exitFlow: () => null,
|
||||
viewConfig: null,
|
||||
indexedViews: [],
|
||||
setResourceSpec: () => null,
|
||||
updateAgentMeta: () => null,
|
||||
emitErrorEvent: () => null,
|
||||
emitEvent: jest.fn(),
|
||||
eventState: null,
|
||||
};
|
||||
|
||||
jest
|
||||
.spyOn(userEventService, 'captureDiscoverEvent')
|
||||
.mockResolvedValue(undefined as never);
|
||||
|
||||
return { ctx, discoverCtx };
|
||||
}
|
||||
|
||||
function renderAwsAccount(
|
||||
ctx: TeleportContext,
|
||||
discoverCtx: DiscoverContextState
|
||||
) {
|
||||
return render(
|
||||
<MemoryRouter
|
||||
initialEntries={[
|
||||
{ pathname: cfg.routes.discover, state: { entity: 'application' } },
|
||||
]}
|
||||
>
|
||||
<ContextProvider ctx={ctx}>
|
||||
<FeaturesContextProvider value={[]}>
|
||||
<DiscoverProvider mockCtx={discoverCtx}>
|
||||
<AwsAccount />
|
||||
</DiscoverProvider>
|
||||
</FeaturesContextProvider>
|
||||
</ContextProvider>
|
||||
</MemoryRouter>
|
||||
);
|
||||
}
|
||||
@@ -16,7 +16,7 @@
|
||||
* along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import React, { useEffect, useState, useCallback } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import {
|
||||
Box,
|
||||
@@ -28,12 +28,13 @@ import {
|
||||
Flex,
|
||||
} from 'design';
|
||||
import FieldSelect from 'shared/components/FieldSelect';
|
||||
import useAttempt from 'shared/hooks/useAttemptNext';
|
||||
import { useAsync } from 'shared/hooks/useAsync';
|
||||
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 { App } from 'teleport/services/apps';
|
||||
import cfg from 'teleport/config';
|
||||
import {
|
||||
Integration,
|
||||
@@ -44,8 +45,10 @@ import {
|
||||
integrationRWE,
|
||||
integrationRWEAndNodeRWE,
|
||||
integrationRWEAndDbCU,
|
||||
integrationAndAppRW,
|
||||
} from 'teleport/Discover/yamlTemplates';
|
||||
import useTeleport from 'teleport/useTeleport';
|
||||
import ResourceService from 'teleport/services/resources';
|
||||
|
||||
import {
|
||||
ActionButtons,
|
||||
@@ -59,7 +62,6 @@ import { DiscoverUrlLocationState, useDiscover } from '../../useDiscover';
|
||||
type Option = BaseOption<Integration>;
|
||||
|
||||
export function AwsAccount() {
|
||||
const { storeUser } = useTeleport();
|
||||
const {
|
||||
prevStep,
|
||||
nextStep,
|
||||
@@ -68,9 +70,35 @@ export function AwsAccount() {
|
||||
eventState,
|
||||
resourceSpec,
|
||||
currentStep,
|
||||
viewConfig,
|
||||
} = useDiscover();
|
||||
|
||||
// if true, requires an additional step where we fetch for
|
||||
// apps matching fetched aws integrations to determine
|
||||
// if an app already exists for the integration the user
|
||||
// will select
|
||||
const isAddingAwsApp =
|
||||
resourceSpec.kind === ResourceKind.Application &&
|
||||
resourceSpec.appMeta.awsConsole;
|
||||
|
||||
const { storeUser } = useTeleport();
|
||||
const clusterId = storeUser.getClusterId();
|
||||
|
||||
const [attempt, fetch] = useAsync(
|
||||
useCallback(async () => {
|
||||
const response = await fetchAwsIntegrationsWithApps(
|
||||
clusterId,
|
||||
isAddingAwsApp
|
||||
);
|
||||
// Auto select the only option.
|
||||
if (response.awsIntegrations.length === 1) {
|
||||
setSelectedAwsIntegration(
|
||||
makeAwsIntegrationOption(response.awsIntegrations[0])
|
||||
);
|
||||
}
|
||||
return response;
|
||||
}, [clusterId, isAddingAwsApp])
|
||||
);
|
||||
|
||||
const integrationAccess = storeUser.getIntegrationsAccess();
|
||||
|
||||
let roleTemplate = integrationRWE;
|
||||
@@ -81,12 +109,12 @@ export function AwsAccount() {
|
||||
integrationAccess.read;
|
||||
|
||||
// Ensure required permissions based on which flow this is in.
|
||||
if (viewConfig.kind === ResourceKind.Database) {
|
||||
if (resourceSpec.kind === ResourceKind.Database) {
|
||||
roleTemplate = integrationRWEAndDbCU;
|
||||
const databaseAccess = storeUser.getDatabaseAccess();
|
||||
hasAccess = hasAccess && databaseAccess.create; // required to enroll AWS RDS db
|
||||
}
|
||||
if (viewConfig.kind === ResourceKind.Server) {
|
||||
if (resourceSpec.kind === ResourceKind.Server) {
|
||||
roleTemplate = integrationRWEAndNodeRWE;
|
||||
const nodesAccess = storeUser.getNodeAccess();
|
||||
hasAccess =
|
||||
@@ -96,37 +124,27 @@ export function AwsAccount() {
|
||||
nodesAccess.list &&
|
||||
nodesAccess.read; // Needed for TestConnection flow
|
||||
}
|
||||
if (isAddingAwsApp) {
|
||||
roleTemplate = integrationAndAppRW;
|
||||
const appAccess = storeUser.getAppServerAccess();
|
||||
hasAccess =
|
||||
hasAccess &&
|
||||
// required to upsert app server
|
||||
appAccess.create &&
|
||||
appAccess.edit &&
|
||||
// required to list and read app servers
|
||||
appAccess.list &&
|
||||
appAccess.read;
|
||||
}
|
||||
|
||||
const { attempt, run } = useAttempt(hasAccess ? 'processing' : '');
|
||||
|
||||
const [awsIntegrations, setAwsIntegrations] = useState<Option[]>([]);
|
||||
const [selectedAwsIntegration, setSelectedAwsIntegration] =
|
||||
useState<Option>();
|
||||
|
||||
useEffect(() => {
|
||||
if (hasAccess) {
|
||||
fetchAwsIntegrations();
|
||||
if (hasAccess && attempt.status === '') {
|
||||
fetch();
|
||||
}
|
||||
}, []);
|
||||
|
||||
function fetchAwsIntegrations() {
|
||||
run(() =>
|
||||
integrationService.fetchIntegrations().then(res => {
|
||||
const options = res.items
|
||||
.filter(i => i.kind === 'aws-oidc')
|
||||
.map(i => ({
|
||||
value: i,
|
||||
label: i.name,
|
||||
}));
|
||||
setAwsIntegrations(options);
|
||||
|
||||
// Auto select the only option.
|
||||
if (options.length === 1) {
|
||||
setSelectedAwsIntegration(options[0]);
|
||||
}
|
||||
})
|
||||
);
|
||||
}
|
||||
}, [attempt.status, fetch, hasAccess]);
|
||||
|
||||
if (!hasAccess) {
|
||||
return (
|
||||
@@ -147,11 +165,12 @@ export function AwsAccount() {
|
||||
/>
|
||||
</Flex>
|
||||
</Box>
|
||||
<ActionButtons onPrev={prevStep} />
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
if (attempt.status === 'processing') {
|
||||
if (attempt.status === '' || attempt.status === 'processing') {
|
||||
return (
|
||||
<Box maxWidth="700px">
|
||||
<Heading />
|
||||
@@ -162,12 +181,12 @@ export function AwsAccount() {
|
||||
);
|
||||
}
|
||||
|
||||
if (attempt.status === 'failed') {
|
||||
if (attempt.status === 'error') {
|
||||
return (
|
||||
<Box maxWidth="700px">
|
||||
<Heading />
|
||||
<Alert kind="danger" children={attempt.statusText} />
|
||||
<ButtonPrimary mt={2} onClick={fetchAwsIntegrations}>
|
||||
<ButtonPrimary mt={2} onClick={fetch}>
|
||||
Retry
|
||||
</ButtonPrimary>
|
||||
</Box>
|
||||
@@ -179,6 +198,30 @@ export function AwsAccount() {
|
||||
return;
|
||||
}
|
||||
|
||||
if (
|
||||
isAddingAwsApp &&
|
||||
attempt.status === 'success' &&
|
||||
attempt.data.apps.length > 0
|
||||
) {
|
||||
// See if an application already exists for selected integration
|
||||
const foundApp = attempt.data.apps.find(
|
||||
app =>
|
||||
app.integration === selectedAwsIntegration.value.name &&
|
||||
app.awsConsole
|
||||
);
|
||||
if (foundApp) {
|
||||
updateAgentMeta({
|
||||
...agentMeta,
|
||||
awsIntegration: selectedAwsIntegration.value,
|
||||
app: foundApp,
|
||||
});
|
||||
// skips the next step (creating an app server)
|
||||
// since it already exists
|
||||
nextStep(2);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
updateAgentMeta({
|
||||
...agentMeta,
|
||||
awsIntegration: selectedAwsIntegration.value,
|
||||
@@ -187,6 +230,7 @@ export function AwsAccount() {
|
||||
nextStep();
|
||||
}
|
||||
|
||||
const { awsIntegrations } = attempt.data;
|
||||
const hasAwsIntegrations = awsIntegrations.length > 0;
|
||||
|
||||
// When a user clicks to create a new AWS integration, we
|
||||
@@ -225,7 +269,7 @@ export function AwsAccount() {
|
||||
isSimpleValue
|
||||
value={selectedAwsIntegration}
|
||||
onChange={i => setSelectedAwsIntegration(i as Option)}
|
||||
options={awsIntegrations}
|
||||
options={awsIntegrations.map(makeAwsIntegrationOption)}
|
||||
/>
|
||||
</Box>
|
||||
<ButtonText as={Link} to={locationState} pl={0}>
|
||||
@@ -257,6 +301,49 @@ export function AwsAccount() {
|
||||
);
|
||||
}
|
||||
|
||||
function makeAwsIntegrationOption(integration: Integration): Option {
|
||||
return {
|
||||
value: integration,
|
||||
label: integration.name,
|
||||
};
|
||||
}
|
||||
|
||||
async function fetchAwsIntegrationsWithApps(
|
||||
clusterId: string,
|
||||
isAddingAwsApp: boolean
|
||||
): Promise<{
|
||||
awsIntegrations: Integration[];
|
||||
apps: App[];
|
||||
}> {
|
||||
const integrationPage = await integrationService.fetchIntegrations();
|
||||
const awsIntegrations = integrationPage.items.filter(
|
||||
i => i.kind === 'aws-oidc'
|
||||
);
|
||||
if (!isAddingAwsApp || awsIntegrations.length === 0) {
|
||||
// Skip fetching for apps
|
||||
return { awsIntegrations, apps: [] };
|
||||
}
|
||||
|
||||
const resourceSvc = new ResourceService();
|
||||
// fetch for apps that match fetched integration names.
|
||||
// used later to determine if the integration user selected
|
||||
// already has an application created for it.
|
||||
const query = awsIntegrations
|
||||
.map(i => `resource.spec.integration == "${i.name}"`)
|
||||
.join(' || ');
|
||||
|
||||
const { agents: resources } = await resourceSvc.fetchUnifiedResources(
|
||||
clusterId,
|
||||
{
|
||||
query,
|
||||
limit: awsIntegrations.length,
|
||||
kinds: ['app'],
|
||||
sort: { fieldName: 'name', dir: 'ASC' },
|
||||
}
|
||||
);
|
||||
return { awsIntegrations, apps: resources as App[] };
|
||||
}
|
||||
|
||||
const Heading = () => (
|
||||
<>
|
||||
<Header>Connect to your AWS Account</Header>
|
||||
|
||||
@@ -21,7 +21,7 @@ import styled from 'styled-components';
|
||||
|
||||
import { Box, Text } from 'design';
|
||||
|
||||
const Container = styled(Box)`
|
||||
export const Container = styled(Box)`
|
||||
max-width: 1000px;
|
||||
background-color: ${props => props.theme.colors.spotBackground[0]};
|
||||
padding: ${props => `${props.theme.space[3]}px`};
|
||||
|
||||
@@ -21,7 +21,7 @@ import { useTheme } from 'styled-components';
|
||||
import ReactSelectCreatable from 'react-select/creatable';
|
||||
import { Cross } from 'design/Icon';
|
||||
|
||||
const styles = theme => ({
|
||||
export const styles = theme => ({
|
||||
multiValue: (base, state) => {
|
||||
return state.data.isFixed
|
||||
? { ...base, backgroundColor: `${theme.colors.spotBackground[2]}` }
|
||||
@@ -33,7 +33,7 @@ const styles = theme => ({
|
||||
}
|
||||
|
||||
if (state.isDisabled) {
|
||||
return { ...base, paddingRight: 6 };
|
||||
return { ...base, paddingRight: 6, color: theme.colors.text.main };
|
||||
}
|
||||
|
||||
return { ...base, color: theme.colors.text.primary };
|
||||
|
||||
@@ -28,19 +28,25 @@ import {
|
||||
connDiagRW,
|
||||
dbAccessRO,
|
||||
dbAccessRW,
|
||||
awsAppAccessRO,
|
||||
awsAppAccessRW,
|
||||
} from '../../yamlTemplates';
|
||||
|
||||
export function AccessInfo({ accessKind, traitKind, traitDesc }: Props) {
|
||||
let accessDesc = `${traitKind} ${traitDesc}`;
|
||||
if (traitKind === 'ARN') {
|
||||
accessDesc = `AWS Role ${traitKind}s`;
|
||||
}
|
||||
switch (accessKind) {
|
||||
case 'ssoUserAndNoTraits':
|
||||
return (
|
||||
<>
|
||||
<Info>
|
||||
You don’t have any {traitKind} {traitDesc} defined and SSO users are
|
||||
not able to add access.
|
||||
You don’t have any {accessDesc} defined and SSO users are not able
|
||||
to add access.
|
||||
<br />
|
||||
Please ask your Teleport administrator to update your role and add
|
||||
the required {traitKind} {traitDesc}.
|
||||
the required {accessDesc}.
|
||||
</Info>
|
||||
<YamlReader traitKind={traitKind} userAccessReadOnly={true} />
|
||||
</>
|
||||
@@ -49,7 +55,7 @@ export function AccessInfo({ accessKind, traitKind, traitDesc }: Props) {
|
||||
return (
|
||||
<>
|
||||
<Info>
|
||||
You don’t have {traitKind} access.
|
||||
You don't have permission to setup {accessDesc}
|
||||
<br />
|
||||
Please ask your Teleport administrator to update your role:
|
||||
</Info>
|
||||
@@ -60,10 +66,10 @@ export function AccessInfo({ accessKind, traitKind, traitDesc }: Props) {
|
||||
return (
|
||||
<>
|
||||
<Info>
|
||||
You don't have permission to add new {traitKind} {traitDesc}.
|
||||
You don't have permission to add new {accessDesc}.
|
||||
<br />
|
||||
If you don't see the {traitKind} {traitDesc} that you require,
|
||||
please ask your Teleport administrator to update your role:
|
||||
If you don't see the {accessDesc} that you require, please ask your
|
||||
Teleport administrator to update your role:
|
||||
</Info>
|
||||
<YamlReader traitKind={traitKind} />
|
||||
</>
|
||||
@@ -72,10 +78,10 @@ export function AccessInfo({ accessKind, traitKind, traitDesc }: Props) {
|
||||
return (
|
||||
<>
|
||||
<Info>
|
||||
SSO users are not able to add new {traitKind} {traitDesc}.
|
||||
SSO users are not able to add new {accessDesc}.
|
||||
<br />
|
||||
If you don't see the {traitKind} {traitDesc} that you require,
|
||||
please ask your Teleport administrator to update your role:
|
||||
If you don't see the {accessDesc} that you require, please ask your
|
||||
Teleport administrator to update your role:
|
||||
</Info>
|
||||
<YamlReader traitKind={traitKind} userAccessReadOnly={true} />
|
||||
</>
|
||||
@@ -136,6 +142,19 @@ export function YamlReader({
|
||||
<ReadOnlyYamlEditor content={connDiagRW} />
|
||||
</Flex>
|
||||
);
|
||||
case 'ARN':
|
||||
if (userAccessReadOnly) {
|
||||
return (
|
||||
<Flex minHeight="210px" mt={3}>
|
||||
<ReadOnlyYamlEditor content={awsAppAccessRO} />
|
||||
</Flex>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<Flex minHeight="310px" mt={3}>
|
||||
<ReadOnlyYamlEditor content={awsAppAccessRW} />
|
||||
</Flex>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -161,7 +180,7 @@ type AccessKind =
|
||||
| 'ssoUserAndNoTraits'
|
||||
| 'ssoUserButHasTraits';
|
||||
|
||||
export type TraitKind = 'Kubernetes' | 'OS' | 'ConnDiag' | 'Database';
|
||||
export type TraitKind = 'Kubernetes' | 'OS' | 'ConnDiag' | 'Database' | 'ARN';
|
||||
|
||||
type Props = {
|
||||
accessKind: AccessKind;
|
||||
|
||||
@@ -47,6 +47,8 @@ export type Props = {
|
||||
children: React.ReactNode;
|
||||
infoContent?: React.ReactNode;
|
||||
wantAutoDiscover?: boolean;
|
||||
/** A component below the header and above the main content. */
|
||||
preContent?: React.ReactNode;
|
||||
};
|
||||
|
||||
export function SetupAccessWrapper({
|
||||
@@ -62,6 +64,7 @@ export function SetupAccessWrapper({
|
||||
onPrev,
|
||||
children,
|
||||
infoContent,
|
||||
preContent,
|
||||
wantAutoDiscover = false,
|
||||
}: Props) {
|
||||
const canAddTraits = !isSsoUser && canEditUser;
|
||||
@@ -137,6 +140,7 @@ export function SetupAccessWrapper({
|
||||
<Box maxWidth="700px">
|
||||
<Header>Set Up Access</Header>
|
||||
<HeaderSubtitle>{headerSubtitle}</HeaderSubtitle>
|
||||
{preContent}
|
||||
<Box mb={3}>{$content}</Box>
|
||||
{infoContent}
|
||||
<ActionButtons
|
||||
|
||||
@@ -19,6 +19,7 @@
|
||||
import React from 'react';
|
||||
import { MemoryRouter } from 'react-router';
|
||||
import { renderHook, act, waitFor } from '@testing-library/react';
|
||||
import { AwsRole } from 'shared/services/apps';
|
||||
|
||||
import { createTeleportContext } from 'teleport/mocks/contexts';
|
||||
import { ContextProvider } from 'teleport';
|
||||
@@ -31,12 +32,14 @@ import {
|
||||
defaultResourceSpec,
|
||||
} from 'teleport/Discover/Fixtures/fixtures';
|
||||
import TeleportContext from 'teleport/teleportContext';
|
||||
import { app } from 'teleport/Discover/AwsMangementConsole/fixtures';
|
||||
|
||||
import { ResourceKind } from '../ResourceKind';
|
||||
|
||||
import { useUserTraits } from './useUserTraits';
|
||||
|
||||
import type {
|
||||
AppMeta,
|
||||
DbMeta,
|
||||
DiscoverContextState,
|
||||
KubeMeta,
|
||||
@@ -446,6 +449,99 @@ describe('onProceed correctly deduplicates, removes static traits, updates meta,
|
||||
]);
|
||||
});
|
||||
|
||||
test('only update user traits with dynamic awsRoleArns', async () => {
|
||||
const staticAwsRoles: AwsRole[] = [
|
||||
{
|
||||
name: 'static-arn1',
|
||||
arn: 'arn:aws:iam::123456789012:role/static-arn1',
|
||||
display: 'static-arn1',
|
||||
accountId: '123456789012',
|
||||
},
|
||||
{
|
||||
name: 'static-arn2',
|
||||
arn: 'arn:aws:iam::123456789012:role/static-arn2',
|
||||
display: 'static-arn2',
|
||||
accountId: '123456789012',
|
||||
},
|
||||
];
|
||||
const discoverCtx = defaultDiscoverContext({
|
||||
resourceSpec: {
|
||||
...defaultResourceSpec(ResourceKind.Application),
|
||||
appMeta: { awsConsole: true },
|
||||
},
|
||||
agentMeta: {
|
||||
app: {
|
||||
...app,
|
||||
awsRoles: staticAwsRoles,
|
||||
},
|
||||
},
|
||||
});
|
||||
const spyUpdateAgentMeta = jest
|
||||
.spyOn(discoverCtx, 'updateAgentMeta')
|
||||
.mockImplementation(x => x);
|
||||
|
||||
const { result } = renderHook(() => useUserTraits(), {
|
||||
wrapper: wrapperFn(discoverCtx, teleCtx),
|
||||
});
|
||||
|
||||
await waitFor(() =>
|
||||
expect(result.current.staticTraits.awsRoleArns.length).toBeGreaterThan(0)
|
||||
);
|
||||
|
||||
const dynamicTraits = result.current.dynamicTraits;
|
||||
const staticTraits = result.current.staticTraits;
|
||||
const mockedSelectedOptions = {
|
||||
awsRoleArns: [
|
||||
{
|
||||
isFixed: false,
|
||||
label: staticTraits.awsRoleArns[0],
|
||||
value: staticTraits.awsRoleArns[0],
|
||||
},
|
||||
{
|
||||
isFixed: false,
|
||||
label: dynamicTraits.awsRoleArns[0],
|
||||
value: dynamicTraits.awsRoleArns[0],
|
||||
},
|
||||
// duplicate
|
||||
{
|
||||
isFixed: false,
|
||||
label: dynamicTraits.awsRoleArns[0],
|
||||
value: dynamicTraits.awsRoleArns[0],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
act(() => {
|
||||
result.current.onProceed(mockedSelectedOptions);
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(teleCtx.userService.reloadUser).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
// Test that we are updating the user with the correct traits.
|
||||
const mockUser = getMockUser();
|
||||
expect(teleCtx.userService.updateUser).toHaveBeenCalledWith({
|
||||
...mockUser,
|
||||
traits: {
|
||||
...mockUser.traits,
|
||||
awsRoleArns: [dynamicTraits.awsRoleArns[0]],
|
||||
},
|
||||
});
|
||||
|
||||
// Test that app's awsRoles field got updated with the dynamic trait.
|
||||
const updatedMeta = spyUpdateAgentMeta.mock.results[0].value as AppMeta;
|
||||
expect(updatedMeta.app.awsRoles).toStrictEqual([
|
||||
...staticAwsRoles,
|
||||
{
|
||||
name: 'dynamicArn1',
|
||||
display: 'dynamicArn1',
|
||||
arn: 'arn:aws:iam::123456789012:role/dynamicArn1',
|
||||
accountId: '123456789012',
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
test('node with auto discover preserves existing + new dynamic traits', async () => {
|
||||
const discoverCtx = defaultDiscoverContext({
|
||||
resourceSpec: defaultResourceSpec(ResourceKind.Server),
|
||||
@@ -639,7 +735,10 @@ function getMockUser() {
|
||||
kubeUsers: ['dynamicKbUser1', 'dynamicKbUser2'],
|
||||
kubeGroups: ['dynamicKbGroup1', 'dynamicKbGroup2'],
|
||||
windowsLogins: [],
|
||||
awsRoleArns: [],
|
||||
awsRoleArns: [
|
||||
'arn:aws:iam::123456789012:role/dynamicArn1',
|
||||
'arn:aws:iam::123456789012:role/dynamicArn2',
|
||||
],
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -23,10 +23,16 @@ import { arrayStrDiff } from 'teleport/lib/util';
|
||||
import useTeleport from 'teleport/useTeleport';
|
||||
import { Option } from 'teleport/Discover/Shared/SelectCreatable';
|
||||
import { useDiscover } from 'teleport/Discover/useDiscover';
|
||||
import { splitAwsIamArn } from 'teleport/services/integrations/aws';
|
||||
|
||||
import { ResourceKind } from '../ResourceKind';
|
||||
|
||||
import type { DbMeta, KubeMeta, NodeMeta } from 'teleport/Discover/useDiscover';
|
||||
import type {
|
||||
AppMeta,
|
||||
DbMeta,
|
||||
KubeMeta,
|
||||
NodeMeta,
|
||||
} from 'teleport/Discover/useDiscover';
|
||||
import type { User, UserTraits } from 'teleport/services/user';
|
||||
|
||||
// useUserTraits handles:
|
||||
@@ -98,10 +104,22 @@ export function useUserTraits() {
|
||||
}
|
||||
break;
|
||||
|
||||
default:
|
||||
// Note: specific to AWS CLI access
|
||||
case ResourceKind.Application:
|
||||
if (resourceSpec.appMeta?.awsConsole) {
|
||||
const { awsRoles } = (agentMeta as AppMeta).app;
|
||||
staticTraits.awsRoleArns = arrayStrDiff(
|
||||
awsRoles.map(r => r.arn),
|
||||
dynamicTraits.awsRoleArns
|
||||
);
|
||||
break;
|
||||
}
|
||||
throw new Error(
|
||||
`useUserTraits.ts:statiTraits: resource kind ${resourceSpec.kind} is not handled`
|
||||
`resource kind is application, but there is no handler defined`
|
||||
);
|
||||
|
||||
default:
|
||||
throw new Error(`resource kind ${resourceSpec.kind} is not handled`);
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
@@ -186,10 +204,28 @@ export function useUserTraits() {
|
||||
);
|
||||
break;
|
||||
|
||||
default:
|
||||
case ResourceKind.Application:
|
||||
if (resourceSpec.appMeta?.awsConsole) {
|
||||
let newDynamicArns = new Set<string>();
|
||||
traitOpts.awsRoleArns.forEach(o => {
|
||||
if (!staticTraits.awsRoleArns.includes(o.value)) {
|
||||
newDynamicArns.add(o.value);
|
||||
}
|
||||
});
|
||||
|
||||
nextStep(
|
||||
{
|
||||
awsRoleArns: [...newDynamicArns],
|
||||
},
|
||||
numStepsToIncrement
|
||||
);
|
||||
break;
|
||||
}
|
||||
throw new Error(
|
||||
`useUserTrait.ts:onProceed: resource kind ${resourceSpec.kind} is not handled`
|
||||
`resource kind is application, but there is no handler defined`
|
||||
);
|
||||
default:
|
||||
throw new Error(`resource kind ${resourceSpec.kind} is not handled`);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -244,10 +280,37 @@ export function useUserTraits() {
|
||||
});
|
||||
break;
|
||||
|
||||
default:
|
||||
case ResourceKind.Application:
|
||||
if (resourceSpec.appMeta?.awsConsole) {
|
||||
const app = (meta as AppMeta).app;
|
||||
const arns = [
|
||||
...staticTraits.awsRoleArns,
|
||||
...newDynamicTraits.awsRoleArns,
|
||||
];
|
||||
const awsRoles = arns.map(arn => {
|
||||
const { arnResourceName, awsAccountId } = splitAwsIamArn(arn);
|
||||
return {
|
||||
name: arnResourceName,
|
||||
arn,
|
||||
display: arnResourceName,
|
||||
accountId: awsAccountId,
|
||||
};
|
||||
});
|
||||
updateAgentMeta({
|
||||
...meta,
|
||||
app: {
|
||||
...app,
|
||||
awsRoles,
|
||||
},
|
||||
});
|
||||
break;
|
||||
}
|
||||
throw new Error(
|
||||
`useUserTraits.ts:updateResourceMetaDynamicTraits: resource kind ${resourceSpec.kind} is not handled`
|
||||
`resource kind is application, but there is no handler defined`
|
||||
);
|
||||
|
||||
default:
|
||||
throw new Error(`resource kind ${resourceSpec.kind} is not handled`);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -17,3 +17,6 @@
|
||||
*/
|
||||
|
||||
export const WILD_CARD = '*';
|
||||
|
||||
export const AWS_TAG_INFO_LINK =
|
||||
'https://docs.aws.amazon.com/IAM/latest/UserGuide/id_tags_roles.html#id_tags_roles_procs-console';
|
||||
|
||||
@@ -17,6 +17,7 @@
|
||||
*/
|
||||
|
||||
import { ServerResource } from 'teleport/Discover/Server';
|
||||
import { AwsMangementConsole } from 'teleport/Discover/AwsMangementConsole';
|
||||
import { DatabaseResource } from 'teleport/Discover/Database';
|
||||
import { KubernetesResource } from 'teleport/Discover/Kubernetes';
|
||||
import { DesktopResource } from 'teleport/Discover/Desktop';
|
||||
@@ -25,6 +26,7 @@ import { ConnectMyComputerResource } from 'teleport/Discover/ConnectMyComputer';
|
||||
import { ResourceViewConfig } from './flow';
|
||||
|
||||
export const viewConfigs: ResourceViewConfig[] = [
|
||||
AwsMangementConsole,
|
||||
ServerResource,
|
||||
DatabaseResource,
|
||||
KubernetesResource,
|
||||
|
||||
@@ -42,6 +42,7 @@ import { EViewConfigs } from './types';
|
||||
import { ServiceDeployMethod } from './Database/common';
|
||||
|
||||
import type { Node } from 'teleport/services/nodes';
|
||||
import type { App } from 'teleport/services/apps';
|
||||
import type { Kube } from 'teleport/services/kube';
|
||||
import type { Database } from 'teleport/services/databases';
|
||||
import type { ResourceLabel } from 'teleport/services/agents';
|
||||
@@ -522,12 +523,22 @@ export type KubeMeta = BaseMeta & {
|
||||
kube: Kube;
|
||||
};
|
||||
|
||||
// KubeMeta describes the fields for a kube resource
|
||||
// that needs to be preserved throughout the flow.
|
||||
/**
|
||||
* EksMeta describes the fields for a kube resource
|
||||
* that needs to be preserved throughout the flow.
|
||||
*/
|
||||
export type EksMeta = BaseMeta & {
|
||||
kube: Kube;
|
||||
};
|
||||
|
||||
/**
|
||||
* AppMeta describes the fields for a app resource
|
||||
* that needs to be preserved throughout the flow.
|
||||
*/
|
||||
export type AppMeta = BaseMeta & {
|
||||
app: App;
|
||||
};
|
||||
|
||||
// SamlMeta describes the fields for SAML IdP
|
||||
// service provider resource that needs to be
|
||||
// preserved throughout the flow.
|
||||
@@ -543,6 +554,12 @@ export type SamlGcpWorkforceMeta = {
|
||||
poolProviderName: string;
|
||||
};
|
||||
|
||||
export type AgentMeta = DbMeta | NodeMeta | KubeMeta | EksMeta | SamlMeta;
|
||||
export type AgentMeta =
|
||||
| DbMeta
|
||||
| NodeMeta
|
||||
| KubeMeta
|
||||
| EksMeta
|
||||
| SamlMeta
|
||||
| AppMeta;
|
||||
|
||||
export type State = ReturnType<typeof useDiscover>;
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
kind: role
|
||||
spec:
|
||||
allow:
|
||||
# app_labels: a user with this role will be allowed to connect to
|
||||
# applications with labels matching below.
|
||||
app_labels:
|
||||
'*': '*'
|
||||
aws_role_arns:
|
||||
# Define AWS role ARNS for this role:
|
||||
- 'arn:aws:iam::<AWS_ACCOUNT_ID>:role/<NAME_OF_ROLE>'
|
||||
@@ -0,0 +1,16 @@
|
||||
kind: role
|
||||
spec:
|
||||
allow:
|
||||
# app_labels: a user with this role will be allowed to connect to
|
||||
# applications with labels matching below.
|
||||
app_labels:
|
||||
'*': '*'
|
||||
aws_role_arns:
|
||||
# Define AWS role ARNS for this role:
|
||||
- 'arn:aws:iam::<AWS_ACCOUNT_ID>:role/<NAME_OF_ROLE>'
|
||||
rules:
|
||||
# Rule that allows users to define their own ARN's
|
||||
- resources:
|
||||
- user
|
||||
verbs:
|
||||
- update
|
||||
@@ -27,6 +27,9 @@ import dbCU from './dbCU.yaml?raw';
|
||||
import integrationRWE from './integrationRWE.yaml?raw';
|
||||
import integrationRWEAndDbCU from './integrationRWEAndDbCU.yaml?raw';
|
||||
import integrationRWEAndNodeRWE from './integrationRWEAndNodeRWE.yaml?raw';
|
||||
import integrationAndAppRW from './integrationAndAppRW.yaml?raw';
|
||||
import awsAppAccessRW from './awsAppAccessRW.yaml?raw';
|
||||
import awsAppAccessRO from './awsAppAccessRO.yaml?raw';
|
||||
|
||||
export {
|
||||
nodeAccessRO,
|
||||
@@ -40,4 +43,7 @@ export {
|
||||
integrationRWE,
|
||||
integrationRWEAndDbCU,
|
||||
integrationRWEAndNodeRWE,
|
||||
integrationAndAppRW,
|
||||
awsAppAccessRW,
|
||||
awsAppAccessRO,
|
||||
};
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
kind: role
|
||||
spec:
|
||||
allow:
|
||||
# app_labels: a user with this role will be allowed to connect to
|
||||
# applications with labels matching below.
|
||||
app_labels:
|
||||
'*': '*'
|
||||
rules:
|
||||
- resources:
|
||||
- integration
|
||||
verbs:
|
||||
- list
|
||||
- create
|
||||
- use
|
||||
- read
|
||||
- resources:
|
||||
- app_server
|
||||
verbs:
|
||||
- create
|
||||
- update
|
||||
- list
|
||||
- read
|
||||
@@ -19,6 +19,7 @@
|
||||
import cfg, {
|
||||
UrlDeployServiceIamConfigureScriptParams,
|
||||
UrlAwsOidcConfigureIdp,
|
||||
UrlAwsConfigureIamScriptParams,
|
||||
} from './config';
|
||||
|
||||
test('getDeployServiceIamConfigureScriptPath formatting', async () => {
|
||||
@@ -63,3 +64,15 @@ test('getAwsOidcConfigureIdpScriptUrl formatting, without s3 fields', async () =
|
||||
`${base}${expected}`
|
||||
);
|
||||
});
|
||||
|
||||
test('getAwsIamConfigureScriptAppAccessUrl formatting', async () => {
|
||||
const params: Omit<UrlAwsConfigureIamScriptParams, 'region'> = {
|
||||
iamRoleName: 'role-arn',
|
||||
};
|
||||
const base =
|
||||
'http://localhost/v1/webapi/scripts/integrations/configure/aws-app-access-iam.sh?';
|
||||
const expected = `role=role-arn`;
|
||||
expect(cfg.getAwsIamConfigureScriptAppAccessUrl(params)).toBe(
|
||||
`${base}${expected}`
|
||||
);
|
||||
});
|
||||
|
||||
@@ -318,6 +318,11 @@ const cfg = {
|
||||
awsSecurityGroupsListPath:
|
||||
'/v1/webapi/sites/:clusterId/integrations/aws-oidc/:name/securitygroups',
|
||||
|
||||
awsAppAccessPath:
|
||||
'/v1/webapi/sites/:clusterId/integrations/aws-oidc/:name/aws-app-access',
|
||||
awsConfigureIamAppAccessPath:
|
||||
'/v1/webapi/scripts/integrations/configure/aws-app-access-iam.sh?role=:iamRoleName',
|
||||
|
||||
eksClustersListPath:
|
||||
'/v1/webapi/sites/:clusterId/integrations/aws-oidc/:name/eksclusters',
|
||||
eksEnrollClustersPath:
|
||||
@@ -916,6 +921,15 @@ const cfg = {
|
||||
});
|
||||
},
|
||||
|
||||
getAwsAppAccessUrl(integrationName: string) {
|
||||
const clusterId = cfg.proxyCluster;
|
||||
|
||||
return generatePath(cfg.api.awsAppAccessPath, {
|
||||
clusterId,
|
||||
name: integrationName,
|
||||
});
|
||||
},
|
||||
|
||||
getUserGroupsListUrl(clusterId: string, params: UrlResourcesParams) {
|
||||
return generateResourcePath(cfg.api.userGroupsListPath, {
|
||||
clusterId,
|
||||
@@ -1080,6 +1094,17 @@ const cfg = {
|
||||
);
|
||||
},
|
||||
|
||||
getAwsIamConfigureScriptAppAccessUrl(
|
||||
params: Omit<UrlAwsConfigureIamScriptParams, 'region'>
|
||||
) {
|
||||
return (
|
||||
cfg.baseUrl +
|
||||
generatePath(cfg.api.awsConfigureIamAppAccessPath, {
|
||||
...params,
|
||||
})
|
||||
);
|
||||
},
|
||||
|
||||
getAwsConfigureIamScriptListDatabasesUrl(
|
||||
params: UrlAwsConfigureIamScriptParams
|
||||
) {
|
||||
|
||||
@@ -47,6 +47,7 @@ test('correct formatting of apps fetch response', async () => {
|
||||
userGroups: [],
|
||||
samlApp: false,
|
||||
samlAppSsoUrl: '',
|
||||
integration: '',
|
||||
},
|
||||
{
|
||||
kind: 'app',
|
||||
@@ -67,6 +68,7 @@ test('correct formatting of apps fetch response', async () => {
|
||||
userGroups: [],
|
||||
samlApp: false,
|
||||
samlAppSsoUrl: '',
|
||||
integration: '',
|
||||
},
|
||||
{
|
||||
kind: 'app',
|
||||
@@ -87,6 +89,7 @@ test('correct formatting of apps fetch response', async () => {
|
||||
userGroups: [],
|
||||
samlApp: false,
|
||||
samlAppSsoUrl: '',
|
||||
integration: '',
|
||||
},
|
||||
{
|
||||
kind: 'app',
|
||||
@@ -107,6 +110,7 @@ test('correct formatting of apps fetch response', async () => {
|
||||
userGroups: [],
|
||||
samlApp: true,
|
||||
samlAppSsoUrl: 'http://localhost/enterprise/saml-idp/login/saml-app',
|
||||
integration: '',
|
||||
},
|
||||
],
|
||||
startKey: mockResponse.startKey,
|
||||
|
||||
@@ -20,9 +20,7 @@ import api from 'teleport/services/api';
|
||||
import cfg, { UrlAppParams, UrlResourcesParams } from 'teleport/config';
|
||||
import { ResourcesResponse } from 'teleport/services/agents';
|
||||
|
||||
import auth from 'teleport/services/auth/auth';
|
||||
|
||||
import { MfaChallengeScope } from 'teleport/services/auth/auth';
|
||||
import auth, { MfaChallengeScope } from 'teleport/services/auth/auth';
|
||||
|
||||
import makeApp from './makeApps';
|
||||
import { App } from './types';
|
||||
|
||||
@@ -16,6 +16,8 @@
|
||||
* along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import { AwsRole } from 'shared/services/apps';
|
||||
|
||||
import cfg from 'teleport/config';
|
||||
|
||||
import { App } from './types';
|
||||
@@ -33,6 +35,7 @@ export default function makeApp(json: any): App {
|
||||
samlApp = false,
|
||||
friendlyName = '',
|
||||
requiresRequest,
|
||||
integration = '',
|
||||
} = json;
|
||||
|
||||
const canCreateUrl = fqdn && clusterId && publicAddr;
|
||||
@@ -41,7 +44,7 @@ export default function makeApp(json: any): App {
|
||||
: '';
|
||||
const id = `${clusterId}-${name}-${publicAddr || uri}`;
|
||||
const labels = json.labels || [];
|
||||
const awsRoles = json.awsRoles || [];
|
||||
const awsRoles: AwsRole[] = json.awsRoles || [];
|
||||
const userGroups = json.userGroups || [];
|
||||
|
||||
const isTcp = uri && uri.startsWith('tcp://');
|
||||
@@ -83,5 +86,6 @@ export default function makeApp(json: any): App {
|
||||
samlApp,
|
||||
samlAppSsoUrl,
|
||||
requiresRequest,
|
||||
integration,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -44,6 +44,9 @@ export interface App {
|
||||
samlApp: boolean;
|
||||
// samlAppSsoUrl is the URL that triggers IdP-initiated SSO for SAML Application;
|
||||
samlAppSsoUrl?: string;
|
||||
// Integration is the integration name that must be used to access this Application.
|
||||
// Only applicable to AWS App Access.
|
||||
integration?: string;
|
||||
}
|
||||
|
||||
export type UserGroupAndDescription = {
|
||||
|
||||
@@ -20,6 +20,17 @@ export const AWS_IAM_ARN_DEFAULT_PARTITION = 'arn:aws:iam::';
|
||||
export const AWS_IAM_ARN_CHINA_PARTITION = 'arn:aws-cn:iam::';
|
||||
export const AWS_IAM_ARN_USGOV_PARTITION = 'arn:aws-us-gov:iam::';
|
||||
|
||||
/**
|
||||
* 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
|
||||
*
|
||||
* The regex checks for alphanumerics and select few characters.
|
||||
*/
|
||||
export const IAM_ROLE_NAME_REGEX = /^[\w+=,.@-]+$/;
|
||||
|
||||
export const IAM_ROLE_ARN_REGEX =
|
||||
/^arn:(aws|aws-cn|aws-us-gov):iam::\d{12}:role\/[\w+=,.@-]+$/;
|
||||
|
||||
/**
|
||||
* @returns
|
||||
* - awsAccountId: the 12 digit aws account Id
|
||||
|
||||
@@ -20,8 +20,9 @@ import api from 'teleport/services/api';
|
||||
import cfg from 'teleport/config';
|
||||
|
||||
import makeNode from '../nodes/makeNode';
|
||||
|
||||
import auth from '../auth/auth';
|
||||
import { App } from '../apps';
|
||||
import makeApp from '../apps/makeApps';
|
||||
|
||||
import {
|
||||
Integration,
|
||||
@@ -161,6 +162,12 @@ export const integrationService = {
|
||||
.then(resp => resp.serviceDashboardUrl);
|
||||
},
|
||||
|
||||
async createAwsAppAccess(integrationName): Promise<App> {
|
||||
return api
|
||||
.post(cfg.getAwsAppAccessUrl(integrationName), null)
|
||||
.then(makeApp);
|
||||
},
|
||||
|
||||
async deployDatabaseServices(
|
||||
integrationName,
|
||||
req: AwsOidcDeployDatabaseServicesRequest
|
||||
|
||||
Reference in New Issue
Block a user