diff --git a/frontend/e2e/pages/base-page.ts b/frontend/e2e/pages/base-page.ts index 83467a30f24..916bafa5fde 100644 --- a/frontend/e2e/pages/base-page.ts +++ b/frontend/e2e/pages/base-page.ts @@ -121,6 +121,24 @@ export default abstract class BasePage { await this.waitForLoadingComplete(); } + protected async waitForDetailsActions(actionsButton: Locator, timeoutMs = 60_000): Promise { + // Navigating right after impersonation teardown can race the SPA reload and + // abort API discovery, leaving the resource watch stuck on "Model does not + // exist" with no auto-retry. Recover by reloading until actions render. + await expect(async () => { + const modelError = await this.page + .getByText('Model does not exist') + .isVisible() + .catch(() => false); + if (modelError) { + await this.retryOnError(); + } else { + // eslint-disable-next-line no-restricted-syntax + await actionsButton.waitFor({ state: 'visible', timeout: 5_000 }); + } + }).toPass({ timeout: timeoutMs }); + } + protected locator( selector: string, options?: { diff --git a/frontend/e2e/pages/masthead-page.ts b/frontend/e2e/pages/masthead-page.ts index 0a42493f113..81cdd4cd85c 100644 --- a/frontend/e2e/pages/masthead-page.ts +++ b/frontend/e2e/pages/masthead-page.ts @@ -8,6 +8,20 @@ export class MastheadPage extends BasePage { private readonly logo: Locator = this.page.getByTestId('masthead-logo'); private readonly quickCreateToggle: Locator = this.page.getByTestId('quick-create-dropdown'); private readonly userDropdownToggle: Locator = this.page.getByTestId('user-dropdown-toggle'); + private readonly impersonateUserItem: Locator = this.page.getByTestId('impersonate-user'); + private readonly stopImpersonateItem: Locator = this.page.getByTestId('stop-impersonate'); + private readonly usernameInput: Locator = this.page.getByTestId('username-input'); + private readonly serviceAccountRadio: Locator = this.page.getByTestId( + 'impersonate-kind-service-account', + ); + private readonly serviceAccountNamespaceDropdown: Locator = this.page.getByTestId( + 'service-account-namespace-dropdown', + ); + private readonly serviceAccountNameDropdown: Locator = this.page.getByTestId( + 'service-account-name-dropdown', + ); + private readonly groupInput: Locator = this.page.getByPlaceholder('Enter groups'); + private readonly impersonateButton: Locator = this.page.getByTestId('impersonate-button'); private readonly copyLoginCommandLink: Locator = this.page .getByTestId('copy-login-command') .locator('a'); @@ -40,6 +54,68 @@ export class MastheadPage extends BasePage { await this.userDropdownToggle.click(); } + private async selectGroups(groups: string[]): Promise { + if (groups.length === 0) { + return; + } + + // Open once; the modal keeps the selector open between selections + await this.groupInput.click(); + for (const group of groups) { + const groupOption = this.page.getByText(group, { exact: true }); + await this.robustClick(groupOption); + } + await this.page.mouse.click(20, 20); + } + + private async fillConsoleSelectSearch(text: string): Promise { + await this.page.getByTestId('console-select-search-input').locator('input').fill(text); + } + + private async selectConsoleSelectOption(label: string): Promise { + const menuList = this.page.getByTestId('console-select-menu-list'); + await this.robustClick(menuList.getByText(label, { exact: true }).first()); + } + + async impersonateUser(username: string, groups: string[] = []): Promise { + await this.openUserDropdown(); + await this.robustClick(this.impersonateUserItem); + await this.usernameInput.fill(username); + await this.selectGroups(groups); + await this.robustClick(this.impersonateButton); + } + + async impersonateServiceAccount( + namespace: string, + name: string, + groups: string[] = [], + ): Promise { + await this.openUserDropdown(); + await this.robustClick(this.impersonateUserItem); + await this.robustClick(this.serviceAccountRadio); + await this.robustClick(this.serviceAccountNamespaceDropdown); + await this.fillConsoleSelectSearch(namespace); + await this.selectConsoleSelectOption(namespace); + await this.robustClick(this.serviceAccountNameDropdown); + await this.fillConsoleSelectSearch(name); + await this.selectConsoleSelectOption(name); + await this.selectGroups(groups); + await this.robustClick(this.impersonateButton); + } + + async stopImpersonating(): Promise { + const currentURL = this.page.url(); + await this.openUserDropdown(); + await Promise.all([ + this.page.waitForURL((url) => url.href !== currentURL, { + timeout: 60_000, + waitUntil: 'domcontentloaded', + }), + this.robustClick(this.stopImpersonateItem), + ]); + await this.page.waitForLoadState('domcontentloaded'); + } + async isAuthDisabled(): Promise { return this.page.evaluate(() => { const w = window as Window & { SERVER_FLAGS?: { authDisabled?: boolean } }; diff --git a/frontend/e2e/pages/service-account-page.ts b/frontend/e2e/pages/service-account-page.ts new file mode 100644 index 00000000000..3d5232ca34e --- /dev/null +++ b/frontend/e2e/pages/service-account-page.ts @@ -0,0 +1,23 @@ +import { expect, type Locator } from '@playwright/test'; + +import BasePage from './base-page'; + +export class ServiceAccountPage extends BasePage { + private readonly actionsMenuButton: Locator = this.page.getByTestId('actions-menu-button'); + private readonly impersonateAction: Locator = this.page.getByRole('menuitem', { + name: /Impersonate service account/, + }); + + async navigateToDetails(namespace: string, name: string): Promise { + await this.goTo(`/k8s/ns/${namespace}/~v1~ServiceAccount/${name}`); + await expect(this.page.getByRole('heading', { level: 1 }).filter({ hasText: name })).toBeVisible({ + timeout: 60_000, + }); + await this.waitForDetailsActions(this.actionsMenuButton); + } + + async impersonateFromDetails(): Promise { + await this.robustClick(this.actionsMenuButton); + await this.robustClick(this.impersonateAction); + } +} diff --git a/frontend/e2e/pages/user-page.ts b/frontend/e2e/pages/user-page.ts new file mode 100644 index 00000000000..64c2c970f7c --- /dev/null +++ b/frontend/e2e/pages/user-page.ts @@ -0,0 +1,25 @@ +import type { Locator } from '@playwright/test'; + +import { expect } from '../fixtures'; + +import BasePage from './base-page'; + +export class UserPage extends BasePage { + private readonly actionsMenuButton: Locator = this.page.getByTestId('actions-menu-button'); + private readonly impersonateAction: Locator = this.page.getByRole('menuitem', { + name: /Impersonate user/, + }); + + async navigateToDetails(name: string): Promise { + await this.goTo(`/k8s/cluster/user.openshift.io~v1~User/${name}`); + await expect(this.page.getByRole('heading', { level: 1 }).filter({ hasText: name })).toBeVisible({ + timeout: 60_000, + }); + await this.waitForDetailsActions(this.actionsMenuButton); + } + + async impersonateFromDetails(): Promise { + await this.robustClick(this.actionsMenuButton); + await this.robustClick(this.impersonateAction); + } +} diff --git a/frontend/e2e/tests/console/app/impersonation.spec.ts b/frontend/e2e/tests/console/app/impersonation.spec.ts new file mode 100644 index 00000000000..4fb912fc1a4 --- /dev/null +++ b/frontend/e2e/tests/console/app/impersonation.spec.ts @@ -0,0 +1,192 @@ +import { test, expect } from '../../../fixtures'; +import { warmupSPA } from '../../../pages/base-page'; +import { MastheadPage } from '../../../pages/masthead-page'; +import { ServiceAccountPage } from '../../../pages/service-account-page'; +import { UserPage } from '../../../pages/user-page'; + +test.describe('Impersonation', { tag: ['@admin'] }, () => { + test('can impersonate users and service accounts with groups', async ({ + page, + cleanup, + k8sClient, + }) => { + const suffix = Date.now(); + const namespace = `sa-impersonation-${suffix}`; + const serviceAccountName = `impersonation-target-${suffix}`; + const groupName = `impersonation-group-${suffix}`; + const secondGroupName = `impersonation-group-two-${suffix}`; + const username = `impersonation-user-${suffix}`; + const serviceAccountUsername = `system:serviceaccount:${namespace}:${serviceAccountName}`; + const masthead = new MastheadPage(page); + const serviceAccountPage = new ServiceAccountPage(page); + const userPage = new UserPage(page); + + await test.step('Create service account and group', async () => { + await k8sClient.createNamespace(namespace); + await k8sClient.waitForNamespaceReady(namespace); + cleanup.trackNamespace(namespace); + await k8sClient.coreV1Api.createNamespacedServiceAccount({ + namespace, + body: { metadata: { name: serviceAccountName } }, + }); + await k8sClient.customObjectsApi.createClusterCustomObject({ + group: 'user.openshift.io', + version: 'v1', + plural: 'groups', + body: { + apiVersion: 'user.openshift.io/v1', + kind: 'Group', + metadata: { name: groupName }, + }, + }); + cleanup.trackClusterCustomResource(groupName, 'user.openshift.io', 'v1', 'groups', 'Group'); + await k8sClient.customObjectsApi.createClusterCustomObject({ + group: 'user.openshift.io', + version: 'v1', + plural: 'groups', + body: { + apiVersion: 'user.openshift.io/v1', + kind: 'Group', + metadata: { name: secondGroupName }, + }, + }); + cleanup.trackClusterCustomResource( + secondGroupName, + 'user.openshift.io', + 'v1', + 'groups', + 'Group', + ); + await k8sClient.customObjectsApi.createClusterCustomObject({ + group: 'user.openshift.io', + version: 'v1', + plural: 'users', + body: { + apiVersion: 'user.openshift.io/v1', + kind: 'User', + metadata: { name: username }, + }, + }); + cleanup.trackClusterCustomResource(username, 'user.openshift.io', 'v1', 'users', 'User'); + }); + + await test.step('Impersonate user from masthead modal', async () => { + await warmupSPA(page); + await masthead.impersonateUser(username); + await expect(page.getByText(`You are impersonating User ${username}`)).toBeVisible({ + timeout: 60_000, + }); + }); + + await test.step('Stop impersonating user', async () => { + await masthead.stopImpersonating(); + await expect(page.getByText(`You are impersonating User ${username}`)).toBeHidden({ + timeout: 60_000, + }); + }); + + await test.step('Impersonate user with group from masthead modal', async () => { + await masthead.impersonateUser(username, [groupName]); + await expect(page.getByText(`You are impersonating user ${username}`)).toBeVisible({ + timeout: 60_000, + }); + await expect(page.getByText(`with groups: ${groupName}`)).toBeVisible({ timeout: 60_000 }); + }); + + await test.step('Stop impersonating user with group', async () => { + await masthead.stopImpersonating(); + await expect(page.getByText(`You are impersonating user ${username}`)).toBeHidden({ + timeout: 60_000, + }); + }); + + await test.step('Impersonate user with multiple groups from masthead modal', async () => { + await masthead.impersonateUser(username, [groupName, secondGroupName]); + await expect(page.getByText(`You are impersonating user ${username}`)).toBeVisible({ + timeout: 60_000, + }); + await expect( + page.getByText(`with groups: ${groupName}, ${secondGroupName}`), + ).toBeVisible({ timeout: 60_000 }); + }); + + await test.step('Stop impersonating user with multiple groups', async () => { + await masthead.stopImpersonating(); + await expect(page.getByText(`You are impersonating user ${username}`)).toBeHidden({ + timeout: 60_000, + }); + }); + + await test.step('Impersonate service account from masthead modal', async () => { + await masthead.impersonateServiceAccount(namespace, serviceAccountName); + await expect(page.getByText(`You are impersonating ServiceAccount ${serviceAccountUsername}`)).toBeVisible({ + timeout: 60_000, + }); + }); + + await test.step('Stop impersonating service account', async () => { + await masthead.stopImpersonating(); + await expect(page.getByText(`You are impersonating ServiceAccount ${serviceAccountUsername}`)).toBeHidden({ + timeout: 60_000, + }); + }); + + await test.step('Impersonate service account with group from masthead modal', async () => { + await masthead.impersonateServiceAccount(namespace, serviceAccountName, [groupName]); + await expect(page.getByText(`You are impersonating ServiceAccount ${serviceAccountUsername}`)).toBeVisible({ + timeout: 60_000, + }); + await expect(page.getByText(`with groups: ${groupName}`)).toBeVisible({ timeout: 60_000 }); + }); + + await test.step('Stop impersonating service account with group', async () => { + await masthead.stopImpersonating(); + await expect(page.getByText(`You are impersonating ServiceAccount ${serviceAccountUsername}`)).toBeHidden({ + timeout: 60_000, + }); + }); + + await test.step('Impersonate service account with multiple groups from masthead modal', async () => { + await masthead.impersonateServiceAccount(namespace, serviceAccountName, [ + groupName, + secondGroupName, + ]); + await expect(page.getByText(`You are impersonating ServiceAccount ${serviceAccountUsername}`)).toBeVisible({ + timeout: 60_000, + }); + await expect( + page.getByText(`with groups: ${groupName}, ${secondGroupName}`), + ).toBeVisible({ timeout: 60_000 }); + }); + + await test.step('Stop impersonating service account with multiple groups', async () => { + await masthead.stopImpersonating(); + await expect(page.getByText(`You are impersonating ServiceAccount ${serviceAccountUsername}`)).toBeHidden({ + timeout: 60_000, + }); + }); + + await test.step('Impersonate service account from resource details action', async () => { + await serviceAccountPage.navigateToDetails(namespace, serviceAccountName); + await serviceAccountPage.impersonateFromDetails(); + await expect(page.getByText(`You are impersonating ServiceAccount ${serviceAccountUsername}`)).toBeVisible({ + timeout: 60_000, + }); + }); + + await test.step('Stop impersonating service account', async () => { + await masthead.stopImpersonating(); + await expect(page.getByText(`You are impersonating ServiceAccount ${serviceAccountUsername}`)).toBeHidden({ + timeout: 60_000, + }); + }); + + await test.step('Impersonate user from resource details action', async () => { + await userPage.navigateToDetails(username); + await userPage.impersonateFromDetails(); + await expect(page.getByText(`You are impersonating User ${username}`)).toBeVisible({ + timeout: 60_000, + }); + }); + }); +}); diff --git a/frontend/packages/console-app/console-extensions.json b/frontend/packages/console-app/console-extensions.json index 9f1a29d4869..ebb173c4aff 100644 --- a/frontend/packages/console-app/console-extensions.json +++ b/frontend/packages/console-app/console-extensions.json @@ -2686,7 +2686,7 @@ "version": "v1", "kind": "ServiceAccount" }, - "provider": { "$codeRef": "defaultProvider.useDefaultActionsProvider" } + "provider": { "$codeRef": "serviceAccountProvider.useServiceAccountActionsProvider" } } }, { diff --git a/frontend/packages/console-app/locales/en/console-app.json b/frontend/packages/console-app/locales/en/console-app.json index a65dd7dab3f..61ddd94c053 100644 --- a/frontend/packages/console-app/locales/en/console-app.json +++ b/frontend/packages/console-app/locales/en/console-app.json @@ -354,6 +354,7 @@ "ImageStreams": "ImageStreams", "Impersonate {{kind}} \"{{name}}\"": "Impersonate {{kind}} \"{{name}}\"", "Impersonate Group {{name}}": "Impersonate Group {{name}}", + "Impersonate service account {{name}}": "Impersonate service account {{name}}", "Impersonate user {{name}}": "Impersonate user {{name}}", "In progress": "In progress", "In progress ({{statusCount, number}})": "In progress ({{statusCount, number}})", diff --git a/frontend/packages/console-app/package.json b/frontend/packages/console-app/package.json index f6ce139c921..2f38b180191 100644 --- a/frontend/packages/console-app/package.json +++ b/frontend/packages/console-app/package.json @@ -99,7 +99,8 @@ "customResourceDefinitionProvider": "src/actions/providers/custom-resource-definition-provider.ts", "machineConfigPoolProvider": "src/actions/providers/machine-config-pool-provider.ts", "serviceMonitorProvider": "src/actions/providers/service-monitor-provider.ts", - "userProvider": "src/actions/providers/user-provider.ts" + "userProvider": "src/actions/providers/user-provider.ts", + "serviceAccountProvider": "src/actions/providers/service-account-provider.ts" } } } diff --git a/frontend/packages/console-app/src/actions/providers/service-account-provider.ts b/frontend/packages/console-app/src/actions/providers/service-account-provider.ts new file mode 100644 index 00000000000..f4c60b0a333 --- /dev/null +++ b/frontend/packages/console-app/src/actions/providers/service-account-provider.ts @@ -0,0 +1,65 @@ +import { useMemo } from 'react'; +import { useTranslation } from 'react-i18next'; +import { useNavigate } from 'react-router'; +import type { ExtensionHook } from '@console/dynamic-plugin-sdk/src/api/common-types'; +import type { Action } from '@console/dynamic-plugin-sdk/src/extensions/actions'; +import type { K8sResourceKind } from '@console/dynamic-plugin-sdk/src/extensions/console-types'; +import * as UIActions from '@console/internal/actions/ui'; +import { asAccessReview } from '@console/internal/components/utils'; +import { ServiceAccountModel } from '@console/internal/models'; +import { referenceFor } from '@console/internal/module/k8s'; +import { useConsoleDispatch } from '@console/shared/src/hooks/useConsoleDispatch'; +import { useK8sModel } from '@console/shared/src/hooks/useK8sModel'; +import { useCommonResourceActions } from '../hooks/useCommonResourceActions'; + +const serviceAccountUsername = (namespace: string | undefined, name: string | undefined) => + `system:serviceaccount:${namespace}:${name}`; + +const useImpersonateAction = (resource: K8sResourceKind): Action[] => { + const { t } = useTranslation('console-app'); + const navigate = useNavigate(); + const dispatch = useConsoleDispatch(); + + const factory = useMemo( + () => ({ + ImpersonateServiceAccount: () => ({ + id: 'impersonate-service-account', + label: t('Impersonate service account {{name}}', { name: resource?.metadata?.name }), + cta: () => { + dispatch( + UIActions.startImpersonate( + 'ServiceAccount', + serviceAccountUsername(resource?.metadata?.namespace, resource?.metadata?.name), + ), + ); + navigate(window.SERVER_FLAGS.basePath); + }, + accessReview: asAccessReview(ServiceAccountModel, resource, 'impersonate'), + }), + }), + [dispatch, navigate, resource, t], + ); + + return useMemo( + () => + resource?.metadata?.namespace && resource?.metadata?.name + ? [factory.ImpersonateServiceAccount()] + : [], + [factory, resource?.metadata?.name, resource?.metadata?.namespace], + ); +}; + +export const useServiceAccountActionsProvider: ExtensionHook = ( + resource, +) => { + const [kindObj, inFlight] = useK8sModel(referenceFor(resource)); + const impersonateAction = useImpersonateAction(resource); + const commonActions = useCommonResourceActions(kindObj, resource); + + const actions = useMemo( + () => [...impersonateAction, ...commonActions], + [commonActions, impersonateAction], + ); + + return [actions, !inFlight, false]; +}; diff --git a/frontend/packages/console-shared/src/utils/console-fetch-utils.ts b/frontend/packages/console-shared/src/utils/console-fetch-utils.ts index 9212fe85361..59a43b1eb62 100644 --- a/frontend/packages/console-shared/src/utils/console-fetch-utils.ts +++ b/frontend/packages/console-shared/src/utils/console-fetch-utils.ts @@ -34,16 +34,25 @@ export const getConsoleRequestHeaders: GetConsoleRequestHeaders = () => { if (impersonateData) { const { kind, name, groups } = impersonateData; - if (kind === 'User' && name) { - // Simple user impersonation + if ( + (kind === 'User' || kind === 'ServiceAccount') && + name && + (!groups || groups.length === 0) + ) { + // Simple user or service account impersonation headers['Impersonate-User'] = name; } else if (kind === 'Group' && name) { // Single group impersonation (backward compatibility) // Even if we are impersonating a group, we still need to set Impersonate-User to something or k8s will complain headers['Impersonate-User'] = name; headers['Impersonate-Group'] = name; - } else if (kind === 'UserWithGroups' && name && groups && groups.length > 0) { - // User with multiple groups impersonation + } else if ( + (kind === 'UserWithGroups' || kind === 'ServiceAccount') && + name && + groups && + groups.length > 0 + ) { + // User or service account with multiple groups impersonation headers['Impersonate-User'] = name; // Note: This creates an array of values for the same header key headers['Impersonate-Group'] = groups; diff --git a/frontend/public/actions/ui.ts b/frontend/public/actions/ui.ts index 393c0ee83e2..641f39c1b5a 100644 --- a/frontend/public/actions/ui.ts +++ b/frontend/public/actions/ui.ts @@ -190,22 +190,24 @@ export const startImpersonate = const imp = getImpersonate(getState()); if ((imp?.name && imp.name !== name) || (imp?.kind && imp.kind !== kind)) { // eslint-disable-next-line no-console - console.warn(`Impersonate race detected: ${name} vs ${imp.name} / ${kind} ${imp.kind}`); + console.warn('Impersonate race detected. Ignoring stale impersonation request.'); return; } const encodedName = encodeImpersonationValue(name, textEncoder); let subprotocols; - if (kind === 'User') { + if ((kind === 'User' || kind === 'ServiceAccount') && (!groups || groups.length === 0)) { subprotocols = [`Impersonate-User.${encodedName}`]; } else if (kind === 'Group') { subprotocols = [`Impersonate-Group.${encodedName}`]; - } else if (kind === 'UserWithGroups' && groups && groups.length > 0) { - // User with multiple groups impersonation - // Encode user subprotocol + } else if ( + (kind === 'UserWithGroups' || kind === 'ServiceAccount') && + groups && + groups.length > 0 + ) { + // User or service account with multiple groups impersonation subprotocols = [`Impersonate-User.${encodedName}`]; - // Encode each group as a separate subprotocol groups.forEach((group) => { const encodedGroup = encodeImpersonationValue(group, textEncoder); subprotocols.push(`Impersonate-Group.${encodedGroup}`); diff --git a/frontend/public/components/impersonate-notifier.tsx b/frontend/public/components/impersonate-notifier.tsx index 0ab6e919ee3..0fdf959fc06 100644 --- a/frontend/public/components/impersonate-notifier.tsx +++ b/frontend/public/components/impersonate-notifier.tsx @@ -46,7 +46,7 @@ export const ImpersonateNotifier = connect( // Enhanced group display with tooltip for many groups const MAX_GROUPS_DISPLAY = 2; const groups = impersonate.groups || []; - const hasGroups = isUserWithGroups && groups.length > 0; + const hasGroups = groups.length > 0; const visibleGroups = groups.slice(0, MAX_GROUPS_DISPLAY); const remainingCount = Math.max(0, groups.length - MAX_GROUPS_DISPLAY); diff --git a/frontend/public/components/masthead/masthead-toolbar.tsx b/frontend/public/components/masthead/masthead-toolbar.tsx index 14ad935b147..8d10e8427f4 100644 --- a/frontend/public/components/masthead/masthead-toolbar.tsx +++ b/frontend/public/components/masthead/masthead-toolbar.tsx @@ -863,11 +863,13 @@ const MastheadToolbarContents: FC = ({ setIsImpersonateModalOpen(false)} - onImpersonate={(userName: string, groups: string[]) => { - if (groups && groups.length > 0) { + onImpersonate={(userName: string, groups: string[], kind: 'User' | 'ServiceAccount') => { + if (kind === 'ServiceAccount') { + dispatch(UIActions.startImpersonate(kind, userName, groups)); + } else if (groups && groups.length > 0) { dispatch(UIActions.startImpersonate('UserWithGroups', userName, groups)); } else { - dispatch(UIActions.startImpersonate('User', userName)); + dispatch(UIActions.startImpersonate(kind, userName)); } setIsImpersonateModalOpen(false); // Redirect to projects page to prevent RBAC issues for impersonated users diff --git a/frontend/public/components/modals/__tests__/impersonate-user-modal-integration.spec.tsx b/frontend/public/components/modals/__tests__/impersonate-user-modal-integration.spec.tsx index c935378c0c2..20641d7d357 100644 --- a/frontend/public/components/modals/__tests__/impersonate-user-modal-integration.spec.tsx +++ b/frontend/public/components/modals/__tests__/impersonate-user-modal-integration.spec.tsx @@ -75,7 +75,7 @@ describe('ImpersonateUserModal Integration Tests', () => { await user.click(submitButton); await waitFor(() => { - expect(onImpersonate).toHaveBeenCalledWith('testuser', []); + expect(onImpersonate).toHaveBeenCalledWith('testuser', [], 'User'); expect(mockStartImpersonate).toHaveBeenCalledWith('User', 'testuser'); }); }); @@ -113,7 +113,7 @@ describe('ImpersonateUserModal Integration Tests', () => { await user.click(submitButton); await waitFor(() => { - expect(onImpersonate).toHaveBeenCalledWith('multiuser', ['developers']); + expect(onImpersonate).toHaveBeenCalledWith('multiuser', ['developers'], 'User'); expect(mockStartImpersonate).toHaveBeenCalledWith('UserWithGroups', 'multiuser', [ 'developers', ]); @@ -169,7 +169,7 @@ describe('ImpersonateUserModal Integration Tests', () => { await user.click(submitButton); await waitFor(() => { - expect(onImpersonate).toHaveBeenCalledWith('groupuser', ['developers', 'admins']); + expect(onImpersonate).toHaveBeenCalledWith('groupuser', ['developers', 'admins'], 'User'); }); }); @@ -218,7 +218,7 @@ describe('ImpersonateUserModal Integration Tests', () => { await user.click(submitButton); await waitFor(() => { - expect(onImpersonate).toHaveBeenCalledWith('deselectuser', []); + expect(onImpersonate).toHaveBeenCalledWith('deselectuser', [], 'User'); }); }); }); diff --git a/frontend/public/components/modals/__tests__/impersonate-user-modal.spec.tsx b/frontend/public/components/modals/__tests__/impersonate-user-modal.spec.tsx index 1f2de4520e8..3bb8a2b37f1 100644 --- a/frontend/public/components/modals/__tests__/impersonate-user-modal.spec.tsx +++ b/frontend/public/components/modals/__tests__/impersonate-user-modal.spec.tsx @@ -9,6 +9,53 @@ jest.mock('../../utils/k8s-watch-hook', () => ({ useK8sWatchResource: jest.fn(), })); +// Stub NsDropdown: emits a fixed namespace selection on click +jest.mock('../../utils/list-dropdown', () => ({ + NsDropdown: ({ + selectedKey, + onChange, + dataTest, + }: { + selectedKey?: string; + dataTest?: string; + onChange: (key: string, kind?: string, resource?: { metadata: { name: string } }) => void; + }) => ( + + ), +})); + +// Stub ResourceDropdown: emits a fixed service account selection on click +jest.mock('@console/shared/src/components/dropdown/ResourceDropdown', () => ({ + ResourceDropdown: ({ + selectedKey, + onChange, + dataTest, + disabled, + placeholder, + }: { + selectedKey?: string | null; + dataTest?: string; + disabled?: boolean; + placeholder?: string; + onChange: (key: string, name?: string, resource?: { metadata: { name: string } }) => void; + }) => ( + + ), +})); + const mockGroups: GroupKind[] = [ { apiVersion: 'user.openshift.io/v1', @@ -42,14 +89,35 @@ const mockGroups: GroupKind[] = [ }, ]; +const mockServiceAccounts = [ + { + apiVersion: 'v1', + kind: 'ServiceAccount', + metadata: { name: 'builder', namespace: 'test-ns', uid: 'sa-1', resourceVersion: '1' }, + }, + { + apiVersion: 'v1', + kind: 'ServiceAccount', + metadata: { name: 'deployer', namespace: 'test-ns', uid: 'sa-2', resourceVersion: '1' }, + }, +]; + describe('ImpersonateUserModal', () => { const mockOnClose = jest.fn(); const mockOnImpersonate = jest.fn(); beforeEach(() => { jest.clearAllMocks(); - // Default mock: groups loaded successfully - (useK8sWatchResource as jest.Mock).mockReturnValue([mockGroups, true, null]); + // Default mock: groups and service accounts loaded successfully + (useK8sWatchResource as jest.Mock).mockImplementation((resource) => { + if (!resource) { + return [[], true, null]; + } + if (resource.groupVersionKind?.kind === 'ServiceAccount') { + return [mockServiceAccounts, true, null]; + } + return [mockGroups, true, null]; + }); }); describe('Basic Rendering', () => { @@ -192,7 +260,7 @@ describe('ImpersonateUserModal', () => { await user.click(submitButton); await waitFor(() => { - expect(mockOnImpersonate).toHaveBeenCalledWith('testuser', []); + expect(mockOnImpersonate).toHaveBeenCalledWith('testuser', [], 'User'); }); }); @@ -210,10 +278,90 @@ describe('ImpersonateUserModal', () => { await user.click(submitButton); await waitFor(() => { - expect(mockOnImpersonate).toHaveBeenCalledWith('testuser', []); + expect(mockOnImpersonate).toHaveBeenCalledWith('testuser', [], 'User'); }); }); + it('should call onImpersonate with service account username and groups', async () => { + const user = userEvent.setup(); + render( + , + ); + + await user.click(screen.getByTestId('impersonate-kind-service-account')); + + // Select namespace and service account from the dropdowns + await user.click(screen.getByTestId('service-account-namespace-dropdown')); + await user.click(screen.getByTestId('service-account-name-dropdown')); + + const groupInput = screen.getByPlaceholderText('Enter groups'); + await user.click(groupInput); + await user.click(await screen.findByText('developers')); + + await user.click(screen.getByTestId('impersonate-button')); + + await waitFor(() => { + expect(mockOnImpersonate).toHaveBeenCalledWith( + 'system:serviceaccount:test-ns:builder', + ['developers'], + 'ServiceAccount', + ); + }); + }); + + it('should disable the service account name dropdown until a namespace is selected', async () => { + const user = userEvent.setup(); + render( + , + ); + + await user.click(screen.getByTestId('impersonate-kind-service-account')); + expect(screen.getByTestId('service-account-name-dropdown')).toBeDisabled(); + + await user.click(screen.getByTestId('service-account-namespace-dropdown')); + expect(screen.getByTestId('service-account-name-dropdown')).toBeEnabled(); + }); + + it('should clear the selected service account when the namespace changes', async () => { + const user = userEvent.setup(); + render( + , + ); + + await user.click(screen.getByTestId('impersonate-kind-service-account')); + await user.click(screen.getByTestId('service-account-namespace-dropdown')); + + const nameDropdown = screen.getByTestId('service-account-name-dropdown'); + await user.click(nameDropdown); + expect(nameDropdown).toHaveTextContent('builder'); + + // Selecting the namespace again resets the previously selected service account + await user.click(screen.getByTestId('service-account-namespace-dropdown')); + expect(nameDropdown).toHaveTextContent('Select a service account'); + }); + + it('should watch service accounts for the selected namespace only', async () => { + const user = userEvent.setup(); + render( + , + ); + + await user.click(screen.getByTestId('impersonate-kind-service-account')); + + // No cluster-wide watch before a namespace is chosen + expect(useK8sWatchResource).toHaveBeenLastCalledWith(null); + + await user.click(screen.getByTestId('service-account-namespace-dropdown')); + + expect(useK8sWatchResource).toHaveBeenLastCalledWith( + expect.objectContaining({ + groupVersionKind: expect.objectContaining({ kind: 'ServiceAccount' }), + namespace: 'test-ns', + isList: true, + }), + ); + }); + it('should close modal after successful submission', async () => { const user = userEvent.setup(); render( diff --git a/frontend/public/components/modals/impersonate-user-modal.tsx b/frontend/public/components/modals/impersonate-user-modal.tsx index 902e7210d9a..0e23ef3750a 100644 --- a/frontend/public/components/modals/impersonate-user-modal.tsx +++ b/frontend/public/components/modals/impersonate-user-modal.tsx @@ -26,21 +26,26 @@ import { HelperTextItem, Flex, FlexItem, + Radio, } from '@patternfly/react-core'; import { RhUiCloseIcon, RhUiErrorFillIcon } from '@patternfly/react-icons'; import { useTranslation } from 'react-i18next'; -import { GroupModel } from '../../models'; -import type { GroupKind } from '../../module/k8s'; +import { ResourceDropdown } from '@console/shared/src/components/dropdown/ResourceDropdown'; +import { GroupModel, ServiceAccountModel } from '../../models'; +import type { GroupKind, K8sResourceKind } from '../../module/k8s'; import { FieldLevelHelp } from '../utils/field-level-help'; import { useK8sWatchResource } from '../utils/k8s-watch-hook'; +import { NsDropdown } from '../utils/list-dropdown'; const SELECT_ALL_KEY = '__select_all__'; const MAX_VISIBLE_CHIPS = 5; +type ImpersonateSubjectKind = 'User' | 'ServiceAccount'; + export interface ImpersonateUserModalProps { isOpen: boolean; onClose: () => void; - onImpersonate: (username: string, groups: string[]) => void; + onImpersonate: (username: string, groups: string[], kind: ImpersonateSubjectKind) => void; prefilledUsername?: string; isUsernameReadonly?: boolean; } @@ -53,9 +58,14 @@ export const ImpersonateUserModal: FC = ({ isUsernameReadonly = false, }) => { const { t } = useTranslation('public'); + const [impersonateKind, setImpersonateKind] = useState('User'); const [username, setUsername] = useState(prefilledUsername); + const [serviceAccountNamespace, setServiceAccountNamespace] = useState(''); + const [serviceAccountName, setServiceAccountName] = useState(''); const [selectedGroups, setSelectedGroups] = useState([]); const [usernameError, setUsernameError] = useState(''); + const [serviceAccountNamespaceError, setServiceAccountNamespaceError] = useState(''); + const [serviceAccountNameError, setServiceAccountNameError] = useState(''); const [isGroupSelectOpen, setIsGroupSelectOpen] = useState(false); const [showAllGroups, setShowAllGroups] = useState(false); const [groupSearchFilter, setGroupSearchFilter] = useState(''); @@ -78,10 +88,44 @@ export const ImpersonateUserModal: FC = ({ return groups.map((group) => group.metadata.name).sort(); }, [groups, groupsLoaded, groupsLoadError]); + // Fetch available service accounts from the selected namespace. + // Pass `null` until a namespace is chosen to avoid a cluster-wide watch. + const [watchedServiceAccounts, serviceAccountsLoaded, serviceAccountsLoadError] = + useK8sWatchResource( + serviceAccountNamespace + ? { + groupVersionKind: { + group: ServiceAccountModel.apiGroup, + version: ServiceAccountModel.apiVersion, + kind: ServiceAccountModel.kind, + }, + namespace: serviceAccountNamespace, + isList: true, + } + : null, + ); + + const serviceAccounts = useMemo( + () => [ + { + data: watchedServiceAccounts ?? [], + loaded: serviceAccountsLoaded, + loadError: serviceAccountsLoadError, + kind: ServiceAccountModel.kind, + }, + ], + [watchedServiceAccounts, serviceAccountsLoaded, serviceAccountsLoadError], + ); + const handleClose = useCallback(() => { + setImpersonateKind('User'); setUsername(prefilledUsername); + setServiceAccountNamespace(''); + setServiceAccountName(''); setSelectedGroups([]); setUsernameError(''); + setServiceAccountNamespaceError(''); + setServiceAccountNameError(''); onClose(); }, [prefilledUsername, onClose]); @@ -140,16 +184,43 @@ export const ImpersonateUserModal: FC = ({ }; const validateForm = (): boolean => { - if (!username.trim()) { + setUsernameError(''); + setServiceAccountNamespaceError(''); + setServiceAccountNameError(''); + + if (impersonateKind === 'User' && !username.trim()) { setUsernameError(t('Username is required')); return false; } + + if (impersonateKind === 'ServiceAccount') { + // Namespace and name are selected from existing resources, so only + // presence is validated as a safeguard. + let isValid = true; + + if (!serviceAccountNamespace.trim()) { + setServiceAccountNamespaceError(t('Service account namespace is required')); + isValid = false; + } + + if (!serviceAccountName.trim()) { + setServiceAccountNameError(t('Service account name is required')); + isValid = false; + } + + return isValid; + } + return true; }; const handleImpersonate = () => { if (validateForm()) { - onImpersonate(username.trim(), selectedGroups); + const impersonateUsername = + impersonateKind === 'ServiceAccount' + ? `system:serviceaccount:${serviceAccountNamespace.trim()}:${serviceAccountName.trim()}` + : username.trim(); + onImpersonate(impersonateUsername, selectedGroups, impersonateKind); handleClose(); } }; @@ -157,9 +228,14 @@ export const ImpersonateUserModal: FC = ({ // Reset form when modal opens with new prefilled username useEffect(() => { if (isOpen) { + setImpersonateKind('User'); setUsername(prefilledUsername); + setServiceAccountNamespace(''); + setServiceAccountName(''); setSelectedGroups([]); setUsernameError(''); + setServiceAccountNamespaceError(''); + setServiceAccountNameError(''); setGroupSearchFilter(''); setShowAllGroups(false); } @@ -185,6 +261,11 @@ export const ImpersonateUserModal: FC = ({ const textInputGroupRef = useRef(null); + const isImpersonateDisabled = + impersonateKind === 'ServiceAccount' + ? !serviceAccountNamespace.trim() || !serviceAccountName.trim() + : !username.trim(); + const toggle = (toggleRef: Ref) => ( = ({ variant={AlertVariant.warning} isInline title={t( - 'Impersonating a user grants you their exact permissions. You must enter username, but you can also enter a group to simulate the permissions of a member of that group.', + 'Impersonating a user or service account grants you their exact permissions. You must enter a username or service account, but you can also enter a group to simulate the permissions of a member of that group.', )} /> + + { + setImpersonateKind('User'); + setUsernameError(''); + setServiceAccountNamespaceError(''); + setServiceAccountNameError(''); + }} + data-test="impersonate-kind-user" + /> + { + setImpersonateKind('ServiceAccount'); + setUsernameError(''); + setServiceAccountNamespaceError(''); + setServiceAccountNameError(''); + }} + data-test="impersonate-kind-service-account" + /> + + {groupsLoadError && ( {groupsLoadError.message} )} - - {t('Username')} - {t('The name of the user to impersonate')} - - } - fieldId="impersonate-username" - isRequired - > - handleUsernameChange(value)} - readOnly={isUsernameReadonly} - placeholder={t('Enter a username')} - data-test="username-input" - validated={usernameError ? 'error' : 'default'} - aria-label={t('Username to impersonate')} - aria-describedby="username-help-text" - /> - {usernameError && ( - - - }> - {usernameError} - - - - )} - + {impersonateKind === 'User' ? ( + + {t('Username')} + {t('The name of the user to impersonate')} + + } + fieldId="impersonate-username" + isRequired + > + handleUsernameChange(value)} + readOnly={isUsernameReadonly} + placeholder={t('Enter a username')} + data-test="username-input" + validated={usernameError ? 'error' : 'default'} + aria-label={t('Username to impersonate')} + aria-describedby="username-help-text" + /> + {usernameError && ( + + + }> + {usernameError} + + + + )} + + ) : ( + <> + + { + setServiceAccountNamespace(resource?.metadata?.name ?? ''); + setServiceAccountNamespaceError(''); + // The service accounts of the previously selected namespace no longer apply + setServiceAccountName(''); + setServiceAccountNameError(''); + }} + dataTest="service-account-namespace-dropdown" + /> + {serviceAccountNamespaceError && ( + + + }> + {serviceAccountNamespaceError} + + + + )} + + + { + setServiceAccountName(key ?? ''); + setServiceAccountNameError(''); + }} + dataTest="service-account-name-dropdown" + disabled={!serviceAccountNamespace} + ariaLabel={t('Service account name to impersonate')} + isFullWidth + /> + {serviceAccountNameError && ( + + + }> + {serviceAccountNameError} + + + + )} + + + )} = ({ key="impersonate" variant="primary" onClick={handleImpersonate} - isDisabled={!username.trim()} + isDisabled={isImpersonateDisabled} data-test="impersonate-button" > {t('Impersonate')} diff --git a/frontend/public/locales/en/public.json b/frontend/public/locales/en/public.json index 7078a415270..dabe9c1b96d 100644 --- a/frontend/public/locales/en/public.json +++ b/frontend/public/locales/en/public.json @@ -797,7 +797,7 @@ "Immutable, if set to true, ensures that data stored in the ConfigMap cannot be updated": "Immutable, if set to true, ensures that data stored in the ConfigMap cannot be updated", "Impersonate": "Impersonate", "Impersonate user": "Impersonate user", - "Impersonating a user grants you their exact permissions. You must enter username, but you can also enter a group to simulate the permissions of a member of that group.": "Impersonating a user grants you their exact permissions. You must enter username, but you can also enter a group to simulate the permissions of a member of that group.", + "Impersonating a user or service account grants you their exact permissions. You must enter a username or service account, but you can also enter a group to simulate the permissions of a member of that group.": "Impersonating a user or service account grants you their exact permissions. You must enter a username or service account, but you can also enter a group to simulate the permissions of a member of that group.", "Import code from your Git repository to be built and deployed": "Import code from your Git repository to be built and deployed", "Import from Git": "Import from Git", "Import more YAML": "Import more YAML", @@ -1363,6 +1363,7 @@ "Select a configuration to receive updates. Updates can be configured to receive information from Red Hat or a custom update service.": "Select a configuration to receive updates. Updates can be configured to receive information from Red Hat or a custom update service.", "Select a key": "Select a key", "Select a resource": "Select a resource", + "Select a service account": "Select a service account", "Select a version": "Select a version", "Select a workload": "Select a workload", "Select all": "Select all", @@ -1394,6 +1395,11 @@ "Send resolved alerts to this receiver?": "Send resolved alerts to this receiver?", "Served": "Served", "Service": "Service", + "Service account name": "Service account name", + "Service account name is required": "Service account name is required", + "Service account name to impersonate": "Service account name to impersonate", + "Service account namespace": "Service account namespace", + "Service account namespace is required": "Service account namespace is required", "Service Account Token": "Service Account Token", "Service key": "Service key", "Service Level Agreement (SLA)": "Service Level Agreement (SLA)",