This commit is contained in:
Savelii 2026-07-27 08:15:06 +00:00 committed by GitHub
commit de229fb0e5
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
14 changed files with 353 additions and 42 deletions

View File

@ -61,8 +61,9 @@ export class ApiKeysController {
};
}
// `apiKey:manage` callers see every key by default; `ownership=mine` narrows to own.
@GlobalScope('apiKey:list')
// Every authenticated user may list their own keys. The service only
// includes other users' keys for `apiKey:manage` callers; `ownership=mine`
// narrows back to own.
@Get('/', { middlewares: [isApiEnabledMiddleware] })
async getApiKeys(req: AuthenticatedRequest, _res: Response, @Query query: ListApiKeysQueryDto) {
return await this.publicApiKeyService.getRedactedApiKeys(req.user, {
@ -75,8 +76,8 @@ export class ApiKeysController {
});
}
// Members can delete their own keys; `apiKey:manage` holders can revoke anyone's.
@GlobalScope('apiKey:delete')
// No role scope required: own keys are always revocable. The service
// restricts deleting other users' keys to `apiKey:manage` holders.
@Delete('/:id', { middlewares: [isApiEnabledMiddleware] })
async deleteApiKey(req: AuthenticatedRequest, _res: Response, @Param('id') apiKeyId: string) {
const { isOwn } = await this.publicApiKeyService.deleteApiKey(req.user, apiKeyId);
@ -124,7 +125,8 @@ export class ApiKeysController {
};
}
@GlobalScope('apiKey:list')
// No role scope required: returns the scopes the caller's role can assign
// to a key — empty-ish for roles without apiKey grants.
@Get('/scopes', { middlewares: [isApiEnabledMiddleware] })
async getApiKeyScopes(req: AuthenticatedRequest, _res: Response) {
const scopes = getApiKeyScopesForRole(req.user);

View File

@ -20,6 +20,7 @@ import {
createUser,
createUserShell,
} from './shared/db/users';
import { createCustomRoleWithScopeSlugs } from './shared/db/roles';
import type { SuperAgentTest } from './shared/types';
import * as utils from './shared/utils/';
@ -843,3 +844,89 @@ describe('Cross-user behavior (admin scope)', () => {
expect(response.body.data.totals.all).toBe(4);
});
});
describe('User with a role that grants no apiKey scopes', () => {
let restrictedUser: User;
let authAgent: SuperAgentTest;
beforeEach(async () => {
const restrictedRole = await createCustomRoleWithScopeSlugs(['role:manageProject'], {
roleType: 'global',
});
restrictedUser = await createUser({
password: randomValidPassword(),
role: restrictedRole,
});
authAgent = testServer.authAgentFor(restrictedUser);
});
test('GET /api-keys should return own keys', async () => {
const ownKey = await addApiKey(restrictedUser, { scopes: ['workflow:create'] });
const response = await authAgent.get('/api-keys').expect(200);
expect(response.body.data.counts.all).toBe(1);
expect(response.body.data.items).toHaveLength(1);
expect(response.body.data.items[0].id).toBe(ownKey.id);
});
test("GET /api-keys should never include other users' keys", async () => {
await createOwnerWithApiKey();
await addApiKey(restrictedUser, { scopes: ['workflow:create'] });
const response = await authAgent.get('/api-keys?ownership=all').expect(200);
expect(response.body.data.items).toHaveLength(1);
expect(
response.body.data.items.every(
(apiKey: { userId: string }) => apiKey.userId === restrictedUser.id,
),
).toBe(true);
expect(response.body.data.owners).toEqual([]);
});
test('DELETE /api-keys/:id should revoke an own key', async () => {
const ownKey = await addApiKey(restrictedUser, { scopes: ['workflow:create'] });
const response = await authAgent.delete(`/api-keys/${ownKey.id}`).expect(200);
expect(response.body.data.success).toBe(true);
const remaining = await authAgent.get('/api-keys').expect(200);
expect(remaining.body.data.items).toHaveLength(0);
});
test("DELETE /api-keys/:id should 404 for another user's key", async () => {
const otherUser = await createOwnerWithApiKey();
await authAgent.delete(`/api-keys/${otherUser.apiKeys[0].id}`).expect(404);
});
test('POST /api-keys should be forbidden', async () => {
await authAgent
.post('/api-keys')
.send({ label: 'My API Key', expiresAt: null, scopes: ['workflow:create'] })
.expect(403);
});
test('PATCH /api-keys/:id should be forbidden even for an own key', async () => {
const ownKey = await addApiKey(restrictedUser, { scopes: ['workflow:create'] });
await authAgent
.patch(`/api-keys/${ownKey.id}`)
.send({ label: 'Updated label', scopes: ['workflow:create'] })
.expect(403);
});
test('POST /api-keys/:id/rotate should be forbidden', async () => {
const ownKey = await addApiKey(restrictedUser, { scopes: ['workflow:create'] });
await authAgent.post(`/api-keys/${ownKey.id}/rotate`).expect(403);
});
test('GET /api-keys/scopes should return the scopes assignable by the role', async () => {
const response = await authAgent.get('/api-keys/scopes').expect(200);
expect(Array.isArray(response.body.data)).toBe(true);
});
});

