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:
Lisa Kim
2024-05-21 19:07:17 +00:00
committed by GitHub
parent d150568dbb
commit 1588fe939d
41 changed files with 2123 additions and 75 deletions
+4
View File
@@ -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)
@@ -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>
);
}
@@ -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>
);
};
@@ -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>
);
}
@@ -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"}
}
}
]
}`;
@@ -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 };
};
@@ -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>
);
};
@@ -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) {
@@ -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
+13
View File
@@ -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}`
);
});
+25
View File
@@ -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