diff --git a/web/packages/design/src/ButtonSelect/ButtonSelect.tsx b/web/packages/design/src/ButtonSelect/ButtonSelect.tsx index 03e63d73133bf..bf4f9f0be7d53 100644 --- a/web/packages/design/src/ButtonSelect/ButtonSelect.tsx +++ b/web/packages/design/src/ButtonSelect/ButtonSelect.tsx @@ -80,7 +80,13 @@ export const ButtonSelect = []>({ {options.map(option => { const isActive = activeValue === option.value; return ( - + (props: TableProps) { ) { return ; } - data.map((item, rowIdx) => { + data.forEach((item, rowIdx) => { const TableRow: React.FC = ({ children }) => ( (props: TableProps) { ); const customRow = row?.customRow?.(item); + const renderAfter = row?.renderAfter?.(item); + if (customRow) { rows.push({customRow}); - return; + } else { + const cells = columns.flatMap((column, columnIdx) => { + if (column.isNonRender) { + return []; // does not include this column. + } + + const $cell = column.render ? ( + column.render(item) + ) : ( + + ); + + return ( + + {$cell} + + ); + }); + rows.push({cells}); } - const cells = columns.flatMap((column, columnIdx) => { - if (column.isNonRender) { - return []; // does not include this column. - } - - const $cell = column.render ? ( - column.render(item) - ) : ( - + if (renderAfter) { + rows.push( + {renderAfter} ); - - return ( - - {$cell} - - ); - }); - rows.push({cells}); + } }); if (rows.length) { diff --git a/web/packages/design/src/DataTable/types.ts b/web/packages/design/src/DataTable/types.ts index b1f117b71fccc..d5fa7226cc67a 100644 --- a/web/packages/design/src/DataTable/types.ts +++ b/web/packages/design/src/DataTable/types.ts @@ -93,6 +93,11 @@ export type TableProps = { * 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; }; }; diff --git a/web/packages/shared/components/AccessRequests/NewRequest/RequestCheckout/RequestCheckout.story.tsx b/web/packages/shared/components/AccessRequests/NewRequest/RequestCheckout/RequestCheckout.story.tsx index dbe771618d4a0..650319421866f 100644 --- a/web/packages/shared/components/AccessRequests/NewRequest/RequestCheckout/RequestCheckout.story.tsx +++ b/web/packages/shared/components/AccessRequests/NewRequest/RequestCheckout/RequestCheckout.story.tsx @@ -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 = () => { ); }; + export const Empty = () => { const [selectedReviewers, setSelectedReviewers] = useState([]); const [maxDuration, setMaxDuration] = useState>(); @@ -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 ( + + {}} + /> + + ); +}; + export const LoadedLongTermRequest = () => { const dryRunResponseWithLongTerm = { ...dryRunResponse, @@ -408,4 +453,6 @@ const baseProps: RequestCheckoutWithSliderProps = { requestKind: RequestKind.ShortTerm, setRequestKind: () => null, onStartTimeChange: () => null, + addedResourceConstraints: {}, + setResourceConstraints: () => null, }; diff --git a/web/packages/shared/components/AccessRequests/NewRequest/RequestCheckout/RequestCheckout.test.tsx b/web/packages/shared/components/AccessRequests/NewRequest/RequestCheckout/RequestCheckout.test.tsx index 382489b639455..2d9467df82b0b 100644 --- a/web/packages/shared/components/AccessRequests/NewRequest/RequestCheckout/RequestCheckout.test.tsx +++ b/web/packages/shared/components/AccessRequests/NewRequest/RequestCheckout/RequestCheckout.test.tsx @@ -175,4 +175,6 @@ const props: RequestCheckoutWithSliderProps = { onStartTimeChange: () => null, fetchKubeNamespaces: () => null, updateNamespacesForKubeCluster: () => null, + addedResourceConstraints: {}, + setResourceConstraints: () => null, }; diff --git a/web/packages/shared/components/AccessRequests/NewRequest/RequestCheckout/RequestCheckout.tsx b/web/packages/shared/components/AccessRequests/NewRequest/RequestCheckout/RequestCheckout.tsx index 2008cd1ac5201..893ebd6ee3b38 100644 --- a/web/packages/shared/components/AccessRequests/NewRequest/RequestCheckout/RequestCheckout.tsx +++ b/web/packages/shared/components/AccessRequests/NewRequest/RequestCheckout/RequestCheckout.tsx @@ -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 & { + 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 = ({ + item, + createAttempt, + clearAttempt, + setResourceConstraints, + addedResourceConstraints, +}: { + item: WithResourceConstraints<'aws_console', DisplayRow>; + createAttempt: RequestCheckoutProps['createAttempt']; + clearAttempt: RequestCheckoutProps['clearAttempt']; + setResourceConstraints: RequestCheckoutProps['setResourceConstraints']; + addedResourceConstraints: RequestCheckoutProps['addedResourceConstraints']; +}) => ( + + Role ARNs + + {item.constraints.aws_console.role_arns.map((arn, idx) => ( + + + {formatAWSRoleARNForDisplay(arn)} + + { + 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; + `} + > + + + + ))} + + +); + export function RequestCheckout({ toggleResource, toggleResources, @@ -186,6 +286,8 @@ export function RequestCheckout({ updateNamespacesForKubeCluster, requestKind = RequestKind.ShortTerm, setRequestKind, + addedResourceConstraints, + setResourceConstraints, }: RequestCheckoutProps) { const theme = useTheme(); const [reason, setReason] = useState(''); @@ -194,6 +296,23 @@ export function RequestCheckout({ setRequestKind !== undefined && toggleResources !== undefined; const isLongTerm = requestKind === RequestKind.LongTerm; + const displayRows = useMemo[]>(() => { + // 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({ 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({ ); }; - function customRow(item: T) { + const renderAfter = (item: DisplayRow) => { + if (hasResourceConstraints(item, 'aws_console')) { + return ( + + + + + + + + ); + } + }; + + function customRow(item: DisplayRow) { if (item.kind === 'kube_cluster') { const unsupported = requestKind === RequestKind.LongTerm && @@ -356,7 +503,7 @@ export function RequestCheckout({ } const getStyle = useMemo( - () => (item: T) => { + () => (item: DisplayRow) => { if ( !shouldShowLongTermGroupingErrors({ requestKind, @@ -497,11 +644,10 @@ export function RequestCheckout({ /> )} d.kind !== 'namespace' - )} + data={displayRows} row={{ customRow, + renderAfter, getStyle, }} columns={[ @@ -563,7 +709,7 @@ export function RequestCheckout({ 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 = startTime: Date; requestKind?: RequestKind; setRequestKind?: React.Dispatch>; + addedResourceConstraints: ResourceConstraintsMap; + setResourceConstraints: ( + key: ResourceIDString, + rc?: ResourceConstraints + ) => void; onStartTimeChange(t?: Date): void; fetchKubeNamespaces(search: string, kubeCluster: T): Promise; updateNamespacesForKubeCluster( diff --git a/web/packages/shared/components/AccessRequests/ReviewRequests/RequestView/RequestReview/RequestReview.tsx b/web/packages/shared/components/AccessRequests/ReviewRequests/RequestView/RequestReview/RequestReview.tsx index e03f57f5f809d..9fa7e4ab22990 100644 --- a/web/packages/shared/components/AccessRequests/ReviewRequests/RequestView/RequestReview/RequestReview.tsx +++ b/web/packages/shared/components/AccessRequests/ReviewRequests/RequestView/RequestReview/RequestReview.tsx @@ -111,6 +111,10 @@ export default function RequestReview({ return state !== undefined ? state === currentOptionState : undefined; } + const someResourcesConstrained = request.resources?.some( + r => !!r.constraints + ); + return ( {({ validator }) => ( @@ -168,7 +172,9 @@ export default function RequestReview({ {radio} - + ml={1} maxWidth="600px" @@ -196,6 +202,18 @@ export default function RequestReview({ } options={suggestedAccessListOptions} /> + {someResourcesConstrained && ( + + Requested resource(s) include Constraints; access + granted via membership in an Access List will be + broader than requested. + + )} ); diff --git a/web/packages/shared/components/AccessRequests/ReviewRequests/RequestView/RequestView.story.tsx b/web/packages/shared/components/AccessRequests/ReviewRequests/RequestView/RequestView.story.tsx index 8462704cb6a74..1db834046954f 100644 --- a/web/packages/shared/components/AccessRequests/ReviewRequests/RequestView/RequestView.story.tsx +++ b/web/packages/shared/components/AccessRequests/ReviewRequests/RequestView/RequestView.story.tsx @@ -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 ( + flags} + /> + ); +}; + +export const LoadedResourceApprovedWithConstraints = () => { + const flags = { + ...sampleFlags, + canAssume: true, + }; + return ( + flags} + /> + ); +}; + export const AccessListPromoted = () => { const flags = { ...sampleFlags, diff --git a/web/packages/shared/components/AccessRequests/ReviewRequests/RequestView/RequestView.tsx b/web/packages/shared/components/AccessRequests/ReviewRequests/RequestView/RequestView.tsx index e0a4cf35dc569..57fed38de05f9 100644 --- a/web/packages/shared/components/AccessRequests/ReviewRequests/RequestView/RequestView.tsx +++ b/web/packages/shared/components/AccessRequests/ReviewRequests/RequestView/RequestView.tsx @@ -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 ( + + + {formatAWSRoleARNForDisplay(label)} + + ); +}; + +const AwsConsoleConstraintsList = ({ + resource, +}: { + resource: WithResourceConstraints<'aws_console', R>; +}) => { + return ( + + Role ARNs + + {resource.constraints.aws_console.role_arns.map(arn => ( + + ))} + + + ); +}; + 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[number]) => + hasResourceConstraints(r, 'aws_console') ? ( + + ) : null; + + const renderAfter = (r: NonNullable[number]) => + r.constraints ? ( + + + + {renderConstraints(r)} + + + + ) : null; + return ( )} - {resources?.length > 0 && ( + {!!data?.length && ( ({ - ...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` diff --git a/web/packages/shared/components/AccessRequests/Shared/utils.ts b/web/packages/shared/components/AccessRequests/Shared/utils.ts index 1079ca9796f09..ed2a14468f367 100644 --- a/web/packages/shared/components/AccessRequests/Shared/utils.ts +++ b/web/packages/shared/components/AccessRequests/Shared/utils.ts @@ -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 = ( + item: WithResourceConstraints< + 'aws_console', + Pick + >, + 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); +}; diff --git a/web/packages/shared/components/AccessRequests/fixtures/index.ts b/web/packages/shared/components/AccessRequests/fixtures/index.ts index 1b2fc3f7340ef..480091f3c281d 100644 --- a/web/packages/shared/components/AccessRequests/fixtures/index.ts +++ b/web/packages/shared/components/AccessRequests/fixtures/index.ts @@ -16,6 +16,7 @@ * along with this program. If not, see . */ +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', diff --git a/web/packages/shared/services/accessRequests/accessRequests.ts b/web/packages/shared/services/accessRequests/accessRequests.ts index abe72c9d127c9..7c217ddb8dad1 100644 --- a/web/packages/shared/services/accessRequests/accessRequests.ts +++ b/web/packages/shared/services/accessRequests/accessRequests.ts @@ -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 extends T ? keyof T : never; +type DetailKeys = Exclude< + KeysOfUnion, + keyof TBase +>; +type StringKeys = Extract; + +/** + * ResourceConstraintsKind mirrors the fields assignable + * to the gRPC-generated ResourceConstraints struct's `details`. + */ +export type ResourceConstraintsKind = StringKeys< + DetailKeys +>; + +/** + * ResourceConstraintsVariant narrows {@link ResourceConstraints} to the provided + * {@link ResourceConstraintsKind}. + */ +export type ResourceConstraintsVariant = + Extract>; + +/** + * 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 }; + +const isConstraintsVariant = ( + c: ResourceConstraints | undefined, + key: K +): c is ResourceConstraintsVariant => + !!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 } => + 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 +>; diff --git a/web/packages/shared/services/apps.ts b/web/packages/shared/services/apps.ts index 3c5570b3719be..a9b72eca1e636 100644 --- a/web/packages/shared/services/apps.ts +++ b/web/packages/shared/services/apps.ts @@ -22,6 +22,7 @@ export type AwsRole = { arn: string; display: string; accountId: string; + requiresRequest?: boolean; }; /** diff --git a/web/packages/teleport/src/services/apps/makeApps.ts b/web/packages/teleport/src/services/apps/makeApps.ts index db4f2507f2bac..8dd0c9e1ef986 100644 --- a/web/packages/teleport/src/services/apps/makeApps.ts +++ b/web/packages/teleport/src/services/apps/makeApps.ts @@ -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, }; } diff --git a/web/packages/teleport/src/services/apps/types.ts b/web/packages/teleport/src/services/apps/types.ts index d26bb68eaa4a5..3662a240ba1ca 100644 --- a/web/packages/teleport/src/services/apps/types.ts +++ b/web/packages/teleport/src/services/apps/types.ts @@ -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; }; /** diff --git a/web/packages/teleterm/src/ui/AccessRequestCheckout/AccessRequestCheckout.tsx b/web/packages/teleterm/src/ui/AccessRequestCheckout/AccessRequestCheckout.tsx index 05bb1a9ee47ab..0fd979bd2e683 100644 --- a/web/packages/teleterm/src/ui/AccessRequestCheckout/AccessRequestCheckout.tsx +++ b/web/packages/teleterm/src/ui/AccessRequestCheckout/AccessRequestCheckout.tsx @@ -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} /> )}