View File

@ -0,0 +1,34 @@
import userEvent from '@testing-library/user-event';
import { render } from '@testing-library/vue';
import N8nTabs from './Tabs.vue';
const options = [
{ value: 'first', label: 'First' },
{ value: 'second', label: 'Second', disabled: true, tooltip: 'No access' },
];
describe('N8nTabs', () => {
it('emits update:modelValue when an enabled tab is clicked', async () => {
const { getByText, emitted } = render(N8nTabs, {
props: { modelValue: 'second', options },
});
await userEvent.click(getByText('First'));
expect(emitted('update:modelValue')).toEqual([['first']]);
});
it('does not emit for a disabled tab and marks it aria-disabled', async () => {
const { getByText, emitted } = render(N8nTabs, {
props: { modelValue: 'first', options },
});
const tab = getByText('Second').closest('[aria-disabled]');
expect(tab).toHaveAttribute('aria-disabled', 'true');
await userEvent.click(getByText('Second'));
expect(emitted('update:modelValue')).toBeUndefined();
});
});

View File

@ -60,7 +60,10 @@ const emit = defineEmits<{
}>();
const handleTooltipClick = (tab: Value, event: MouseEvent) => emit('tooltipClick', tab, event);
const handleTabClick = (tab: Value) => emit('update:modelValue', tab);
const handleTabClick = (option: TabOptions<Value>) => {
if (option.disabled) return;
emit('update:modelValue', option.value);
};
const scroll = (left: number) => {
const container = tabs.value;
@ -105,7 +108,7 @@ const scrollRight = () => scroll(50);
:href="option.href"
rel="noopener noreferrer"
:class="[$style.link, $style.tab, option.label ? '' : $style.noText]"
@click="() => handleTabClick(option.value)"
@click="() => handleTabClick(option)"
>
<div :class="$style.externalLinkContent">
{{ option.label }}
@ -141,10 +144,12 @@ const scrollRight = () => scroll(50);
[$style.activeTab]: modelValue === option.value,
[$style.noText]: !option.label,
[$style.dangerTab]: option.variant === 'danger',
[$style.disabledTab]: option.disabled === true,
}"
@click="() => handleTabClick(option.value)"
@keydown.enter.prevent="() => handleTabClick(option.value)"
@keydown.space.prevent="() => handleTabClick(option.value)"
:aria-disabled="option.disabled || undefined"
@click="() => handleTabClick(option)"
@keydown.enter.prevent="() => handleTabClick(option)"
@keydown.space.prevent="() => handleTabClick(option)"
>
<N8nIcon
v-if="option.icon && option.iconPosition !== 'right'"
@ -304,6 +309,15 @@ const scrollRight = () => scroll(50);
}
}
.disabledTab {
color: var(--color--text--tint-1);
cursor: not-allowed;
&:hover {
color: var(--color--text--tint-1);
}
}
.button {
position: absolute;
background-color: var(--tabs--arrow-buttons--color, var(--color--foreground--tint-2));

View File

@ -9,6 +9,8 @@ export interface TabOptions<Value extends string | number> {
iconPosition?: 'left' | 'right';
variant?: 'default' | 'danger';
href?: string;
/** Prevents selecting the tab (greyed out); combine with `tooltip` to explain why. */
disabled?: boolean;
tooltip?: string;
align?: 'left' | 'right';
to?: RouteLocationRaw;

View File

@ -2885,6 +2885,7 @@
"projectRoles.tab.assignments": "Assignments",
"projectRoles.sourceControl.table.projectsAssigned": "Projects assigned",
"roles.tab.instance": "Instance roles",
"roles.tab.instance.disabledTooltip": "Your role does not have the permission to create instance roles",
"roles.tab.project": "Project roles",
"instanceRoles.table.membersAssigned": "Members assigned",
"instanceRoles.resource.settings": "Instance settings",
@ -3631,6 +3632,7 @@
"settings.api.create.description": "Control n8n programmatically using the <a href=\"https://docs.n8n.io/api\" target=\"_blank\">n8n API</a>",
"settings.api.create.button": "Create API key",
"settings.api.create.button.loading": "Creating API key...",
"settings.api.create.disabledTooltip": "Your role does not have the permission to create API keys",
"settings.api.search.placeholder": "Search",
"settings.api.search.noResults": "No API keys match your search.",
"settings.api.owners.all": "All owners",

View File

@ -944,12 +944,7 @@ export const routes: RouteRecordRaw[] = [
name: VIEWS.API_SETTINGS,
component: SettingsApiView,
meta: {
middleware: ['authenticated', 'rbac'],
middlewareOptions: {
rbac: {
scope: ['apiKey:list'],
},
},
middleware: ['authenticated'],
telemetry: {
pageCategory: 'settings',
getProperties() {

View File

@ -3,6 +3,7 @@ import { mockedStore, type MockedStore } from '@/__tests__/utils';
import { useRBACStore } from '@n8n/stores/rbac.store';
import { createTestingPinia } from '@pinia/testing';
import userEvent from '@testing-library/user-event';
import { screen } from '@testing-library/vue';
import RolesView from './RolesView.vue';
const mockReplace = vi.fn();
@ -73,15 +74,32 @@ describe('RolesView', () => {
expect(getByText('Project roles')).toBeInTheDocument();
});
it('hides the instance tab from a role:manageProject-only user', () => {
it('shows the instance tab disabled with a tooltip to a role:manageProject-only user', async () => {
rbacStore.hasScope = vi.fn().mockReturnValue(false);
const { getByText, queryByText, getByTestId, queryByTestId } = renderComponent();
const { getByText, getByTestId, queryByTestId } = renderComponent();
expect(queryByText('Instance roles')).not.toBeInTheDocument();
const instanceTab = getByText('Instance roles').closest('[aria-disabled]');
expect(instanceTab).toHaveAttribute('aria-disabled', 'true');
expect(getByText('Project roles')).toBeInTheDocument();
expect(getByTestId('project-roles-view')).toBeInTheDocument();
expect(queryByTestId('instance-roles-view')).not.toBeInTheDocument();
await userEvent.hover(getByText('Instance roles'));
expect(
await screen.findByText('Your role does not have the permission to create instance roles'),
).toBeInTheDocument();
});
it('keeps the project tab active when the disabled instance tab is clicked', async () => {
rbacStore.hasScope = vi.fn().mockReturnValue(false);
const { getByText, getByTestId, queryByTestId } = renderComponent();
await userEvent.click(getByText('Instance roles'));
expect(getByTestId('project-roles-view')).toBeInTheDocument();
expect(queryByTestId('instance-roles-view')).not.toBeInTheDocument();
});
it('coerces ?tab=instance to the project tab for a role:manageProject-only user', () => {

View File

@ -45,14 +45,17 @@ const addRoleLabel = computed(() =>
i18n.baseText(activeTab.value === 'project' ? 'roles.addRole.project' : 'roles.addRole.instance'),
);
const tabOptions = computed<Array<TabOptions<RolesTab>>>(() =>
canManageInstanceRoles.value
? [
{ label: i18n.baseText('roles.tab.instance'), value: 'instance' },
{ label: i18n.baseText('roles.tab.project'), value: 'project' },
]
: [{ label: i18n.baseText('roles.tab.project'), value: 'project' }],
);
const tabOptions = computed<Array<TabOptions<RolesTab>>>(() => [
{
label: i18n.baseText('roles.tab.instance'),
value: 'instance',
disabled: !canManageInstanceRoles.value,
tooltip: canManageInstanceRoles.value
? undefined
: i18n.baseText('roles.tab.instance.disabledTooltip'),
},
{ label: i18n.baseText('roles.tab.project'), value: 'project' },
]);
// Reflect tab selection in the URL (replace keeps history clean / back-button safe).
watch(activeTab, (tab) => {

View File

@ -9,6 +9,7 @@ import userEvent from '@testing-library/user-event';
import { useApiKeysStore } from '../apiKeys.store';
import { useUIStore } from '@/app/stores/ui.store';
import { useUsersStore } from '@/features/settings/users/users.store';
import { useRBACStore } from '@n8n/stores/rbac.store';
import { useTelemetry } from '@/app/composables/useTelemetry';
import { DateTime } from 'luxon';
import type { ApiKeyWithRawValue } from '@n8n/api-types';
@ -53,14 +54,17 @@ const testApiKey: ApiKeyWithRawValue = {
const apiKeysStore = mockedStore(useApiKeysStore);
const usersStore = mockedStore(useUsersStore);
const uiStore = mockedStore(useUIStore);
const rbacStore = mockedStore(useRBACStore);
describe('ApiKeyCreateOrEditModal', () => {
beforeEach(() => {
apiKeysStore.availableScopes = ['user:create', 'user:list'];
// Default: current user IS the owner of the test key (no read-only).
// Default: current user IS the owner of the test key (no read-only)
// and their role allows editing keys.
usersStore.currentUserId = 'u1';
// @ts-expect-error: replacing a computed for the test
usersStore.currentUser = { id: 'u1' };
rbacStore.hasScope.mockReturnValue(true);
});
afterEach(() => {
@ -372,4 +376,23 @@ describe('ApiKeyCreateOrEditModal', () => {
});
});
});
describe('read-only mode (role without apiKey:update)', () => {
test('renders an own key read-only, keeping Close + Revoke', async () => {
rbacStore.hasScope.mockReturnValue(false);
apiKeysStore.apiKeys = [testApiKey];
const { getByTestId, queryByText } = renderComponent({
props: { mode: 'edit', activeId: '123' },
});
await retry(() => expect(getByTestId('api-key-label')).toBeInTheDocument());
expect(queryByText('Save')).not.toBeInTheDocument();
expect(getByTestId('api-key-readonly-close')).toBeInTheDocument();
// Revoking own keys stays available regardless of role scopes.
expect(getByTestId('api-key-readonly-revoke')).toBeInTheDocument();
expect((getByTestId('api-key-label') as unknown as HTMLInputElement).disabled).toBe(true);
});
});
});

View File

@ -10,6 +10,7 @@ import { useUsersStore } from '@/features/settings/users/users.store';
import { createEventBus } from '@n8n/utils/event-bus';
import { useI18n } from '@n8n/i18n';
import { useRootStore } from '@n8n/stores/useRootStore';
import { useRBACStore } from '@n8n/stores/rbac.store';
import { useClipboard } from '@n8n/composables/useClipboard';
import { useDocumentTitle } from '@/app/composables/useDocumentTitle';
import { useApiKeysStore } from '../apiKeys.store';
@ -49,6 +50,7 @@ const clipboard = useClipboard();
const apiKeysStore = useApiKeysStore();
const { createApiKey, updateApiKey, deleteApiKey, apiKeysById, availableScopes } = apiKeysStore;
const { currentUser } = storeToRefs(useUsersStore());
const rbacStore = useRBACStore();
const documentTitle = useDocumentTitle();
const label = ref('');
@ -128,7 +130,10 @@ const currentApiKey = computed<ApiKey | null>(() =>
const isReadOnly = computed(() => {
const apiKey = currentApiKey.value;
if (!apiKey?.owner || !currentUser.value) return false;
if (!apiKey) return false;
// Even own keys are view-only when the role doesn't allow editing them.
if (!rbacStore.hasScope('apiKey:update')) return true;
if (!apiKey.owner || !currentUser.value) return false;
return apiKey.owner.id !== currentUser.value.id;
});

View File

@ -11,13 +11,18 @@ import ApiKeyLabelCell from './ApiKeyLabelCell.vue';
import ApiKeyOwnerCell from './ApiKeyOwnerCell.vue';
import ApiKeyScopesCell from './ApiKeyScopesCell.vue';
const props = defineProps<{
apiKeys: ApiKey[];
itemsLength: number;
loading?: boolean;
/** When set, Edit is only offered for keys owned by this user. */
currentUserId?: string;
}>();
const props = withDefaults(
defineProps<{
apiKeys: ApiKey[];
itemsLength: number;
loading?: boolean;
/** When set, Edit is only offered for keys owned by this user. */
currentUserId?: string;
/** Whether the current user's role allows editing/rotating their own keys. */
canUpdate?: boolean;
}>(),
{ currentUserId: undefined, canUpdate: true },
);
const emit = defineEmits<{
edit: [apiKey: ApiKey];
@ -61,7 +66,7 @@ type ApiKeyAction = 'edit' | 'view' | 'revoke' | 'rotate';
function getRowActions(apiKey: ApiKey): Array<ActionDropdownItem<ApiKeyAction>> {
const actions: Array<ActionDropdownItem<ApiKeyAction>> = [];
if (isOwn(apiKey)) {
if (isOwn(apiKey) && props.canUpdate) {
actions.push({
id: 'edit',
label: i18n.baseText('settings.api.actions.edit'),
@ -77,7 +82,8 @@ function getRowActions(apiKey: ApiKey): Array<ActionDropdownItem<ApiKeyAction>>
});
}
} else {
// Non-owners open the same modal, which renders read-only based on ownership.
// Non-owners and roles without apiKey:update open the same modal,
// which renders read-only based on ownership and scopes.
actions.push({
id: 'view',
label: i18n.baseText('settings.api.actions.view'),

View File

@ -101,6 +101,14 @@ vi.mock('@n8n/design-system', async (importOriginal) => {
</div>
`,
},
// Reka UI tooltips don't open in jsdom either; expose the props the view
// passes so tests can assert the tooltip wiring without driving the popup.
N8nTooltip: {
name: 'N8nTooltip',
props: ['disabled', 'content'],
template:
'<div :data-tooltip-disabled="disabled" :data-tooltip-content="content"><slot /></div>',
},
};
});
@ -290,6 +298,14 @@ describe('SettingsApiView', () => {
apiKeysStore.totalAllCount = 1;
};
beforeEach(() => {
// Rotation requires apiKey:update; these tests exercise the expiry and
// ownership gates, not the scope gate.
rbacStore.hasScope.mockImplementation(
(scope: string | string[]) => scope === 'apiKey:update',
);
});
it('offers Rotate for an owned, non-expired key', () => {
singleOwnedKey({ expiresAt: null });
@ -342,6 +358,89 @@ describe('SettingsApiView', () => {
});
});
describe('restricted role (no apiKey scopes)', () => {
const setupKeys = () => {
settingsStore.isPublicApiEnabled = true;
cloudStore.userIsTrialing = false;
apiKeysStore.apiKeys = [makeKey({ id: '1', label: 'test-key-1' })];
apiKeysStore.mineCount = 1;
apiKeysStore.allCount = 1;
apiKeysStore.totalMineCount = 1;
apiKeysStore.totalAllCount = 1;
};
it('disables the create button and explains why for a role without apiKey:create', () => {
setupKeys();
renderComponent(SettingsApiView);
const createButton = screen.getByTestId('api-key-create-button');
expect(createButton).toBeDisabled();
const tooltip = createButton.closest('[data-tooltip-content]');
expect(tooltip?.getAttribute('data-tooltip-disabled')).toBe('false');
expect(tooltip?.getAttribute('data-tooltip-content')).toBe(
'Your role does not have the permission to create API keys',
);
});
it('enables the create button when the role has apiKey:create', () => {
rbacStore.hasScope.mockImplementation(
(scope: string | string[]) => scope === 'apiKey:create',
);
setupKeys();
renderComponent(SettingsApiView);
const createButton = screen.getByTestId('api-key-create-button');
expect(createButton).toBeEnabled();
// The explanatory tooltip is off while creation is allowed.
expect(
createButton.closest('[data-tooltip-content]')?.getAttribute('data-tooltip-disabled'),
).toBe('true');
});
it('offers View instead of Edit/Rotate on own keys for a role without apiKey:update', () => {
setupKeys();
renderComponent(SettingsApiView);
expect(screen.getByTestId('api-key-view-action')).toBeInTheDocument();
expect(screen.queryByTestId('api-key-edit-action')).toBeNull();
expect(screen.queryByTestId('api-key-rotate-action')).toBeNull();
// Revoking own keys stays available regardless of role scopes.
expect(screen.getByTestId('api-key-revoke-action')).toBeInTheDocument();
});
it('keeps Edit and Rotate on own keys when the role has apiKey:update', () => {
rbacStore.hasScope.mockImplementation(
(scope: string | string[]) => scope === 'apiKey:update',
);
setupKeys();
renderComponent(SettingsApiView);
expect(screen.getByTestId('api-key-edit-action')).toBeInTheDocument();
expect(screen.getByTestId('api-key-rotate-action')).toBeInTheDocument();
expect(screen.queryByTestId('api-key-view-action')).toBeNull();
});
it('disables the empty-state CTA for a role without apiKey:create', () => {
settingsStore.isPublicApiEnabled = true;
cloudStore.userIsTrialing = false;
apiKeysStore.apiKeys = [];
apiKeysStore.mineCount = 0;
apiKeysStore.allCount = 0;
apiKeysStore.totalMineCount = 0;
apiKeysStore.totalAllCount = 0;
apiKeysStore.labelFilter = '';
renderComponent(SettingsApiView);
expect(screen.getByRole('button', { name: 'Create API key' })).toBeDisabled();
});
});
it('keeps the search input visible when a filter zeroes the results', () => {
settingsStore.isPublicApiEnabled = true;
apiKeysStore.apiKeys = [];

View File

@ -29,6 +29,7 @@ import {
N8nInput,
N8nTabs,
N8nText,
N8nTooltip,
} from '@n8n/design-system';
import { I18nT } from 'vue-i18n';
import ApiKeyOwnerFilter from '../components/ApiKeyOwnerFilter.vue';
@ -147,6 +148,10 @@ const rotateConfirmApiKey = ref<ApiKey | null>(null);
const rotating = ref(false);
const canManageAllKeys = computed(() => rbacStore.hasScope('apiKey:manage'));
// Viewing and revoking own keys is available to everyone; creating and editing
// stay behind role scopes, so the section explains the restriction instead of hiding.
const canCreateKeys = computed(() => rbacStore.hasScope('apiKey:create'));
const canUpdateKeys = computed(() => rbacStore.hasScope('apiKey:update'));
// Badges show the unfiltered totals so a search-narrowed "Mine (0)" doesn't read
// as "I have no keys" when the user really has keys that just don't match.
@ -357,9 +362,19 @@ function onOpenScopes(apiKey: ApiKey) {
/>
</div>
</div>
<N8nButton size="medium" @click="onCreateApiKey">
{{ i18n.baseText('settings.api.create.button') }}
</N8nButton>
<N8nTooltip
:disabled="canCreateKeys"
:content="i18n.baseText('settings.api.create.disabledTooltip')"
>
<N8nButton
size="medium"
:disabled="!canCreateKeys"
data-test-id="api-key-create-button"
@click="onCreateApiKey"
>
{{ i18n.baseText('settings.api.create.button') }}
</N8nButton>
</N8nTooltip>
</div>
<N8nTabs
@ -378,6 +393,7 @@ function onOpenScopes(apiKey: ApiKey) {
:items-length="apiKeysCount"
:loading="loading"
:current-user-id="usersStore.currentUser?.id"
:can-update="canUpdateKeys"
:class="$style.table"
@edit="onEdit"
@revoke="onRevokeRequest"
@ -418,9 +434,14 @@ function onOpenScopes(apiKey: ApiKey) {
:button-text="
i18n.baseText(loading ? 'settings.api.create.button.loading' : 'settings.api.create.button')
"
:button-disabled="!canCreateKeys"
:description="i18n.baseText('settings.api.create.description')"
@click:button="onCreateApiKey"
/>
>
<template #disabledButtonTooltip>
{{ i18n.baseText('settings.api.create.disabledTooltip') }}
</template>
</N8nEmptyState>
<ApiKeyScopesModal
:api-key="scopesModalApiKey"