mirror of
https://github.com/gravitational/teleport.git
synced 2026-09-19 10:37:19 +08:00
Access Requests: Web UI handling for Resource-Scoped Constraints (#62152)
* feat: Implement Resource Constraints for Web * fixup: Address code review feedback * test: Add stories for Resource Constraint RequestCheckout changes * fixup: Address code review feedback
This commit is contained in:
@@ -80,7 +80,13 @@ export const ButtonSelect = <T extends readonly Option<OptionValue>[]>({
|
||||
{options.map(option => {
|
||||
const isActive = activeValue === option.value;
|
||||
return (
|
||||
<HoverTooltip tipContent={option.tooltip} key={option.label}>
|
||||
<HoverTooltip
|
||||
tipContent={option.tooltip}
|
||||
key={option.label}
|
||||
css={`
|
||||
flex: 1 1 0;
|
||||
`}
|
||||
>
|
||||
<ButtonSelectButton
|
||||
aria-label={option.label}
|
||||
aria-checked={isActive}
|
||||
|
||||
@@ -110,7 +110,7 @@ export default function Table<T>(props: TableProps<T>) {
|
||||
) {
|
||||
return <LoadingIndicator colSpan={columns.length} />;
|
||||
}
|
||||
data.map((item, rowIdx) => {
|
||||
data.forEach((item, rowIdx) => {
|
||||
const TableRow: React.FC<PropsWithChildren> = ({ children }) => (
|
||||
<tr
|
||||
key={rowIdx}
|
||||
@@ -122,29 +122,36 @@ export default function Table<T>(props: TableProps<T>) {
|
||||
);
|
||||
|
||||
const customRow = row?.customRow?.(item);
|
||||
const renderAfter = row?.renderAfter?.(item);
|
||||
|
||||
if (customRow) {
|
||||
rows.push(<TableRow key={rowIdx}>{customRow}</TableRow>);
|
||||
return;
|
||||
} else {
|
||||
const cells = columns.flatMap((column, columnIdx) => {
|
||||
if (column.isNonRender) {
|
||||
return []; // does not include this column.
|
||||
}
|
||||
|
||||
const $cell = column.render ? (
|
||||
column.render(item)
|
||||
) : (
|
||||
<TextCell data={column.key ? item[column.key] : undefined} />
|
||||
);
|
||||
|
||||
return (
|
||||
<React.Fragment key={`${rowIdx} ${columnIdx}`}>
|
||||
{$cell}
|
||||
</React.Fragment>
|
||||
);
|
||||
});
|
||||
rows.push(<TableRow key={rowIdx}>{cells}</TableRow>);
|
||||
}
|
||||
|
||||
const cells = columns.flatMap((column, columnIdx) => {
|
||||
if (column.isNonRender) {
|
||||
return []; // does not include this column.
|
||||
}
|
||||
|
||||
const $cell = column.render ? (
|
||||
column.render(item)
|
||||
) : (
|
||||
<TextCell data={column.key ? item[column.key] : undefined} />
|
||||
if (renderAfter) {
|
||||
rows.push(
|
||||
<React.Fragment key={`${rowIdx}-after`}>{renderAfter}</React.Fragment>
|
||||
);
|
||||
|
||||
return (
|
||||
<React.Fragment key={`${rowIdx} ${columnIdx}`}>
|
||||
{$cell}
|
||||
</React.Fragment>
|
||||
);
|
||||
});
|
||||
rows.push(<TableRow key={rowIdx}>{cells}</TableRow>);
|
||||
}
|
||||
});
|
||||
|
||||
if (rows.length) {
|
||||
|
||||
@@ -92,6 +92,11 @@ export type TableProps<T> = {
|
||||
* dropdown selector.
|
||||
*/
|
||||
customRow?(row: T): JSX.Element;
|
||||
/**
|
||||
* conditionally render a custom row after either `customRow` or
|
||||
* the base table row.
|
||||
*/
|
||||
renderAfter?(row: T): JSX.Element | null;
|
||||
};
|
||||
};
|
||||
|
||||
|
||||
+48
-1
@@ -22,7 +22,12 @@ import { Link, MemoryRouter } from 'react-router-dom';
|
||||
import { Box, ButtonPrimary, ButtonText } from 'design';
|
||||
import { UNSUPPORTED_KINDS } from 'shared/components/AccessRequests/NewRequest/RequestCheckout/LongTerm';
|
||||
import { Option } from 'shared/components/Select';
|
||||
import { AccessRequest, RequestKind } from 'shared/services/accessRequests';
|
||||
import {
|
||||
AccessRequest,
|
||||
getResourceIDString,
|
||||
RequestKind,
|
||||
ResourceConstraintsMap,
|
||||
} from 'shared/services/accessRequests';
|
||||
|
||||
import { dryRunResponse } from '../../fixtures';
|
||||
import { useSpecifiableFields } from '../useSpecifiableFields';
|
||||
@@ -73,6 +78,7 @@ export const Loaded = () => {
|
||||
</MemoryRouter>
|
||||
);
|
||||
};
|
||||
|
||||
export const Empty = () => {
|
||||
const [selectedReviewers, setSelectedReviewers] = useState([]);
|
||||
const [maxDuration, setMaxDuration] = useState<Option<number>>();
|
||||
@@ -130,6 +136,45 @@ export const LoadedResourceRequest = () => {
|
||||
);
|
||||
};
|
||||
|
||||
export const LoadedResourceRequestWithConstraints = () => {
|
||||
const pendingAccessRequests = [
|
||||
{
|
||||
kind: 'app',
|
||||
id: 'aws-console',
|
||||
name: 'AWS Console App',
|
||||
clusterName: 'localhost',
|
||||
},
|
||||
] satisfies RequestCheckoutWithSliderProps['pendingAccessRequests'];
|
||||
const addedResourceConstraints = {
|
||||
[getResourceIDString({
|
||||
kind: 'app',
|
||||
name: 'aws-console',
|
||||
cluster: 'localhost',
|
||||
})]: {
|
||||
aws_console: {
|
||||
role_arns: [
|
||||
'arn:aws:iam::123456789012:role/Viewer',
|
||||
'arn:aws:iam::123456789012:role/Admin',
|
||||
'arn:aws:iam::123456789012:role/DevOps',
|
||||
],
|
||||
},
|
||||
},
|
||||
} satisfies ResourceConstraintsMap;
|
||||
|
||||
return (
|
||||
<MemoryRouter>
|
||||
<RequestCheckoutWithSlider
|
||||
{...baseProps}
|
||||
isResourceRequest={true}
|
||||
fetchResourceRequestRolesAttempt={{ status: 'success' }}
|
||||
pendingAccessRequests={pendingAccessRequests}
|
||||
addedResourceConstraints={addedResourceConstraints}
|
||||
setResourceConstraints={() => {}}
|
||||
/>
|
||||
</MemoryRouter>
|
||||
);
|
||||
};
|
||||
|
||||
export const LoadedLongTermRequest = () => {
|
||||
const dryRunResponseWithLongTerm = {
|
||||
...dryRunResponse,
|
||||
@@ -408,4 +453,6 @@ const baseProps: RequestCheckoutWithSliderProps = {
|
||||
requestKind: RequestKind.ShortTerm,
|
||||
setRequestKind: () => null,
|
||||
onStartTimeChange: () => null,
|
||||
addedResourceConstraints: {},
|
||||
setResourceConstraints: () => null,
|
||||
};
|
||||
|
||||
+2
@@ -175,4 +175,6 @@ const props: RequestCheckoutWithSliderProps = {
|
||||
onStartTimeChange: () => null,
|
||||
fetchKubeNamespaces: () => null,
|
||||
updateNamespacesForKubeCluster: () => null,
|
||||
addedResourceConstraints: {},
|
||||
setResourceConstraints: () => null,
|
||||
};
|
||||
|
||||
+169
-8
@@ -48,7 +48,13 @@ import {
|
||||
} from 'design';
|
||||
import { Danger } from 'design/Alert';
|
||||
import Table, { Cell } from 'design/DataTable';
|
||||
import { ArrowBack, ChevronDown, ChevronRight, Warning } from 'design/Icon';
|
||||
import {
|
||||
ArrowBack,
|
||||
ChevronDown,
|
||||
ChevronRight,
|
||||
Cross,
|
||||
Warning,
|
||||
} from 'design/Icon';
|
||||
import { HoverTooltip } from 'design/Tooltip';
|
||||
import {
|
||||
LongTermGroupingErrors,
|
||||
@@ -56,13 +62,26 @@ import {
|
||||
UNSUPPORTED_KINDS,
|
||||
} from 'shared/components/AccessRequests/NewRequest/RequestCheckout/LongTerm';
|
||||
import { RequestableResourceKind } from 'shared/components/AccessRequests/NewRequest/resource';
|
||||
import {
|
||||
formatAWSRoleARNForDisplay,
|
||||
toggleAWSConsoleConstraint,
|
||||
} from 'shared/components/AccessRequests/Shared/utils';
|
||||
import { FieldCheckbox } from 'shared/components/FieldCheckbox';
|
||||
import { Option } from 'shared/components/Select';
|
||||
import { TextSelectCopyMulti } from 'shared/components/TextSelectCopy';
|
||||
import Validation, { useRule, Validator } from 'shared/components/Validation';
|
||||
import { Attempt } from 'shared/hooks/useAttemptNext';
|
||||
import { mergeRefs } from 'shared/libs/mergeRefs';
|
||||
import { AccessRequest, RequestKind } from 'shared/services/accessRequests';
|
||||
import {
|
||||
AccessRequest,
|
||||
getResourceIDString,
|
||||
hasResourceConstraints,
|
||||
RequestKind,
|
||||
ResourceConstraints,
|
||||
ResourceConstraintsMap,
|
||||
ResourceIDString,
|
||||
WithResourceConstraints,
|
||||
} from 'shared/services/accessRequests';
|
||||
import { pluralize } from 'shared/utils/text';
|
||||
|
||||
import { AccessDurationRequest } from '../../AccessDuration';
|
||||
@@ -148,6 +167,87 @@ export const RequestCheckoutWithSlider = forwardRef<
|
||||
}
|
||||
);
|
||||
|
||||
type DisplayRow<T extends PendingListItem> = T & {
|
||||
constraints?: ResourceConstraints;
|
||||
};
|
||||
|
||||
const StyledAWSRoleARNDisplayRow = styled(Flex).attrs({
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'space-between',
|
||||
gap: 2,
|
||||
})<{ $idx: number; $len: number }>`
|
||||
border-radius: ${({ theme }) => theme.radii[1]}px;
|
||||
border-bottom: ${({ theme, $idx, $len }) =>
|
||||
theme.borders[$idx !== $len - 1 ? 1 : 0]};
|
||||
border-bottom-color: ${({ theme }) =>
|
||||
theme.colors.interactive.tonal.neutral[0]};
|
||||
margin: 0 -${({ theme }) => theme.space[1]}px;
|
||||
padding: ${({ theme }) => theme.space[1] + theme.space[1] / 2}px;
|
||||
transition: all 150ms;
|
||||
|
||||
&:hover,
|
||||
&:focus-visible {
|
||||
background-color: ${({ theme }) => theme.colors.levels.sunken};
|
||||
border-bottom-color: transparent;
|
||||
}
|
||||
`;
|
||||
|
||||
const AWSConsoleConstraintsList = <T extends PendingListItem>({
|
||||
item,
|
||||
createAttempt,
|
||||
clearAttempt,
|
||||
setResourceConstraints,
|
||||
addedResourceConstraints,
|
||||
}: {
|
||||
item: WithResourceConstraints<'aws_console', DisplayRow<T>>;
|
||||
createAttempt: RequestCheckoutProps<T>['createAttempt'];
|
||||
clearAttempt: RequestCheckoutProps<T>['clearAttempt'];
|
||||
setResourceConstraints: RequestCheckoutProps<T>['setResourceConstraints'];
|
||||
addedResourceConstraints: RequestCheckoutProps<T>['addedResourceConstraints'];
|
||||
}) => (
|
||||
<Flex flexDirection="column" gap={1} mt={1} width="100%">
|
||||
<Text bold>Role ARNs</Text>
|
||||
<Flex flexDirection="column" width="100%">
|
||||
{item.constraints.aws_console.role_arns.map((arn, idx) => (
|
||||
<StyledAWSRoleARNDisplayRow
|
||||
key={arn}
|
||||
$idx={idx}
|
||||
$len={item.constraints.aws_console.role_arns.length}
|
||||
>
|
||||
<Text style={{ alignContent: 'center' }}>
|
||||
{formatAWSRoleARNForDisplay(arn)}
|
||||
</Text>
|
||||
<ButtonIcon
|
||||
size={0}
|
||||
title="Remove Role ARN"
|
||||
onClick={() => {
|
||||
clearAttempt();
|
||||
const ridStr = getResourceIDString({
|
||||
cluster: item.clusterName,
|
||||
kind: item.kind,
|
||||
name: item.id,
|
||||
});
|
||||
console.log('toggling role arn', {
|
||||
ridStr,
|
||||
curConstraints: item.constraints,
|
||||
addedResourceConstraints,
|
||||
setResourceConstraints,
|
||||
});
|
||||
toggleAWSConsoleConstraint(item, arn, setResourceConstraints);
|
||||
}}
|
||||
disabled={createAttempt.status === 'processing'}
|
||||
css={`
|
||||
border-radius: ${({ theme }) => theme.radii[2]}px;
|
||||
`}
|
||||
>
|
||||
<Cross size="small" />
|
||||
</ButtonIcon>
|
||||
</StyledAWSRoleARNDisplayRow>
|
||||
))}
|
||||
</Flex>
|
||||
</Flex>
|
||||
);
|
||||
|
||||
export function RequestCheckout<T extends PendingListItem>({
|
||||
toggleResource,
|
||||
toggleResources,
|
||||
@@ -186,6 +286,8 @@ export function RequestCheckout<T extends PendingListItem>({
|
||||
updateNamespacesForKubeCluster,
|
||||
requestKind = RequestKind.ShortTerm,
|
||||
setRequestKind,
|
||||
addedResourceConstraints,
|
||||
setResourceConstraints,
|
||||
}: RequestCheckoutProps<T>) {
|
||||
const theme = useTheme();
|
||||
const [reason, setReason] = useState('');
|
||||
@@ -194,6 +296,23 @@ export function RequestCheckout<T extends PendingListItem>({
|
||||
setRequestKind !== undefined && toggleResources !== undefined;
|
||||
const isLongTerm = requestKind === RequestKind.LongTerm;
|
||||
|
||||
const displayRows = useMemo<DisplayRow<T>[]>(() => {
|
||||
// Kube namespaces are displayed as part of their parent kube_cluster.
|
||||
const base = pendingAccessRequests.filter(d => d.kind !== 'namespace');
|
||||
|
||||
if (!addedResourceConstraints) return base;
|
||||
|
||||
return base.map(row => {
|
||||
const rcId = getResourceIDString({
|
||||
cluster: row.clusterName,
|
||||
kind: row.kind,
|
||||
name: row.id,
|
||||
});
|
||||
const rc = addedResourceConstraints[rcId];
|
||||
return rc ? { ...row, constraints: rc } : row;
|
||||
});
|
||||
}, [pendingAccessRequests, addedResourceConstraints]);
|
||||
|
||||
function updateReason(reason: string) {
|
||||
setReason(reason);
|
||||
}
|
||||
@@ -280,6 +399,14 @@ export function RequestCheckout<T extends PendingListItem>({
|
||||
return [false, undefined];
|
||||
}, [isResourceRequest, isLongTerm, dryRunResponse?.longTermResourceGrouping]);
|
||||
|
||||
const longTermButtonTooltipText =
|
||||
longTermDisabledReason ||
|
||||
(addedResourceConstraints &&
|
||||
Object.entries(addedResourceConstraints).length &&
|
||||
!isLongTerm
|
||||
? 'Selecting Permanent access will remove added resource constraints'
|
||||
: undefined);
|
||||
|
||||
const numPendingAccessRequests = pendingAccessRequests.filter(
|
||||
item => !isKubeClusterWithNamespaces(item, pendingAccessRequests)
|
||||
).length;
|
||||
@@ -304,7 +431,27 @@ export function RequestCheckout<T extends PendingListItem>({
|
||||
);
|
||||
};
|
||||
|
||||
function customRow(item: T) {
|
||||
const renderAfter = (item: DisplayRow<T>) => {
|
||||
if (hasResourceConstraints(item, 'aws_console')) {
|
||||
return (
|
||||
<tr style={{ borderTop: 'none' }} data-render-after-row>
|
||||
<td colSpan={showClusterNameColumn ? 4 : 3}>
|
||||
<Flex justifyContent="space-between" alignItems="center" mt={-2}>
|
||||
<AWSConsoleConstraintsList
|
||||
item={item}
|
||||
setResourceConstraints={setResourceConstraints}
|
||||
clearAttempt={clearAttempt}
|
||||
createAttempt={createAttempt}
|
||||
addedResourceConstraints={addedResourceConstraints}
|
||||
/>
|
||||
</Flex>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
function customRow(item: DisplayRow<T>) {
|
||||
if (item.kind === 'kube_cluster') {
|
||||
const unsupported =
|
||||
requestKind === RequestKind.LongTerm &&
|
||||
@@ -356,7 +503,7 @@ export function RequestCheckout<T extends PendingListItem>({
|
||||
}
|
||||
|
||||
const getStyle = useMemo(
|
||||
() => (item: T) => {
|
||||
() => (item: DisplayRow<T>) => {
|
||||
if (
|
||||
!shouldShowLongTermGroupingErrors({
|
||||
requestKind,
|
||||
@@ -497,11 +644,10 @@ export function RequestCheckout<T extends PendingListItem>({
|
||||
/>
|
||||
)}
|
||||
<StyledTable
|
||||
data={pendingAccessRequests.filter(
|
||||
d => d.kind !== 'namespace'
|
||||
)}
|
||||
data={displayRows}
|
||||
row={{
|
||||
customRow,
|
||||
renderAfter,
|
||||
getStyle,
|
||||
}}
|
||||
columns={[
|
||||
@@ -563,7 +709,7 @@ export function RequestCheckout<T extends PendingListItem>({
|
||||
value: RequestKind.LongTerm,
|
||||
label: 'Permanent',
|
||||
disabled: longTermDisabled,
|
||||
tooltip: longTermDisabledReason,
|
||||
tooltip: longTermButtonTooltipText,
|
||||
},
|
||||
]}
|
||||
activeValue={
|
||||
@@ -1039,6 +1185,16 @@ const StyledTable = styled(Table)`
|
||||
border-radius: 8px;
|
||||
box-shadow: ${props => props.theme.boxShadow[0]};
|
||||
overflow: hidden;
|
||||
|
||||
// Handle hovering/focusing constraint rows / their parent row the same.
|
||||
tr:hover:has(+ [data-render-after-row]) + [data-render-after-row],
|
||||
tr:focus-visible:has(+ [data-render-after-row]) + [data-render-after-row],
|
||||
[data-render-after-row]:hover,
|
||||
[data-render-after-row]:focus-visible,
|
||||
tr:has(+ [data-render-after-row]:hover),
|
||||
tr:has(+ [data-render-after-row]:focus-visible) {
|
||||
background-color: ${({ theme }) => theme.colors.levels.surface};
|
||||
}
|
||||
` as typeof Table;
|
||||
|
||||
const ShortenedText = styled(Text)`
|
||||
@@ -1122,6 +1278,11 @@ export type RequestCheckoutProps<T extends PendingListItem = PendingListItem> =
|
||||
startTime: Date;
|
||||
requestKind?: RequestKind;
|
||||
setRequestKind?: React.Dispatch<React.SetStateAction<RequestKind>>;
|
||||
addedResourceConstraints: ResourceConstraintsMap;
|
||||
setResourceConstraints: (
|
||||
key: ResourceIDString,
|
||||
rc?: ResourceConstraints
|
||||
) => void;
|
||||
onStartTimeChange(t?: Date): void;
|
||||
fetchKubeNamespaces(search: string, kubeCluster: T): Promise<string[]>;
|
||||
updateNamespacesForKubeCluster(
|
||||
|
||||
+19
-1
@@ -111,6 +111,10 @@ export default function RequestReview({
|
||||
return state !== undefined ? state === currentOptionState : undefined;
|
||||
}
|
||||
|
||||
const someResourcesConstrained = request.resources?.some(
|
||||
r => !!r.constraints
|
||||
);
|
||||
|
||||
return (
|
||||
<Validation>
|
||||
{({ validator }) => (
|
||||
@@ -168,7 +172,9 @@ export default function RequestReview({
|
||||
<React.Fragment key={index}>
|
||||
{radio}
|
||||
<Box ml={4} mt={2} css={{ position: 'relative' }}>
|
||||
<HorizontalLine />
|
||||
<HorizontalLine
|
||||
height={92 + (someResourcesConstrained ? 24 : 0)}
|
||||
/>
|
||||
<FieldSelect<SuggestedAcessListOption>
|
||||
ml={1}
|
||||
maxWidth="600px"
|
||||
@@ -196,6 +202,18 @@ export default function RequestReview({
|
||||
}
|
||||
options={suggestedAccessListOptions}
|
||||
/>
|
||||
{someResourcesConstrained && (
|
||||
<Text
|
||||
ml={1}
|
||||
mt={2}
|
||||
fontSize={1}
|
||||
color="text.slightlyMuted"
|
||||
>
|
||||
Requested resource(s) include Constraints; access
|
||||
granted via membership in an Access List will be
|
||||
broader than requested.
|
||||
</Text>
|
||||
)}
|
||||
</Box>
|
||||
</React.Fragment>
|
||||
);
|
||||
|
||||
+39
@@ -24,6 +24,9 @@ import {
|
||||
} from 'shared/hooks/useAsync';
|
||||
|
||||
import {
|
||||
requestResourceApprovedWithConstraints,
|
||||
requestResourcePendingWithConstraints,
|
||||
requestResourceWithConstraintsSuggestedAccessLists,
|
||||
requestRoleApproved,
|
||||
requestRoleApprovedWithStartTime,
|
||||
requestRoleDenied,
|
||||
@@ -106,6 +109,42 @@ export const LoadedRoleApprovedWithStartTime = () => {
|
||||
);
|
||||
};
|
||||
|
||||
export const LoadedResourcePendingWithConstraints = () => {
|
||||
const flags = {
|
||||
...sampleFlags,
|
||||
canReview: true,
|
||||
canDelete: true,
|
||||
};
|
||||
return (
|
||||
<RequestView
|
||||
{...sample}
|
||||
fetchRequestAttempt={makeSuccessAttempt(
|
||||
requestResourcePendingWithConstraints
|
||||
)}
|
||||
fetchSuggestedAccessListsAttempt={makeSuccessAttempt(
|
||||
requestResourceWithConstraintsSuggestedAccessLists
|
||||
)}
|
||||
getFlags={() => flags}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export const LoadedResourceApprovedWithConstraints = () => {
|
||||
const flags = {
|
||||
...sampleFlags,
|
||||
canAssume: true,
|
||||
};
|
||||
return (
|
||||
<RequestView
|
||||
{...sample}
|
||||
fetchRequestAttempt={makeSuccessAttempt(
|
||||
requestResourceApprovedWithConstraints
|
||||
)}
|
||||
getFlags={() => flags}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export const AccessListPromoted = () => {
|
||||
const flags = {
|
||||
...sampleFlags,
|
||||
|
||||
+79
-7
@@ -38,6 +38,7 @@ import {
|
||||
ChevronCircleDown,
|
||||
CircleCheck,
|
||||
CircleCross,
|
||||
Key,
|
||||
} from 'design/Icon';
|
||||
import { LabelKind } from 'design/LabelState/LabelState';
|
||||
import { TeleportGearIcon } from 'design/SVGIcon';
|
||||
@@ -49,9 +50,11 @@ import {
|
||||
AccessRequestReview,
|
||||
AccessRequestReviewer,
|
||||
canAssumeNow,
|
||||
hasResourceConstraints,
|
||||
RequestKind,
|
||||
RequestState,
|
||||
Resource,
|
||||
WithResourceConstraints,
|
||||
} from 'shared/services/accessRequests';
|
||||
|
||||
import type {
|
||||
@@ -62,7 +65,10 @@ import {
|
||||
getAssumeStartTimeTooltipText,
|
||||
PromotedMessage,
|
||||
} from '../../Shared/Shared';
|
||||
import { getFormattedDurationTxt } from '../../Shared/utils';
|
||||
import {
|
||||
formatAWSRoleARNForDisplay,
|
||||
getFormattedDurationTxt,
|
||||
} from '../../Shared/utils';
|
||||
import { formattedName } from '../formattedName';
|
||||
import { RequestDelete } from './RequestDelete';
|
||||
import RequestReview from './RequestReview';
|
||||
@@ -449,6 +455,42 @@ export function Timestamp({
|
||||
);
|
||||
}
|
||||
|
||||
const AWSConstraintChip = ({ label }: { label: string }) => {
|
||||
return (
|
||||
<Flex
|
||||
flexDirection="row"
|
||||
justifyContent="center"
|
||||
alignItems="center"
|
||||
gap={2}
|
||||
px={3}
|
||||
py={2}
|
||||
backgroundColor={'spotBackground.0'}
|
||||
borderRadius="999px"
|
||||
title={label}
|
||||
>
|
||||
<Key size="small" />
|
||||
<Text typography="body3">{formatAWSRoleARNForDisplay(label)}</Text>
|
||||
</Flex>
|
||||
);
|
||||
};
|
||||
|
||||
const AwsConsoleConstraintsList = <R extends object>({
|
||||
resource,
|
||||
}: {
|
||||
resource: WithResourceConstraints<'aws_console', R>;
|
||||
}) => {
|
||||
return (
|
||||
<Flex flexDirection="column" gap={2} mt={2}>
|
||||
<Text bold>Role ARNs</Text>
|
||||
<Flex flexDirection="row" gap={2} flexWrap="wrap">
|
||||
{resource.constraints.aws_console.role_arns.map(arn => (
|
||||
<AWSConstraintChip key={arn} label={arn} />
|
||||
))}
|
||||
</Flex>
|
||||
</Flex>
|
||||
);
|
||||
};
|
||||
|
||||
function Comment({
|
||||
author,
|
||||
comment,
|
||||
@@ -460,6 +502,29 @@ function Comment({
|
||||
createdDuration: string;
|
||||
resources?: Resource[];
|
||||
}) {
|
||||
const data = resources?.map(resource => ({
|
||||
...resource.id,
|
||||
...resource.details,
|
||||
name: resource.details?.friendlyName || formattedName(resource),
|
||||
constraints: resource.constraints,
|
||||
}));
|
||||
|
||||
const renderConstraints = (r: NonNullable<typeof data>[number]) =>
|
||||
hasResourceConstraints(r, 'aws_console') ? (
|
||||
<AwsConsoleConstraintsList resource={r} />
|
||||
) : null;
|
||||
|
||||
const renderAfter = (r: NonNullable<typeof data>[number]) =>
|
||||
r.constraints ? (
|
||||
<tr style={{ borderTop: 'none' }} data-render-after-row>
|
||||
<td colSpan={3}>
|
||||
<Flex flexDirection="column" gap={1} mt={-2}>
|
||||
{renderConstraints(r)}
|
||||
</Flex>
|
||||
</td>
|
||||
</tr>
|
||||
) : null;
|
||||
|
||||
return (
|
||||
<Box
|
||||
border="1px solid"
|
||||
@@ -476,7 +541,7 @@ function Comment({
|
||||
{comment}
|
||||
</Box>
|
||||
)}
|
||||
{resources?.length > 0 && (
|
||||
{!!data?.length && (
|
||||
<Box
|
||||
pt={comment ? 0 : 3}
|
||||
pl={3}
|
||||
@@ -488,11 +553,8 @@ function Comment({
|
||||
bg="levels.elevated"
|
||||
>
|
||||
<StyledTable
|
||||
data={resources.map(resource => ({
|
||||
...resource.id,
|
||||
...resource.details,
|
||||
name: resource.details?.friendlyName || formattedName(resource),
|
||||
}))}
|
||||
data={data}
|
||||
row={{ renderAfter }}
|
||||
columns={[
|
||||
{
|
||||
key: 'clusterName',
|
||||
@@ -685,6 +747,16 @@ const StyledTable = styled(Table)`
|
||||
& > tbody > tr > td {
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
// Handle hovering/focusing constraint rows / their parent row the same.
|
||||
tr:hover:has(+ [data-render-after-row]) + [data-render-after-row],
|
||||
tr:focus-visible:has(+ [data-render-after-row]) + [data-render-after-row],
|
||||
[data-render-after-row]:hover,
|
||||
[data-render-after-row]:focus-visible,
|
||||
tr:has(+ [data-render-after-row]:hover),
|
||||
tr:has(+ [data-render-after-row]:focus-visible) {
|
||||
background-color: ${({ theme }) => theme.colors.levels.surface};
|
||||
}
|
||||
` as typeof Table;
|
||||
|
||||
const BrandName = styled.span`
|
||||
|
||||
@@ -18,7 +18,14 @@
|
||||
|
||||
import { formatDuration, intervalToDuration } from 'date-fns';
|
||||
|
||||
import { ResourceMap } from '../NewRequest';
|
||||
import {
|
||||
getResourceIDString,
|
||||
ResourceConstraints,
|
||||
ResourceIDString,
|
||||
WithResourceConstraints,
|
||||
} from 'shared/services/accessRequests';
|
||||
|
||||
import { PendingListItem, ResourceMap } from '../NewRequest';
|
||||
|
||||
export function getFormattedDurationTxt({
|
||||
start,
|
||||
@@ -45,3 +52,48 @@ export function getNumAddedResources(addedResources: ResourceMap) {
|
||||
Object.keys(addedResources.aws_ic_account_assignment).length
|
||||
);
|
||||
}
|
||||
|
||||
const AWS_IAM_ROLE_ARN_REGEX = /^arn:aws[a-z0-9-]*:iam::(\d{12}):role\/(.+)$/;
|
||||
|
||||
/**
|
||||
* Formats an AWS Role ARN for pretty display, in the format "accountId: rolePathAndName".
|
||||
*/
|
||||
export const formatAWSRoleARNForDisplay = (arn: string) => {
|
||||
const match = arn.match(AWS_IAM_ROLE_ARN_REGEX);
|
||||
|
||||
if (!match || match.length < 3) {
|
||||
return arn;
|
||||
}
|
||||
|
||||
const [, accountId, rolePathAndName] = match;
|
||||
|
||||
return `${accountId}: ${rolePathAndName}`;
|
||||
};
|
||||
|
||||
/**
|
||||
* Toggles an AWS Console constraint by removing the specified ARN from the current constraints.
|
||||
* If no RoleARNs remain after removal, it clears the constraint.
|
||||
*/
|
||||
export const toggleAWSConsoleConstraint = <T extends PendingListItem>(
|
||||
item: WithResourceConstraints<
|
||||
'aws_console',
|
||||
Pick<T, 'id' | 'kind' | 'clusterName'>
|
||||
>,
|
||||
arn: string,
|
||||
set: (
|
||||
key: ResourceIDString,
|
||||
constraints: ResourceConstraints | undefined
|
||||
) => void
|
||||
) => {
|
||||
const key = getResourceIDString({
|
||||
name: item.id,
|
||||
kind: item.kind,
|
||||
cluster: item.clusterName,
|
||||
});
|
||||
const newRc = {
|
||||
aws_console: {
|
||||
role_arns: item.constraints.aws_console.role_arns.filter(a => a !== arn),
|
||||
},
|
||||
};
|
||||
set(key, newRc.aws_console.role_arns.length ? newRc : undefined);
|
||||
};
|
||||
|
||||
@@ -16,6 +16,7 @@
|
||||
* along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import { SuggestedAccessList } from 'shared/components/AccessRequests/ReviewRequests';
|
||||
import { AccessRequest } from 'shared/services/accessRequests';
|
||||
|
||||
export const dryRunResponse: AccessRequest = {
|
||||
@@ -197,6 +198,90 @@ export const requestRolePending: AccessRequest = {
|
||||
reasonPrompts: [],
|
||||
};
|
||||
|
||||
export const requestResourcePendingWithConstraints: AccessRequest = {
|
||||
id: '72de9b90-04fd-5621-a55d-432d9fe56ef2',
|
||||
state: 'PENDING',
|
||||
user: 'Sam',
|
||||
expires: new Date('12-6-2026'),
|
||||
expiresDuration: '1 hour',
|
||||
created: new Date('12-5-2026'),
|
||||
createdDuration: '1 day ago',
|
||||
maxDuration: new Date('12-6-2026'),
|
||||
maxDurationText: '',
|
||||
requestTTL: new Date('12-6-2026'),
|
||||
requestTTLDuration: '1 hour',
|
||||
sessionTTL: new Date('12-6-2026'),
|
||||
sessionTTLDuration: '',
|
||||
roles: ['aws-full-access'],
|
||||
requestReason: 'Just need to view AWS Console setup',
|
||||
resolveReason: '',
|
||||
reviews: [],
|
||||
reviewers: [
|
||||
{
|
||||
name: 'Alice',
|
||||
state: 'PENDING',
|
||||
},
|
||||
{
|
||||
name: 'Bob',
|
||||
state: 'PENDING',
|
||||
},
|
||||
],
|
||||
thresholdNames: ['Default', 'Admin'],
|
||||
resources: [
|
||||
{
|
||||
id: {
|
||||
kind: 'app',
|
||||
name: 'aws-console-prod',
|
||||
clusterName: 'testing.com',
|
||||
},
|
||||
constraints: {
|
||||
aws_console: {
|
||||
role_arns: ['arn:aws:iam::123456789012:role/ReadAccess'],
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
id: {
|
||||
kind: 'app',
|
||||
name: 'aws-console-stage',
|
||||
clusterName: 'testing.com',
|
||||
},
|
||||
constraints: {
|
||||
aws_console: {
|
||||
role_arns: [
|
||||
'arn:aws:iam::123456789012:role/ReadAccess',
|
||||
'arn:aws:iam::123456789012:role/AdminAccess',
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
assumeStartTime: new Date('12-5-2026'),
|
||||
assumeStartTimeDuration: '24 hours from now',
|
||||
reasonMode: 'required',
|
||||
reasonPrompts: [],
|
||||
};
|
||||
|
||||
export const requestResourceWithConstraintsSuggestedAccessLists: SuggestedAccessList[] =
|
||||
[
|
||||
{
|
||||
id: '123456789010',
|
||||
title: 'AWS Full Access',
|
||||
grants: {
|
||||
roles: ['aws-full-access'],
|
||||
traits: {},
|
||||
},
|
||||
},
|
||||
{
|
||||
id: '123456789011',
|
||||
title: 'AWS Console Admin',
|
||||
grants: {
|
||||
roles: ['aws-full-access', 'aws-admin'],
|
||||
traits: {},
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
export const requestRoleDenied: AccessRequest = {
|
||||
id: '3ce23da9-6b85-5fce-9bf3-5fb826120cb2',
|
||||
state: 'DENIED',
|
||||
@@ -333,6 +418,74 @@ export const requestRoleApprovedWithStartTime: AccessRequest = {
|
||||
reasonPrompts: [],
|
||||
};
|
||||
|
||||
export const requestResourceApprovedWithConstraints: AccessRequest = {
|
||||
id: '72de9b90-04fd-5621-a55d-432d9fe56ef2',
|
||||
state: 'APPROVED',
|
||||
user: 'Sam',
|
||||
expires: new Date('12-6-2026'),
|
||||
expiresDuration: '24 hours',
|
||||
created: new Date('12-5-2026'),
|
||||
createdDuration: '2 hours ago',
|
||||
maxDuration: new Date('12-6-2026'),
|
||||
maxDurationText: '',
|
||||
requestTTL: new Date('12-5-2026'),
|
||||
requestTTLDuration: '2 hours',
|
||||
sessionTTL: new Date('12-5-2026'),
|
||||
sessionTTLDuration: '',
|
||||
roles: ['aws-full-access'],
|
||||
requestReason: 'Just need to view AWS Console setup',
|
||||
resolveReason: '',
|
||||
reviews: [
|
||||
{
|
||||
author: 'Alice',
|
||||
createdDuration: '1 minute ago',
|
||||
reason: 'Sure',
|
||||
state: 'APPROVED',
|
||||
roles: ['aws-full-access'],
|
||||
},
|
||||
],
|
||||
reviewers: [
|
||||
{
|
||||
name: 'Alice',
|
||||
state: 'APPROVED',
|
||||
},
|
||||
],
|
||||
thresholdNames: ['Default'],
|
||||
resources: [
|
||||
{
|
||||
id: {
|
||||
kind: 'app',
|
||||
name: 'aws-console-prod',
|
||||
clusterName: 'testing.com',
|
||||
},
|
||||
constraints: {
|
||||
aws_console: {
|
||||
role_arns: ['arn:aws:iam::123456789012:role/ReadAccess'],
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
id: {
|
||||
kind: 'app',
|
||||
name: 'aws-console-stage',
|
||||
clusterName: 'testing.com',
|
||||
},
|
||||
constraints: {
|
||||
aws_console: {
|
||||
role_arns: [
|
||||
'arn:aws:iam::123456789012:role/ReadAccess',
|
||||
'arn:aws:iam::123456789012:role/AdminAccess',
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
assumeStartTime: new Date('12-5-2026'),
|
||||
assumeStartTimeDuration: '24 hours from now',
|
||||
reasonMode: 'required',
|
||||
reasonPrompts: [],
|
||||
};
|
||||
|
||||
export const requestRolePromoted: AccessRequest = {
|
||||
id: '72de9b90-04fd-5621-a55d-432d9fe56ef2',
|
||||
state: 'PROMOTED',
|
||||
|
||||
@@ -105,9 +105,14 @@ export interface AccessRequestReviewer {
|
||||
state: RequestState;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resource represents a {@link ResourceId} with optional additional details
|
||||
* such as {@link ResourceDetails} and/or {@link ResourceConstraints} set by Proxy.
|
||||
*/
|
||||
export type Resource = {
|
||||
id: ResourceId;
|
||||
details?: ResourceDetails;
|
||||
constraints?: ResourceConstraints;
|
||||
};
|
||||
|
||||
// ResourceID is a unique identifier for a teleport resource.
|
||||
@@ -130,3 +135,115 @@ export type ResourceDetails = {
|
||||
hostname?: string;
|
||||
friendlyName?: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* Represents a {@link ResourceId} in an Access Request-related context,
|
||||
* where additional information such as {@link ResourceConstraints} may be provided.
|
||||
*/
|
||||
export type ResourceAccessId = {
|
||||
id: ResourceId;
|
||||
constraints?: ResourceConstraints;
|
||||
};
|
||||
|
||||
type AwsConsoleConstraints = {
|
||||
role_arns: string[];
|
||||
};
|
||||
|
||||
type BaseResourceConstraints = {
|
||||
version?: 'v1';
|
||||
};
|
||||
|
||||
/**
|
||||
* ResourceConstraints mirrors the gRPC-generated ResourceConstraints struct,
|
||||
* with a `oneof details`: exactly one detail variant should be present.
|
||||
*/
|
||||
export type ResourceConstraints = BaseResourceConstraints &
|
||||
(
|
||||
| {
|
||||
aws_console: AwsConsoleConstraints;
|
||||
}
|
||||
| {
|
||||
aws_console?: never;
|
||||
}
|
||||
);
|
||||
|
||||
type KeysOfUnion<T> = T extends T ? keyof T : never;
|
||||
type DetailKeys<TUnion, TBase extends object> = Exclude<
|
||||
KeysOfUnion<TUnion>,
|
||||
keyof TBase
|
||||
>;
|
||||
type StringKeys<T> = Extract<T, string>;
|
||||
|
||||
/**
|
||||
* ResourceConstraintsKind mirrors the fields assignable
|
||||
* to the gRPC-generated ResourceConstraints struct's `details`.
|
||||
*/
|
||||
export type ResourceConstraintsKind = StringKeys<
|
||||
DetailKeys<ResourceConstraints, BaseResourceConstraints>
|
||||
>;
|
||||
|
||||
/**
|
||||
* ResourceConstraintsVariant narrows {@link ResourceConstraints} to the provided
|
||||
* {@link ResourceConstraintsKind}.
|
||||
*/
|
||||
export type ResourceConstraintsVariant<K extends ResourceConstraintsKind> =
|
||||
Extract<ResourceConstraints, Record<K, unknown>>;
|
||||
|
||||
/**
|
||||
* Augments a resource-like object `R` with strongly-typed {@link ResourceConstraints}
|
||||
* based on the specified detail variant key.
|
||||
*/
|
||||
export type WithResourceConstraints<
|
||||
K extends ResourceConstraintsKind,
|
||||
R extends object = object,
|
||||
> = R & { constraints: ResourceConstraintsVariant<K> };
|
||||
|
||||
const isConstraintsVariant = <K extends ResourceConstraintsKind>(
|
||||
c: ResourceConstraints | undefined,
|
||||
key: K
|
||||
): c is ResourceConstraintsVariant<K> =>
|
||||
!!c && typeof c === 'object' && key in c && !!c[key];
|
||||
|
||||
/**
|
||||
* Narrows `item.constraints` to the given variant (e.g., 'awsConsole').
|
||||
*/
|
||||
export const hasResourceConstraints = <
|
||||
K extends ResourceConstraintsKind,
|
||||
T extends { constraints?: ResourceConstraints },
|
||||
>(
|
||||
item: T,
|
||||
key: K
|
||||
): item is T & { constraints: ResourceConstraintsVariant<K> } =>
|
||||
isConstraintsVariant(item.constraints, key);
|
||||
|
||||
declare const __resourceIDBrand: unique symbol;
|
||||
|
||||
/**
|
||||
* Resource identifier in the format "cluster/kind/name".
|
||||
* Use {@link getResourceIDString} to construct; this is a branded type
|
||||
* to ensure compile-time type safety.
|
||||
*/
|
||||
export type ResourceIDString = `${string}/${string}/${string}` & {
|
||||
[__resourceIDBrand]: 'ResourceIDString';
|
||||
};
|
||||
|
||||
/**
|
||||
* Creates a {@link ResourceIDString} from its component parts.
|
||||
*/
|
||||
export const getResourceIDString = ({
|
||||
cluster,
|
||||
kind,
|
||||
name,
|
||||
}: {
|
||||
cluster: string;
|
||||
kind: string;
|
||||
name: string;
|
||||
}): ResourceIDString => `${cluster}/${kind}/${name}` as ResourceIDString;
|
||||
|
||||
/**
|
||||
* Maps supported {@link ResourceIDString}s to their {@link ResourceConstraints}.
|
||||
*/
|
||||
export type ResourceConstraintsMap = Record<
|
||||
ResourceIDString,
|
||||
ResourceConstraints
|
||||
>;
|
||||
|
||||
@@ -22,7 +22,7 @@ export type AwsRole = {
|
||||
arn: string;
|
||||
display: string;
|
||||
accountId: string;
|
||||
requiresRequest?: string;
|
||||
requiresRequest?: boolean;
|
||||
};
|
||||
|
||||
/**
|
||||
|
||||
@@ -68,6 +68,7 @@ export default function makeApp(json: any): App {
|
||||
subKind,
|
||||
samlAppLaunchUrls,
|
||||
mcp,
|
||||
supportedFeatureIds,
|
||||
} = json;
|
||||
|
||||
const launchUrl = getLaunchUrl({
|
||||
@@ -157,5 +158,6 @@ export default function makeApp(json: any): App {
|
||||
samlAppLaunchUrls,
|
||||
mcp,
|
||||
cloudInstance,
|
||||
supportedFeatureIds,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -18,6 +18,7 @@
|
||||
|
||||
import { AppSubKind } from 'shared/services';
|
||||
import { AwsRole } from 'shared/services/apps';
|
||||
import { ComponentFeatureID } from 'shared/utils/componentFeatures';
|
||||
|
||||
import { ResourceLabel } from 'teleport/services/agents';
|
||||
import type { SamlServiceProviderPreset } from 'teleport/services/samlidp/types';
|
||||
@@ -87,6 +88,11 @@ export interface App {
|
||||
* mcp contains MCP server specific configurations.
|
||||
*/
|
||||
mcp?: AppMCP;
|
||||
/**
|
||||
* supportedFeatureIds contains component feature IDs supported by
|
||||
* both the App and all required back-end components.
|
||||
*/
|
||||
supportedFeatureIds?: ComponentFeatureID[];
|
||||
}
|
||||
|
||||
export type UserGroupAndDescription = {
|
||||
@@ -123,6 +129,7 @@ export type PermissionSet = {
|
||||
* eg: 1234--AdministratorAccess
|
||||
*/
|
||||
assignmentId: string;
|
||||
requiresRequest?: boolean;
|
||||
};
|
||||
|
||||
/**
|
||||
|
||||
@@ -286,6 +286,9 @@ export function AccessRequestCheckout() {
|
||||
updateNamespacesForKubeCluster={updateNamespacesForKubeCluster}
|
||||
requireReason={reasonMode === 'required'}
|
||||
reasonPrompts={reasonPrompts}
|
||||
// TODO(kiosion): Support Resource Constraints in Connect's RequestCheckout
|
||||
addedResourceConstraints={{}}
|
||||
setResourceConstraints={() => null}
|
||||
/>
|
||||
)}
|
||||
</Transition>
|
||||
|
||||
Reference in New Issue
Block a user