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:
Maxim
2026-01-27 18:22:13 +00:00
committed by GitHub
parent e2b659f423
commit a9820cd63e
16 changed files with 730 additions and 39 deletions
@@ -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}
+26 -19
View File
@@ -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;
};
};
@@ -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,
};
@@ -175,4 +175,6 @@ const props: RequestCheckoutWithSliderProps = {
onStartTimeChange: () => null,
fetchKubeNamespaces: () => null,
updateNamespacesForKubeCluster: () => null,
addedResourceConstraints: {},
setResourceConstraints: () => null,
};
@@ -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(
@@ -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>
);
@@ -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,
@@ -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
>;
+1 -1
View File
@@ -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>