Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
58 changes: 58 additions & 0 deletions frontend/e2e/pages/masthead-page.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 serviceAccountNamespaceInput: Locator = this.page.getByTestId(
'service-account-namespace-input',
);
private readonly serviceAccountNameInput: Locator = this.page.getByTestId(
'service-account-name-input',
);
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');
Expand Down Expand Up @@ -40,6 +54,50 @@ export class MastheadPage extends BasePage {
await this.userDropdownToggle.click();
}

private async selectGroups(groups: string[]): Promise<void> {
for (const group of groups) {
await this.groupInput.click();
const groupOption = this.page.getByText(group, { exact: true });
await this.robustClick(groupOption);
}
await this.page.mouse.click(20, 20);
}

async impersonateUser(username: string, groups: string[] = []): Promise<void> {
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<void> {
await this.openUserDropdown();
await this.robustClick(this.impersonateUserItem);
await this.robustClick(this.serviceAccountRadio);
await this.serviceAccountNamespaceInput.fill(namespace);
await this.serviceAccountNameInput.fill(name);
await this.selectGroups(groups);
await this.robustClick(this.impersonateButton);
}

async stopImpersonating(): Promise<void> {
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<boolean> {
return this.page.evaluate(() => {
const w = window as Window & { SERVER_FLAGS?: { authDisabled?: boolean } };
Expand Down
22 changes: 22 additions & 0 deletions frontend/e2e/pages/service-account-page.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
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<void> {
await this.goTo(`/k8s/ns/${namespace}/~v1~ServiceAccount/${name}`);
await expect(this.page.getByRole('heading', { level: 1 }).filter({ hasText: name })).toBeVisible({
timeout: 60_000,
});
}

async impersonateFromDetails(): Promise<void> {
await this.robustClick(this.actionsMenuButton);
await this.robustClick(this.impersonateAction);
}
}
109 changes: 109 additions & 0 deletions frontend/e2e/tests/console/app/impersonation.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,109 @@
import { test, expect } from '../../../fixtures';
import { warmupSPA } from '../../../pages/base-page';
import { MastheadPage } from '../../../pages/masthead-page';
import { ServiceAccountPage } from '../../../pages/service-account-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 username = `impersonation-user-${suffix}`;
const serviceAccountUsername = `system:serviceaccount:${namespace}:${serviceAccountName}`;
const masthead = new MastheadPage(page);
const serviceAccountPage = new ServiceAccountPage(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 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 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 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,
});
});
});
});
2 changes: 1 addition & 1 deletion frontend/packages/console-app/console-extensions.json
Original file line number Diff line number Diff line change
Expand Up @@ -2686,7 +2686,7 @@
"version": "v1",
"kind": "ServiceAccount"
},
"provider": { "$codeRef": "defaultProvider.useDefaultActionsProvider" }
"provider": { "$codeRef": "serviceAccountProvider.useServiceAccountActionsProvider" }
}
},
{
Expand Down
1 change: 1 addition & 0 deletions frontend/packages/console-app/locales/en/console-app.json
Original file line number Diff line number Diff line change
Expand Up @@ -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}})",
Expand Down
3 changes: 2 additions & 1 deletion frontend/packages/console-app/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
}
}
}
Original file line number Diff line number Diff line change
@@ -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<Action[]>(
() =>
resource?.metadata?.namespace && resource?.metadata?.name
? [factory.ImpersonateServiceAccount()]
: [],
[factory, resource?.metadata?.name, resource?.metadata?.namespace],
);
};

export const useServiceAccountActionsProvider: ExtensionHook<Action[], K8sResourceKind> = (
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];
};
17 changes: 13 additions & 4 deletions frontend/packages/console-shared/src/utils/console-fetch-utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
14 changes: 8 additions & 6 deletions frontend/public/actions/ui.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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}`);
Expand Down
2 changes: 1 addition & 1 deletion frontend/public/components/impersonate-notifier.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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);

Expand Down
8 changes: 5 additions & 3 deletions frontend/public/components/masthead/masthead-toolbar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -863,11 +863,13 @@ const MastheadToolbarContents: FC<MastheadToolbarContentsProps> = ({
<ImpersonateUserModal
isOpen={isImpersonateModalOpen}
onClose={() => 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
Expand Down
Loading