From c63dec956f23a91f40c94958ccb8398aafbdeead Mon Sep 17 00:00:00 2001 From: Lisa Kim Date: Tue, 8 Oct 2024 09:04:48 -0700 Subject: [PATCH] Teleterm: add support for access requesting kube namespaces --- .../RequestCheckout/KubeNamespaceSelector.tsx | 8 +- .../NewRequest/RequestCheckout/index.ts | 6 +- .../AccessRequests/NewRequest/index.ts | 2 + .../AccessRequestCheckout.tsx | 210 +++++++++--------- .../useAccessRequestCheckout.test.tsx | 120 ++++++++++ .../useAccessRequestCheckout.ts | 138 +++++++++--- .../NewRequest/useNewRequest.ts | 22 +- .../ui/DocumentCluster/UnifiedResources.tsx | 2 +- .../accessRequestsService.test.ts | 10 +- .../accessRequestsService.ts | 80 ++++++- 10 files changed, 458 insertions(+), 140 deletions(-) diff --git a/web/packages/shared/components/AccessRequests/NewRequest/RequestCheckout/KubeNamespaceSelector.tsx b/web/packages/shared/components/AccessRequests/NewRequest/RequestCheckout/KubeNamespaceSelector.tsx index 116ac3765b898..dc4221942bc63 100644 --- a/web/packages/shared/components/AccessRequests/NewRequest/RequestCheckout/KubeNamespaceSelector.tsx +++ b/web/packages/shared/components/AccessRequests/NewRequest/RequestCheckout/KubeNamespaceSelector.tsx @@ -129,12 +129,16 @@ export function KubeNamespaceSelector({ }; async function handleLoadOptions(input: string) { - const options = await fetchKubeNamespaces({ + const namespaces = await fetchKubeNamespaces({ kubeCluster: kubeClusterItem.id, search: input, }); - return options; + return namespaces.map(namespace => ({ + kind: 'namespace', + value: namespace, + label: namespace, + })); } return ( diff --git a/web/packages/shared/components/AccessRequests/NewRequest/RequestCheckout/index.ts b/web/packages/shared/components/AccessRequests/NewRequest/RequestCheckout/index.ts index 95712e2755a15..9e727450cd3e1 100644 --- a/web/packages/shared/components/AccessRequests/NewRequest/RequestCheckout/index.ts +++ b/web/packages/shared/components/AccessRequests/NewRequest/RequestCheckout/index.ts @@ -17,7 +17,11 @@ */ export { RequestCheckoutWithSlider, RequestCheckout } from './RequestCheckout'; -export type { RequestCheckoutProps, PendingListItem } from './RequestCheckout'; +export type { + RequestCheckoutProps, + PendingListItem, + PendingKubeResourceItem, +} from './RequestCheckout'; export * from './utils'; export type { ReviewerOption } from './types'; diff --git a/web/packages/shared/components/AccessRequests/NewRequest/index.ts b/web/packages/shared/components/AccessRequests/NewRequest/index.ts index 44057733335fa..9074113221b16 100644 --- a/web/packages/shared/components/AccessRequests/NewRequest/index.ts +++ b/web/packages/shared/components/AccessRequests/NewRequest/index.ts @@ -20,3 +20,5 @@ export * from './RequestCheckout'; export * from './ResourceList'; export type { ResourceMap, RequestableResourceKind } from './resource'; export { getEmptyResourceState } from './resource'; +export type { KubeNamespaceRequest } from './kube'; +export { isKubeClusterWithNamespaces } from './kube'; diff --git a/web/packages/teleterm/src/ui/AccessRequestCheckout/AccessRequestCheckout.tsx b/web/packages/teleterm/src/ui/AccessRequestCheckout/AccessRequestCheckout.tsx index 8e0904efbeee2..dbcc521911839 100644 --- a/web/packages/teleterm/src/ui/AccessRequestCheckout/AccessRequestCheckout.tsx +++ b/web/packages/teleterm/src/ui/AccessRequestCheckout/AccessRequestCheckout.tsx @@ -32,6 +32,7 @@ import * as Icon from 'design/Icon'; import { pluralize } from 'shared/utils/text'; import { RequestCheckoutWithSlider } from 'shared/components/AccessRequests/NewRequest'; +import { isKubeClusterWithNamespaces } from 'shared/components/AccessRequests/NewRequest/kube'; import useAccessRequestCheckout from './useAccessRequestCheckout'; import { AssumedRolesBar } from './AssumedRolesBar'; @@ -102,6 +103,8 @@ export function AccessRequestCheckout() { pendingRequestTtlOptions, startTime, onStartTimeChange, + fetchKubeNamespaces, + bulkToggleKubeResources, } = useAccessRequestCheckout(); const isRoleRequest = pendingAccessRequests[0]?.kind === 'role'; @@ -111,116 +114,126 @@ export function AccessRequestCheckout() { setShowCheckout(false); } + const pendingAccessRequestsWithoutParentResource = + pendingAccessRequests.filter( + d => !isKubeClusterWithNamespaces(d, pendingAccessRequests) + ); + + const numAddedResources = pendingAccessRequestsWithoutParentResource.length; + // We should rather detect how much space we have, // but for simplicity we only count items. const moreToShow = Math.max( - pendingAccessRequests.length - MAX_RESOURCES_IN_BAR_TO_SHOW, + pendingAccessRequestsWithoutParentResource.length - + MAX_RESOURCES_IN_BAR_TO_SHOW, 0 ); - const numPendingAccessRequests = pendingAccessRequests.length; - return ( <> - {pendingAccessRequests.length > 0 && !isCollapsed() && ( - props.theme.colors.spotBackground[1]}; - `} - > - 0 && + !isCollapsed() && ( + props.theme.space[1]}px; + border-top: 1px solid + ${props => props.theme.colors.spotBackground[1]}; `} > - - - {numPendingAccessRequests}{' '} - {pluralize( - numPendingAccessRequests, - isRoleRequest ? 'role' : 'resource' - )}{' '} - added to access request: - - - {pendingAccessRequests - .slice(0, MAX_RESOURCES_IN_BAR_TO_SHOW) - .map(c => { - let resource = { - name: c.name, - key: `${c.clusterName}-${c.kind}-${c.id}`, - Icon: undefined, - }; - switch (c.kind) { - case 'app': - case 'saml_idp_service_provider': - resource.Icon = Icon.Application; - break; - case 'node': - resource.Icon = Icon.Server; - break; - case 'db': - resource.Icon = Icon.Database; - break; - case 'kube_cluster': - resource.Icon = Icon.Kubernetes; - break; - case 'role': - break; - default: - c satisfies never; - } - return resource; - }) - .map(c => ( - - ))} - {!!moreToShow && ( - - )} + {c.Icon && } + + {c.name} + + + ))} + {!!moreToShow && ( + + )} + + + + setShowCheckout(!showCheckout)} + textTransform="none" + css={` + white-space: nowrap; + `} + > + Proceed to request + + + + - - setShowCheckout(!showCheckout)} - textTransform="none" - css={` - white-space: nowrap; - `} - > - Proceed to request - - - - - - - - )} + + )} {assumedRequests.map(request => ( ))} @@ -270,11 +283,8 @@ export function AccessRequestCheckout() { setPendingRequestTtl={setPendingRequestTtl} startTime={startTime} onStartTimeChange={onStartTimeChange} - // TODO: these are placeholders to satisy linters. - // There is a split PR that handles teleterm support - // that will be merged right after this one (once both are approved) - bulkToggleKubeResources={() => null} - fetchKubeNamespaces={() => null} + fetchKubeNamespaces={fetchKubeNamespaces} + bulkToggleKubeResources={bulkToggleKubeResources} /> )} diff --git a/web/packages/teleterm/src/ui/AccessRequestCheckout/useAccessRequestCheckout.test.tsx b/web/packages/teleterm/src/ui/AccessRequestCheckout/useAccessRequestCheckout.test.tsx index 11e5c955f2fc8..a4dc881042005 100644 --- a/web/packages/teleterm/src/ui/AccessRequestCheckout/useAccessRequestCheckout.test.tsx +++ b/web/packages/teleterm/src/ui/AccessRequestCheckout/useAccessRequestCheckout.test.tsx @@ -21,11 +21,14 @@ import { renderHook, waitFor } from '@testing-library/react'; import { makeRootCluster, makeServer, + makeKube, rootClusterUri, } from 'teleterm/services/tshd/testHelpers'; import { MockAppContext } from 'teleterm/ui/fixtures/mocks'; import { MockAppContextProvider } from 'teleterm/ui/fixtures/MockAppContextProvider'; +import { mapRequestToKubeNamespaceUri } from '../services/workspacesService/accessRequestsService'; + import useAccessRequestCheckout from './useAccessRequestCheckout'; test('fetching requestable roles for servers uses UUID, not hostname', async () => { @@ -64,3 +67,120 @@ test('fetching requestable roles for servers uses UUID, not hostname', async () }) ); }); + +test('fetching requestable roles for a kube_cluster resource without specifying a namespace', async () => { + const kube = makeKube(); + const cluster = makeRootCluster(); + const appContext = new MockAppContext(); + appContext.clustersService.setState(draftState => { + draftState.clusters.set(rootClusterUri, cluster); + }); + await appContext.workspacesService.setActiveWorkspace(rootClusterUri); + await appContext.workspacesService + .getWorkspaceAccessRequestsService(rootClusterUri) + .addOrRemoveResource({ + kind: 'kube', + resource: kube, + }); + + jest.spyOn(appContext.tshd, 'getRequestableRoles'); + + const wrapper = ({ children }) => ( + + {children} + + ); + + renderHook(useAccessRequestCheckout, { wrapper }); + + await waitFor(() => + expect(appContext.tshd.getRequestableRoles).toHaveBeenCalledWith({ + clusterUri: rootClusterUri, + resourceIds: [ + { + clusterName: 'teleport-local', + kind: 'kube_cluster', + name: kube.name, + subResourceName: '', + }, + ], + }) + ); +}); + +test(`fetching requestable roles for a kube cluster's namespaces only creates resource IDs for its namespaces`, async () => { + const kube1 = makeKube(); + const kube2 = makeKube({ + name: 'kube2', + uri: `${rootClusterUri}/kubes/kube2`, + }); + const cluster = makeRootCluster(); + const appContext = new MockAppContext(); + appContext.clustersService.setState(draftState => { + draftState.clusters.set(rootClusterUri, cluster); + }); + await appContext.workspacesService.setActiveWorkspace(rootClusterUri); + await appContext.workspacesService + .getWorkspaceAccessRequestsService(rootClusterUri) + .addOrRemoveResource({ + kind: 'kube', + resource: kube1, + }); + await appContext.workspacesService + .getWorkspaceAccessRequestsService(rootClusterUri) + .addOrRemoveResource({ + kind: 'kube', + resource: kube2, + }); + + await appContext.workspacesService + .getWorkspaceAccessRequestsService(rootClusterUri) + .addOrRemoveKubeNamespaces([ + mapRequestToKubeNamespaceUri({ + clusterUri: rootClusterUri, + id: kube1.name, + name: 'namespace1', + }), + mapRequestToKubeNamespaceUri({ + clusterUri: rootClusterUri, + id: kube1.name, + name: 'namespace2', + }), + ]); + + jest.spyOn(appContext.tshd, 'getRequestableRoles'); + + const wrapper = ({ children }) => ( + + {children} + + ); + + renderHook(useAccessRequestCheckout, { wrapper }); + + await waitFor(() => + expect(appContext.tshd.getRequestableRoles).toHaveBeenCalledWith({ + clusterUri: rootClusterUri, + resourceIds: [ + { + clusterName: 'teleport-local', + kind: 'namespace', + name: kube1.name, + subResourceName: 'namespace1', + }, + { + clusterName: 'teleport-local', + kind: 'namespace', + name: kube1.name, + subResourceName: 'namespace2', + }, + { + clusterName: 'teleport-local', + kind: 'kube_cluster', + name: kube2.name, + subResourceName: '', + }, + ], + }) + ); +}); diff --git a/web/packages/teleterm/src/ui/AccessRequestCheckout/useAccessRequestCheckout.ts b/web/packages/teleterm/src/ui/AccessRequestCheckout/useAccessRequestCheckout.ts index 4ad261cf61b3c..7f8095a019828 100644 --- a/web/packages/teleterm/src/ui/AccessRequestCheckout/useAccessRequestCheckout.ts +++ b/web/packages/teleterm/src/ui/AccessRequestCheckout/useAccessRequestCheckout.ts @@ -24,6 +24,9 @@ import useAttempt from 'shared/hooks/useAttemptNext'; import { getDryRunMaxDuration, PendingListItem, + PendingKubeResourceItem, + isKubeClusterWithNamespaces, + KubeNamespaceRequest, } from 'shared/components/AccessRequests/NewRequest'; import { useSpecifiableFields } from 'shared/components/AccessRequests/NewRequest/useSpecifiableFields'; @@ -34,6 +37,8 @@ import { PendingAccessRequest, extractResourceRequestProperties, ResourceRequest, + mapRequestToKubeNamespaceUri, + mapKubeNamespaceUriToRequest, } from 'teleterm/ui/services/workspacesService/accessRequestsService'; import { retryWithRelogin } from 'teleterm/ui/utils'; import { @@ -87,9 +92,18 @@ export default function useAccessRequestCheckout() { const workspaceAccessRequest = ctx.workspacesService.getActiveWorkspaceAccessRequestsService(); const docService = ctx.workspacesService.getActiveWorkspaceDocumentService(); - const pendingAccessRequest = + const pendingAccessRequestRequest = workspaceAccessRequest?.getPendingAccessRequest(); + const pendingAccessRequests = getPendingAccessRequestsPerResource( + pendingAccessRequestRequest + ); + + const pendingAccessRequestsWithoutParentResource = + pendingAccessRequests.filter( + p => !isKubeClusterWithNamespaces(p, pendingAccessRequests) + ); + useEffect(() => { // Do a new dry run per changes to pending access requests // to get the latest time options and latest calculated @@ -99,20 +113,18 @@ export default function useAccessRequestCheckout() { if (showCheckout && requestedCount == 0) { performDryRun(); } - }, [showCheckout, pendingAccessRequest]); + }, [showCheckout, pendingAccessRequestRequest]); useEffect(() => { - if (!pendingAccessRequest || requestedCount > 0) { + if (!pendingAccessRequestRequest || requestedCount > 0) { return; } - const pendingAccessRequests = - getPendingAccessRequestsPerResource(pendingAccessRequest); runFetchResourceRoles(() => retryWithRelogin(ctx, clusterUri, async () => { const { response } = await ctx.tshd.getRequestableRoles({ clusterUri: rootClusterUri, - resourceIds: pendingAccessRequests + resourceIds: pendingAccessRequestsWithoutParentResource .filter(d => d.kind !== 'role') .map(d => ({ // We have to use id, not name. @@ -121,14 +133,14 @@ export default function useAccessRequestCheckout() { name: d.id, kind: d.kind, clusterName: d.clusterName, - subResourceName: '', + subResourceName: d.subResourceName || '', })), }); setResourceRequestRoles(response.applicableRoles); setSelectedResourceRequestRoles(response.applicableRoles); }) ); - }, [pendingAccessRequest]); + }, [pendingAccessRequestRequest]); useEffect(() => { clearCreateAttempt(); @@ -146,6 +158,9 @@ export default function useAccessRequestCheckout() { } }, [showCheckout, hasExited, createRequestAttempt.status]); + /** + * @param pendingRequest holds a list or map of resources to process + */ function getPendingAccessRequestsPerResource( pendingRequest: PendingAccessRequest ): PendingListItemWithOriginalItem[] { @@ -170,9 +185,34 @@ export default function useAccessRequestCheckout() { } case 'resource': { pendingRequest.resources.forEach(resourceRequest => { + // If this request is a kube cluster and has namespaces + // extract each as own request. + if ( + resourceRequest.kind === 'kube' && + resourceRequest.resource.namespaces?.size > 0 + ) { + // Process each namespace. + resourceRequest.resource.namespaces.forEach(namespaceRequestUri => { + const { kind, id, name } = + mapKubeNamespaceUriToRequest(namespaceRequestUri); + + const item = { + kind, + id, + name, + subResourceName: name, + originalItem: resourceRequest, + clusterName: + ctx.clustersService.findClusterByResource(namespaceRequestUri) + ?.name, + }; + pendingAccessRequests.push(item); + }); + } + const { kind, id, name } = extractResourceRequestProperties(resourceRequest); - pendingAccessRequests.push({ + const item: PendingListItemWithOriginalItem = { kind, id, name, @@ -180,7 +220,8 @@ export default function useAccessRequestCheckout() { clusterName: ctx.clustersService.findClusterByResource( resourceRequest.resource.uri )?.name, - }); + }; + pendingAccessRequests.push(item); }); } } @@ -207,6 +248,21 @@ export default function useAccessRequestCheckout() { ); } + async function bulkToggleKubeResources( + items: PendingKubeResourceItem[], + kubeCluster: PendingListKubeClusterWithOriginalItem + ) { + await workspaceAccessRequest.addOrRemoveKubeNamespaces( + items.map(item => + mapRequestToKubeNamespaceUri({ + id: item.id, + name: item.subResourceName, + clusterUri: kubeCluster.originalItem.resource.uri, + }) + ) + ); + } + function getAssumedRequests() { if (!clusterUri) { return []; @@ -222,22 +278,22 @@ export default function useAccessRequestCheckout() { * Shared logic used both during dry runs and regular access request creation. */ function prepareAndCreateRequest(req: CreateRequest) { - const pendingAccessRequests = - getPendingAccessRequestsPerResource(pendingAccessRequest); const params: CreateAccessRequestRequest = { rootClusterUri, reason: req.reason, suggestedReviewers: req.suggestedReviewers || [], dryRun: req.dryRun, - resourceIds: pendingAccessRequests + resourceIds: pendingAccessRequestsWithoutParentResource .filter(d => d.kind !== 'role') - .map(d => ({ - name: d.id, - clusterName: d.clusterName, - kind: d.kind, - subResourceName: '', - })), - roles: pendingAccessRequests + .map(d => { + return { + name: d.id, + clusterName: d.clusterName, + kind: d.kind, + subResourceName: d.subResourceName || '', + }; + }), + roles: pendingAccessRequestsWithoutParentResource .filter(d => d.kind === 'role') .map(d => d.name), assumeStartTime: req.start && Timestamp.fromDate(req.start), @@ -245,6 +301,11 @@ export default function useAccessRequestCheckout() { requestTtl: req.requestTTL && Timestamp.fromDate(req.requestTTL), }; + // Don't attempt creating anything if there are no resources selected. + if (!params.resourceIds.length && !params.roles.length) { + return; + } + // if we have a resource access request, we pass along the selected roles from the checkout if (params.resourceIds.length > 0) { params.roles = selectedResourceRequestRoles; @@ -256,7 +317,8 @@ export default function useAccessRequestCheckout() { ctx.clustersService.createAccessRequest(params).then(({ response }) => { return { accessRequest: response.request, - requestedCount: pendingAccessRequests.length, + requestedCount: + pendingAccessRequestsWithoutParentResource.filter.length, }; }) ).catch(e => { @@ -275,9 +337,9 @@ export default function useAccessRequestCheckout() { }); teletermAccessRequest = accessRequest; } catch { + setCreateRequestAttempt({ status: '' }); return; } - setCreateRequestAttempt({ status: '' }); const accessRequest = makeUiAccessRequest(teletermAccessRequest); @@ -333,9 +395,27 @@ export default function useAccessRequestCheckout() { } } + async function fetchKubeNamespaces({ + kubeCluster, + search, + }: KubeNamespaceRequest): Promise { + const { response } = await ctx.tshd.listKubernetesResources({ + searchKeywords: search, + limit: 50, + useSearchAsRoles: true, + nextKey: '', + resourceType: 'namespace', + clusterUri, + predicateExpression: '', + kubernetesCluster: kubeCluster, + kubernetesNamespace: '', + }); + return response.resources.map(i => i.name); + } + const shouldShowClusterNameColumn = - pendingAccessRequest?.kind === 'resource' && - Array.from(pendingAccessRequest.resources.values()).some(a => + pendingAccessRequestRequest?.kind === 'resource' && + Array.from(pendingAccessRequestRequest.resources.values()).some(a => routing.isLeafCluster(a.resource.uri) ); @@ -344,8 +424,7 @@ export default function useAccessRequestCheckout() { isCollapsed, assumedRequests: getAssumedRequests(), toggleResource, - pendingAccessRequests: - getPendingAccessRequestsPerResource(pendingAccessRequest), + pendingAccessRequests, shouldShowClusterNameColumn, createRequest, reset, @@ -373,6 +452,8 @@ export default function useAccessRequestCheckout() { pendingRequestTtlOptions, startTime, onStartTimeChange, + fetchKubeNamespaces, + bulkToggleKubeResources, }; } @@ -386,3 +467,8 @@ type PendingListItemWithOriginalItem = Omit & kind: 'role'; } ); + +type PendingListKubeClusterWithOriginalItem = Omit & { + kind: Extract; + originalItem: Extract; +}; diff --git a/web/packages/teleterm/src/ui/DocumentAccessRequests/NewRequest/useNewRequest.ts b/web/packages/teleterm/src/ui/DocumentAccessRequests/NewRequest/useNewRequest.ts index fefd237f739f0..bcc9d7b04f3e0 100644 --- a/web/packages/teleterm/src/ui/DocumentAccessRequests/NewRequest/useNewRequest.ts +++ b/web/packages/teleterm/src/ui/DocumentAccessRequests/NewRequest/useNewRequest.ts @@ -22,6 +22,7 @@ import { FetchStatus, SortType } from 'design/DataTable/types'; import useAttempt from 'shared/hooks/useAttemptNext'; import { makeAdvancedSearchQueryForLabel } from 'shared/utils/advancedSearchLabelQuery'; +import { RequestableResourceKind } from 'shared/components/AccessRequests/NewRequest/resource'; import { ShowResources, @@ -49,7 +50,6 @@ import type { ResourceLabel, ResourceFilter as WeakAgentFilter, ResourcesResponse, - ResourceIdKind, UnifiedResource, } from 'teleport/services/agents'; import type * as teleportApps from 'teleport/services/apps'; @@ -211,6 +211,17 @@ export default function useNewRequest(rootCluster: Cluster) { return; } + /** + * This should never happen but just a safeguard. + * This function is used in the "unified resources" view, + * where a user can click on a "request access" button. + * Selecting kube_cluster's namespace is not available in this view + * (instead it is rendered in the "request checkout" view). + */ + if (kind === 'namespace') { + return; + } + accessRequestsService.addOrRemoveResource( toResourceRequest({ kind, @@ -348,8 +359,13 @@ function getDefaultSort(kind: ResourceKind): SortType { export type ResourceKind = | Extract< - ResourceIdKind, - 'node' | 'app' | 'db' | 'kube_cluster' | 'saml_idp_service_provider' + RequestableResourceKind, + | 'node' + | 'app' + | 'db' + | 'kube_cluster' + | 'saml_idp_service_provider' + | 'namespace' > | 'role'; diff --git a/web/packages/teleterm/src/ui/DocumentCluster/UnifiedResources.tsx b/web/packages/teleterm/src/ui/DocumentCluster/UnifiedResources.tsx index 03da9da97f998..1d2f6e7613701 100644 --- a/web/packages/teleterm/src/ui/DocumentCluster/UnifiedResources.tsx +++ b/web/packages/teleterm/src/ui/DocumentCluster/UnifiedResources.tsx @@ -223,7 +223,7 @@ export function UnifiedResources(props: { const bulkAddResources = useCallback( (resources: UnifiedResourceResponse[]) => { - accessRequestsService.addOrRemoveResources(resources); + accessRequestsService.addAllOrRemoveAllResources(resources); }, [accessRequestsService] ); diff --git a/web/packages/teleterm/src/ui/services/workspacesService/accessRequestsService.test.ts b/web/packages/teleterm/src/ui/services/workspacesService/accessRequestsService.test.ts index 751cab9849052..c0b2f46f99217 100644 --- a/web/packages/teleterm/src/ui/services/workspacesService/accessRequestsService.test.ts +++ b/web/packages/teleterm/src/ui/services/workspacesService/accessRequestsService.test.ts @@ -124,7 +124,7 @@ test('getAddedItemsCount() returns added resource count for pending request', () expect(service.getAddedItemsCount()).toBe(0); }); -test('addOrRemoveResources() adds all resources to pending request', async () => { +test('addAllOrRemoveAllResources() adds all resources to pending request', async () => { const { accessRequestsService: service } = getTestSetup( getMockPendingResourceAccessRequest() ); @@ -138,7 +138,9 @@ test('addOrRemoveResources() adds all resources to pending request', async () => }); // add a single resource that isn't added should add to the request - await service.addOrRemoveResources([{ kind: 'server', resource: server }]); + await service.addAllOrRemoveAllResources([ + { kind: 'server', resource: server }, + ]); let pendingAccessRequest = service.getPendingAccessRequest(); expect( pendingAccessRequest.kind === 'resource' && @@ -149,7 +151,7 @@ test('addOrRemoveResources() adds all resources to pending request', async () => }); // padding an array that contains some resources already added and some that aren't should add them all - await service.addOrRemoveResources([ + await service.addAllOrRemoveAllResources([ { kind: 'server', resource: server }, { kind: 'server', resource: server2 }, ]); @@ -170,7 +172,7 @@ test('addOrRemoveResources() adds all resources to pending request', async () => }); // passing an array of resources that are all already added should remove all the passed resources - await service.addOrRemoveResources([ + await service.addAllOrRemoveAllResources([ { kind: 'server', resource: server }, { kind: 'server', resource: server2 }, ]); diff --git a/web/packages/teleterm/src/ui/services/workspacesService/accessRequestsService.ts b/web/packages/teleterm/src/ui/services/workspacesService/accessRequestsService.ts index f8ff36b9c2491..e408e8244de68 100644 --- a/web/packages/teleterm/src/ui/services/workspacesService/accessRequestsService.ts +++ b/web/packages/teleterm/src/ui/services/workspacesService/accessRequestsService.ts @@ -24,6 +24,7 @@ import { DatabaseUri, KubeUri, AppUri, + KubeResourceNamespaceUri, } from 'teleterm/ui/uri'; import { ModalsService } from 'teleterm/ui/services/modals'; @@ -98,7 +99,44 @@ export class AccessRequestsService { }); } - async addOrRemoveResources(requestedResources: ResourceRequest[]) { + async addOrRemoveKubeNamespaces(namespaceUris: KubeResourceNamespaceUri[]) { + this.setState(draftState => { + if (draftState.pending.kind !== 'resource') { + throw new Error('Cannot add a kube namespace to a role access request'); + } + + const { resources } = draftState.pending; + + namespaceUris.forEach(resourceUri => { + const requestedResource = resources.get( + routing.getKubeUri( + routing.parseKubeResourceNamespaceUri(resourceUri).params + ) + ); + if (!requestedResource || requestedResource.kind !== 'kube') { + throw new Error('Cannot add a kube namespace to a non-kube resource'); + } + const kubeResource = requestedResource.resource; + + if (!kubeResource.namespaces) { + kubeResource.namespaces = new Set(); + } + if (kubeResource.namespaces.has(resourceUri)) { + kubeResource.namespaces.delete(resourceUri); + } else { + kubeResource.namespaces.add(resourceUri); + } + }); + }); + } + + /** + * Removes all requested resources, if all the requested resources were already added + * or adds all requested resources, if not all requested resources were added. + * + * Typically used when user "selects all or deselects all" + */ + async addAllOrRemoveAllResources(requestedResources: ResourceRequest[]) { if (!(await this.canUpdateRequest('resource'))) { return; } @@ -258,6 +296,7 @@ export type ResourceRequest = kind: 'kube'; resource: { uri: KubeUri; + namespaces?: Set; }; } | { @@ -287,8 +326,7 @@ export function extractResourceRequestProperties({ kind: SharedResourceAccessRequestKind; id: string; /** - * Pretty name of the resource (can be the same as `id`). - * For example, for nodes, we want to show hostname instead of its id. + * Can refer to a pretty name of the resource (can be the same as `id`) */ name: string; } { @@ -317,6 +355,42 @@ export function extractResourceRequestProperties({ } } +export function mapRequestToKubeNamespaceUri({ + clusterUri, + id, + name, +}: { + clusterUri: ClusterUri; + /** kubeId */ + id: string; + /** kubeNamespaceId */ + name: string; +}) { + const { + params: { rootClusterId, leafClusterId }, + } = routing.parseClusterUri(clusterUri); + return routing.getKubeResourceNamespaceUri({ + rootClusterId, + leafClusterId, + kubeId: id, + kubeNamespaceId: name, + }); +} + +export function mapKubeNamespaceUriToRequest( + kubeNamespaceUri: KubeResourceNamespaceUri +): { + kind: 'namespace'; + /** kubeId */ + id: string; + /** kubeNamespaceId */ + name: string; +} { + const { kubeNamespaceId, kubeId } = + routing.parseKubeResourceNamespaceUri(kubeNamespaceUri).params; + return { kind: 'namespace', id: kubeId, name: kubeNamespaceId }; +} + /** * Maps the type used by the shared access requests to the type * required by the access requests service.