feat(core): Add scopes to API Keys (#14176)

Co-authored-by: Charlie Kolb <charlie@n8n.io>
Co-authored-by: Danny Martini <danny@n8n.io>
This commit is contained in:
Ricardo Espinoza
2025-04-16 09:03:16 -04:00
committed by GitHub
parent bc12f662e7
commit e1b9407fe9
65 changed files with 3216 additions and 125 deletions

View File

@@ -28,6 +28,8 @@
"n8n-workflow": "workspace:*",
"xss": "catalog:",
"zod": "catalog:",
"zod-class": "0.0.16"
"zod-class": "0.0.16",
"@n8n/permissions": "workspace:*"
}
}

View File

@@ -1,3 +1,5 @@
import type { ApiKeyScope } from '@n8n/permissions';
/** Unix timestamp. Seconds since epoch */
export type UnixTimestamp = number | null;
@@ -9,6 +11,7 @@ export type ApiKey = {
updatedAt: string;
/** Null if API key never expires */
expiresAt: UnixTimestamp | null;
scopes: ApiKeyScope[];
};
export type ApiKeyWithRawValue = ApiKey & { rawApiKey: string };

View File

@@ -6,13 +6,15 @@ describe('CreateApiKeyRequestDto', () => {
{
name: 'expiresAt in the future',
expiresAt: Date.now() / 1000 + 1000,
scopes: ['user:create'],
},
{
name: 'expiresAt null',
expiresAt: null,
scopes: ['user:create'],
},
])('should succeed validation for $name', ({ expiresAt }) => {
const result = CreateApiKeyRequestDto.safeParse({ label: 'valid', expiresAt });
])('should succeed validation for $name', ({ expiresAt, scopes }) => {
const result = CreateApiKeyRequestDto.safeParse({ label: 'valid', expiresAt, scopes });
expect(result.success).toBe(true);
});
@@ -23,25 +25,33 @@ describe('CreateApiKeyRequestDto', () => {
{
name: 'expiresAt in the past',
expiresAt: Date.now() / 1000 - 1000,
scopes: ['user:create'],
expectedErrorPath: ['expiresAt'],
},
{
name: 'expiresAt with string',
expiresAt: 'invalid',
scopes: ['user:create'],
expectedErrorPath: ['expiresAt'],
},
{
name: 'expiresAt with []',
expiresAt: [],
scopes: ['user:create'],
expectedErrorPath: ['expiresAt'],
},
{
name: 'expiresAt with {}',
expiresAt: {},
scopes: ['user:create'],
expectedErrorPath: ['expiresAt'],
},
])('should fail validation for $name', ({ expiresAt, expectedErrorPath }) => {
const result = CreateApiKeyRequestDto.safeParse({ label: 'valid', expiresAt });
const result = CreateApiKeyRequestDto.safeParse({
label: 'valid',
expiresAt,
scopes: ['user:create'],
});
expect(result.success).toBe(false);

View File

@@ -5,6 +5,15 @@ describe('UpdateApiKeyRequestDto', () => {
test('should allow valid label', () => {
const result = UpdateApiKeyRequestDto.safeParse({
label: 'valid label',
scopes: ['user:create'],
});
expect(result.success).toBe(true);
});
test('should allow valid scope', () => {
const result = UpdateApiKeyRequestDto.safeParse({
label: 'valid label',
scopes: ['user:create'],
});
expect(result.success).toBe(true);
});
@@ -27,8 +36,26 @@ describe('UpdateApiKeyRequestDto', () => {
label: '<script>alert("xss");new label</script>',
expectedErrorPath: ['label'],
},
])('should fail validation for $name', ({ label, expectedErrorPath }) => {
const result = UpdateApiKeyRequestDto.safeParse({ label });
{
name: 'scopes with malformed scope',
label: 'valid label',
scopes: ['user:1'],
expectedErrorPath: ['scopes', 0],
},
{
name: 'scopes with empty array',
label: 'valid label',
scopes: [],
expectedErrorPath: ['scopes'],
},
{
name: 'scopes with {}',
label: 'valid label',
scopes: {},
expectedErrorPath: ['scopes'],
},
])('should fail validation for $name', ({ label, scopes, expectedErrorPath }) => {
const result = UpdateApiKeyRequestDto.safeParse({ label, scopes });
expect(result.success).toBe(false);

View File

@@ -2,6 +2,8 @@ import xss from 'xss';
import { z } from 'zod';
import { Z } from 'zod-class';
import { scopesSchema } from '../../schemas/scopes.schema';
const xssCheck = (value: string) =>
value ===
xss(value, {
@@ -10,4 +12,5 @@ const xssCheck = (value: string) =>
export class UpdateApiKeyRequestDto extends Z.class({
label: z.string().max(50).min(1).refine(xssCheck),
scopes: scopesSchema,
}) {}

View File

@@ -136,6 +136,7 @@ export interface FrontendSettings {
workflowHistory: boolean;
workerView: boolean;
advancedPermissions: boolean;
apiKeyScopes: boolean;
projects: {
team: {
limit: number;

View File

@@ -0,0 +1,16 @@
import type { ApiKeyScope } from '@n8n/permissions';
import { z } from 'zod';
export const scopesSchema = z
.array(
z
.string()
.regex(
/^[a-zA-Z]+:[a-zA-Z]+$/,
"Each scope must follow the format '{resource}:{scope}' with only letters (e.g., 'workflow:create')",
),
)
.min(1)
.transform((scopes) => {
return scopes as ApiKeyScope[];
});

View File

@@ -25,3 +25,16 @@ export const RESOURCES = {
folder: [...DEFAULT_OPERATIONS] as const,
insights: ['list'] as const,
} as const;
export const API_KEY_RESOURCES = {
tag: [...DEFAULT_OPERATIONS] as const,
workflow: [...DEFAULT_OPERATIONS, 'move', 'activate', 'deactivate'] as const,
variable: ['create', 'delete', 'list'] as const,
securityAudit: ['generate'] as const,
project: ['create', 'update', 'delete', 'list'] as const,
user: ['read', 'list', 'create', 'changeRole', 'delete'] as const,
execution: ['delete', 'read', 'list', 'get'] as const,
credential: ['create', 'move', 'delete'] as const,
sourceControl: ['pull'] as const,
workflowTags: ['update', 'list'] as const,
} as const;

View File

@@ -1,4 +1,4 @@
import type { RESOURCES } from './constants.ee';
import type { RESOURCES, API_KEY_RESOURCES } from './constants.ee';
export type Resource = keyof typeof RESOURCES;
@@ -16,7 +16,7 @@ type AllScopesObject = {
[R in Resource]: ResourceScope<R>;
};
export type Scope<K extends Resource = Resource> = AllScopesObject[K];
export type Scope = AllScopesObject[Resource];
export type ScopeLevel = 'global' | 'project' | 'resource';
export type GetScopeLevel<T extends ScopeLevel> = Record<T, Scope[]>;
@@ -32,3 +32,22 @@ export type MaskLevels = SharingMasks;
export type ScopeMode = 'oneOf' | 'allOf';
export type ScopeOptions = { mode: ScopeMode };
export type PublicApiKeyResources = keyof typeof API_KEY_RESOURCES;
export type ApiKeyResourceScope<
R extends PublicApiKeyResources,
Operation extends (typeof API_KEY_RESOURCES)[R][number] = (typeof API_KEY_RESOURCES)[R][number],
> = `${R}:${Operation}`;
// This is purely an intermediary type.
// If we tried to do use `ResourceScope<Resource>` directly we'd end
// up with all resources having all scopes.
type AllApiKeyScopesObject = {
[R in PublicApiKeyResources]: ApiKeyResourceScope<R>;
};
export type ApiKeyScope = AllApiKeyScopesObject[PublicApiKeyResources];
export type GlobalRole = 'global:owner' | 'global:admin' | 'global:member';
export type AssignableRole = Exclude<GlobalRole, 'global:owner'>;

View File

@@ -100,6 +100,7 @@ export const LICENSE_FEATURES = {
INSIGHTS_VIEW_SUMMARY: 'feat:insights:viewSummary',
INSIGHTS_VIEW_DASHBOARD: 'feat:insights:viewDashboard',
INSIGHTS_VIEW_HOURLY_DATA: 'feat:insights:viewHourlyData',
API_KEY_SCOPES: 'feat:apiKeyScopes',
} as const;
export const LICENSE_QUOTAS = {

View File

@@ -31,17 +31,20 @@ describe('ApiKeysController', () => {
label: 'My API Key',
apiKey: 'apiKey123',
createdAt: new Date(),
scopes: ['user:create'],
} as ApiKey;
const req = mock<AuthenticatedRequest>({ user: mock<User>({ id: '123' }) });
publicApiKeyService.apiKeyHasValidScopesForRole.mockReturnValue(true);
publicApiKeyService.createPublicApiKeyForUser.mockResolvedValue(apiKeyData);
publicApiKeyService.redactApiKey.mockImplementation(() => '***123');
// Act
const newApiKey = await controller.createAPIKey(req, mock(), mock());
const newApiKey = await controller.createApiKey(req, mock(), mock());
// Assert
@@ -54,6 +57,7 @@ describe('ApiKeysController', () => {
apiKey: '***123',
createdAt: expect.any(Date),
rawApiKey: 'apiKey123',
scopes: ['user:create'],
}),
);
expect(eventService.emit).toHaveBeenCalledWith(
@@ -61,6 +65,32 @@ describe('ApiKeysController', () => {
expect.objectContaining({ user: req.user, publicApi: false }),
);
});
it('should fail to create API key if user uses a scope not allow for its role', async () => {
// Arrange
const req = mock<AuthenticatedRequest>({ user: mock<User>({ id: '123' }) });
publicApiKeyService.apiKeyHasValidScopesForRole.mockReturnValue(false);
// Act and Assert
await expect(controller.createApiKey(req, mock(), mock())).rejects.toThrowError();
});
});
describe('updateApiKey', () => {
it('should fail to update API key if user uses a scope not allow for its role', async () => {
// Arrange
const req = mock<AuthenticatedRequest>({ user: mock<User>({ id: '123' }) });
publicApiKeyService.apiKeyHasValidScopesForRole.mockReturnValue(false);
// Act and Assert
await expect(controller.updateApiKey(req, mock(), mock(), mock())).rejects.toThrowError();
});
});
describe('getAPIKeys', () => {
@@ -82,7 +112,7 @@ describe('ApiKeysController', () => {
// Act
const apiKeys = await controller.getAPIKeys(req);
const apiKeys = await controller.getApiKeys(req);
// Assert
@@ -109,7 +139,7 @@ describe('ApiKeysController', () => {
// Act
await controller.deleteAPIKey(req, mock(), user.id);
await controller.deleteApiKey(req, mock(), user.id);
publicApiKeyService.deleteApiKeyForUser.mockResolvedValue();

View File

@@ -2,8 +2,10 @@ import { CreateApiKeyRequestDto, UpdateApiKeyRequestDto } from '@n8n/api-types';
import type { RequestHandler } from 'express';
import { Body, Delete, Get, Param, Patch, Post, RestController } from '@/decorators';
import { BadRequestError } from '@/errors/response-errors/bad-request.error';
import { EventService } from '@/events/event.service';
import { isApiEnabled } from '@/public-api';
import { getApiKeyScopesForRole } from '@/public-api/permissions.ee';
import { AuthenticatedRequest } from '@/requests';
import { PublicApiKeyService } from '@/services/public-api-key.service';
@@ -26,15 +28,16 @@ export class ApiKeysController {
* Create an API Key
*/
@Post('/', { middlewares: [isApiEnabledMiddleware] })
async createAPIKey(
async createApiKey(
req: AuthenticatedRequest,
_res: Response,
@Body { label, expiresAt }: CreateApiKeyRequestDto,
@Body body: CreateApiKeyRequestDto,
) {
const newApiKey = await this.publicApiKeyService.createPublicApiKeyForUser(req.user, {
label,
expiresAt,
});
if (!this.publicApiKeyService.apiKeyHasValidScopesForRole(req.user.role, body.scopes)) {
throw new BadRequestError('Invalid scopes for user role');
}
const newApiKey = await this.publicApiKeyService.createPublicApiKeyForUser(req.user, body);
this.eventService.emit('public-api-key-created', { user: req.user, publicApi: false });
@@ -42,7 +45,7 @@ export class ApiKeysController {
...newApiKey,
apiKey: this.publicApiKeyService.redactApiKey(newApiKey.apiKey),
rawApiKey: newApiKey.apiKey,
expiresAt,
expiresAt: body.expiresAt,
};
}
@@ -50,7 +53,7 @@ export class ApiKeysController {
* Get API keys
*/
@Get('/', { middlewares: [isApiEnabledMiddleware] })
async getAPIKeys(req: AuthenticatedRequest) {
async getApiKeys(req: AuthenticatedRequest) {
const apiKeys = await this.publicApiKeyService.getRedactedApiKeysForUser(req.user);
return apiKeys;
}
@@ -59,7 +62,7 @@ export class ApiKeysController {
* Delete an API Key
*/
@Delete('/:id', { middlewares: [isApiEnabledMiddleware] })
async deleteAPIKey(req: AuthenticatedRequest, _res: Response, @Param('id') apiKeyId: string) {
async deleteApiKey(req: AuthenticatedRequest, _res: Response, @Param('id') apiKeyId: string) {
await this.publicApiKeyService.deleteApiKeyForUser(req.user, apiKeyId);
this.eventService.emit('public-api-key-deleted', { user: req.user, publicApi: false });
@@ -71,16 +74,25 @@ export class ApiKeysController {
* Patch an API Key
*/
@Patch('/:id', { middlewares: [isApiEnabledMiddleware] })
async updateAPIKey(
async updateApiKey(
req: AuthenticatedRequest,
_res: Response,
@Param('id') apiKeyId: string,
@Body { label }: UpdateApiKeyRequestDto,
@Body body: UpdateApiKeyRequestDto,
) {
await this.publicApiKeyService.updateApiKeyForUser(req.user, apiKeyId, {
label,
});
if (!this.publicApiKeyService.apiKeyHasValidScopesForRole(req.user.role, body.scopes)) {
throw new BadRequestError('Invalid scopes for user role');
}
await this.publicApiKeyService.updateApiKeyForUser(req.user, apiKeyId, body);
return { success: true };
}
@Get('/scopes', { middlewares: [isApiEnabledMiddleware] })
async getApiKeyScopes(req: AuthenticatedRequest, _res: Response) {
const { role } = req.user;
const scopes = getApiKeyScopesForRole(role);
return scopes;
}
}

View File

@@ -105,6 +105,7 @@ export class E2EController {
[LICENSE_FEATURES.INSIGHTS_VIEW_SUMMARY]: false,
[LICENSE_FEATURES.INSIGHTS_VIEW_DASHBOARD]: false,
[LICENSE_FEATURES.INSIGHTS_VIEW_HOURLY_DATA]: false,
[LICENSE_FEATURES.API_KEY_SCOPES]: false,
};
private static readonly numericFeaturesDefaults: Record<NumericLicenseFeature, number> = {

View File

@@ -293,7 +293,7 @@ export class UsersController {
throw new ForbiddenError(NO_OWNER_ON_OWNER);
}
await this.userService.update(targetUser.id, { role: payload.newRoleName });
await this.userService.changeUserRole(req.user, targetUser, payload);
this.eventService.emit('user-changed-role', {
userId: req.user.id,

View File

@@ -1,6 +1,7 @@
import type { ApiKeyScope } from '@n8n/permissions';
import { Column, Entity, Index, ManyToOne, Unique } from '@n8n/typeorm';
import { WithTimestampsAndStringId } from './abstract-entity';
import { jsonColumnType, WithTimestampsAndStringId } from './abstract-entity';
import { User } from './user';
@Entity('user_api_keys')
@@ -19,6 +20,9 @@ export class ApiKey extends WithTimestampsAndStringId {
@Column({ type: String })
label: string;
@Column({ type: jsonColumnType, nullable: false })
scopes: ApiKeyScope[];
@Index({ unique: true })
@Column({ type: String })
apiKey: string;

View File

@@ -1,4 +1,4 @@
import { hasScope, type ScopeOptions, type Scope } from '@n8n/permissions';
import { hasScope, type ScopeOptions, type Scope, GlobalRole } from '@n8n/permissions';
import {
AfterLoad,
AfterUpdate,
@@ -30,9 +30,6 @@ import type { SharedCredentials } from './shared-credentials';
import type { SharedWorkflow } from './shared-workflow';
import { objectRetriever, lowerCaser } from '../utils/transformers';
export type GlobalRole = 'global:owner' | 'global:admin' | 'global:member';
export type AssignableRole = Exclude<GlobalRole, 'global:owner'>;
const STATIC_SCOPE_MAP: Record<GlobalRole, Scope[]> = {
'global:owner': GLOBAL_OWNER_SCOPES,
'global:member': GLOBAL_MEMBER_SCOPES,
@@ -84,7 +81,7 @@ export class User extends WithTimestamps implements IUser {
})
settings: IUserSettings | null;
@Column()
@Column({ type: String })
role: GlobalRole;
@OneToMany('AuthIdentity', 'user')

View File

@@ -0,0 +1,35 @@
import type { GlobalRole } from '@n8n/permissions';
import type { MigrationContext, ReversibleMigration } from '@/databases/types';
import { getApiKeyScopesForRole } from '@/public-api/permissions.ee';
type ApiKeyWithRole = { id: string; role: GlobalRole };
export class AddScopesColumnToApiKeys1742918400000 implements ReversibleMigration {
async up({ runQuery, escape, schemaBuilder: { addColumns, column } }: MigrationContext) {
await addColumns('user_api_keys', [column('scopes').json]);
const userApiKeysTable = escape.tableName('user_api_keys');
const userTable = escape.tableName('user');
const idColumn = escape.columnName('id');
const userIdColumn = escape.columnName('userId');
const roleColumn = escape.columnName('role');
const scopesColumn = escape.columnName('scopes');
const apiKeysWithRoles = await runQuery<ApiKeyWithRole[]>(
`SELECT ${userApiKeysTable}.${idColumn} AS id, ${userTable}.${roleColumn} AS role FROM ${userApiKeysTable} JOIN ${userTable} ON ${userTable}.${idColumn} = ${userApiKeysTable}.${userIdColumn}`,
);
for (const { id, role } of apiKeysWithRoles) {
const scopes = JSON.stringify(getApiKeyScopesForRole(role));
await runQuery(
`UPDATE ${userApiKeysTable} SET ${scopesColumn} = '${scopes}' WHERE ${idColumn} = "${id}"`,
);
}
}
async down({ schemaBuilder: { dropColumns } }: MigrationContext) {
await dropColumns('user_api_keys', ['scopes']);
}
}

View File

@@ -83,6 +83,7 @@ import { AddErrorColumnsToTestRuns1737715421462 } from '../common/1737715421462-
import { CreateFolderTable1738709609940 } from '../common/1738709609940-CreateFolderTable';
import { CreateAnalyticsTables1739549398681 } from '../common/1739549398681-CreateAnalyticsTables';
import { RenameAnalyticsToInsights1741167584277 } from '../common/1741167584277-RenameAnalyticsToInsights';
import { AddScopesColumnToApiKeys1742918400000 } from '../common/1742918400000-AddScopesColumnToApiKeys';
import { UpdateParentFolderIdColumn1740445074052 } from '../mysqldb/1740445074052-UpdateParentFolderIdColumn';
export const mysqlMigrations: Migration[] = [
@@ -170,4 +171,5 @@ export const mysqlMigrations: Migration[] = [
CreateAnalyticsTables1739549398681,
UpdateParentFolderIdColumn1740445074052,
RenameAnalyticsToInsights1741167584277,
AddScopesColumnToApiKeys1742918400000,
];

View File

@@ -83,6 +83,7 @@ import { AddErrorColumnsToTestRuns1737715421462 } from '../common/1737715421462-
import { CreateFolderTable1738709609940 } from '../common/1738709609940-CreateFolderTable';
import { CreateAnalyticsTables1739549398681 } from '../common/1739549398681-CreateAnalyticsTables';
import { RenameAnalyticsToInsights1741167584277 } from '../common/1741167584277-RenameAnalyticsToInsights';
import { AddScopesColumnToApiKeys1742918400000 } from '../common/1742918400000-AddScopesColumnToApiKeys';
export const postgresMigrations: Migration[] = [
InitialMigration1587669153312,
@@ -168,4 +169,5 @@ export const postgresMigrations: Migration[] = [
CreateAnalyticsTables1739549398681,
UpdateParentFolderIdColumn1740445074052,
RenameAnalyticsToInsights1741167584277,
AddScopesColumnToApiKeys1742918400000,
];

View File

@@ -0,0 +1,5 @@
import { AddScopesColumnToApiKeys1742918400000 as BaseMigration } from '../common/1742918400000-AddScopesColumnToApiKeys';
export class AddScopesColumnToApiKeys1742918400000 extends BaseMigration {
transaction = false as const;
}

View File

@@ -44,6 +44,7 @@ import { AddDescriptionToTestDefinition1731404028106 } from './1731404028106-Add
import { MigrateTestDefinitionKeyToString1731582748663 } from './1731582748663-MigrateTestDefinitionKeyToString';
import { CreateFolderTable1738709609940 } from './1738709609940-CreateFolderTable';
import { UpdateParentFolderIdColumn1740445074052 } from './1740445074052-UpdateParentFolderIdColumn';
import { AddScopesColumnToApiKeys1742918400000 } from './1742918400000-AddScopesColumnToApiKeys';
import { UniqueWorkflowNames1620821879465 } from '../common/1620821879465-UniqueWorkflowNames';
import { UpdateWorkflowCredentials1630330987096 } from '../common/1630330987096-UpdateWorkflowCredentials';
import { AddNodeIds1658930531669 } from '../common/1658930531669-AddNodeIds';
@@ -162,6 +163,7 @@ const sqliteMigrations: Migration[] = [
CreateAnalyticsTables1739549398681,
UpdateParentFolderIdColumn1740445074052,
RenameAnalyticsToInsights1741167584277,
AddScopesColumnToApiKeys1742918400000,
];
export { sqliteMigrations };

View File

@@ -1,4 +1,5 @@
import { Service } from '@n8n/di';
import type { GlobalRole } from '@n8n/permissions';
import type { DeepPartial, EntityManager, FindManyOptions } from '@n8n/typeorm';
import { DataSource, In, IsNull, Not, Repository } from '@n8n/typeorm';
@@ -6,7 +7,7 @@ import type { ListQuery } from '@/requests';
import { Project } from '../entities/project';
import { ProjectRelation } from '../entities/project-relation';
import { type GlobalRole, User } from '../entities/user';
import { User } from '../entities/user';
@Service()
export class UserRepository extends Repository<User> {

View File

@@ -1,4 +1,5 @@
import type { AuthenticationMethod, ProjectRelation } from '@n8n/api-types';
import type { GlobalRole } from '@n8n/permissions';
import type {
IPersonalizationSurveyAnswersV4,
IRun,
@@ -8,7 +9,7 @@ import type {
import type { ConcurrencyQueueType } from '@/concurrency/concurrency-control.service';
import type { AuthProviderType } from '@/databases/entities/auth-identity';
import type { GlobalRole, User } from '@/databases/entities/user';
import type { User } from '@/databases/entities/user';
import type { IWorkflowDb } from '@/interfaces';
import type { AiEventMap } from './ai.event-map';

View File

@@ -1,4 +1,4 @@
import type { Scope } from '@n8n/permissions';
import type { AssignableRole, GlobalRole, Scope } from '@n8n/permissions';
import type { Application } from 'express';
import type {
ExecutionError,
@@ -30,7 +30,7 @@ import type { AnnotationTagEntity } from '@/databases/entities/annotation-tag-en
import type { AuthProviderType } from '@/databases/entities/auth-identity';
import type { SharedCredentials } from '@/databases/entities/shared-credentials';
import type { TagEntity } from '@/databases/entities/tag-entity';
import type { AssignableRole, GlobalRole, User } from '@/databases/entities/user';
import type { User } from '@/databases/entities/user';
import type { LICENSE_FEATURES, LICENSE_QUOTAS } from './constants';
import type { Folder } from './databases/entities/folder';

View File

@@ -239,6 +239,10 @@ export class License {
return this.isFeatureEnabled(LICENSE_FEATURES.SAML);
}
isApiKeyScopesEnabled() {
return this.isFeatureEnabled(LICENSE_FEATURES.API_KEY_SCOPES);
}
isAiAssistantEnabled() {
return this.isFeatureEnabled(LICENSE_FEATURES.AI_ASSISTANT);
}

View File

@@ -0,0 +1,66 @@
import type { ApiKeyScope } from '@n8n/permissions';
export const OWNER_API_KEY_SCOPES: ApiKeyScope[] = [
'user:read',
'user:list',
'user:create',
'user:changeRole',
'user:delete',
'sourceControl:pull',
'securityAudit:generate',
'project:create',
'project:update',
'project:delete',
'project:list',
'variable:create',
'variable:delete',
'variable:list',
'tag:create',
'tag:read',
'tag:update',
'tag:delete',
'tag:list',
'workflowTags:update',
'workflowTags:list',
'workflow:create',
'workflow:read',
'workflow:update',
'workflow:delete',
'workflow:list',
'workflow:move',
'workflow:activate',
'workflow:deactivate',
'execution:delete',
'execution:read',
'execution:list',
'execution:get',
'credential:create',
'credential:move',
'credential:delete',
];
export const ADMIN_API_KEY_SCOPES: ApiKeyScope[] = OWNER_API_KEY_SCOPES;
export const MEMBER_API_KEY_SCOPES: ApiKeyScope[] = [
'tag:create',
'tag:read',
'tag:update',
'tag:list',
'workflowTags:update',
'workflowTags:list',
'workflow:create',
'workflow:read',
'workflow:update',
'workflow:delete',
'workflow:list',
'workflow:move',
'workflow:activate',
'workflow:deactivate',
'execution:delete',
'execution:read',
'execution:list',
'execution:get',
'credential:create',
'credential:move',
'credential:delete',
];

View File

@@ -0,0 +1,24 @@
import type { ApiKeyScope, GlobalRole } from '@n8n/permissions';
import {
ADMIN_API_KEY_SCOPES,
MEMBER_API_KEY_SCOPES,
OWNER_API_KEY_SCOPES,
} from './global-roles-scopes';
const MAP_ROLE_SCOPES: Record<GlobalRole, ApiKeyScope[]> = {
'global:owner': OWNER_API_KEY_SCOPES,
'global:admin': ADMIN_API_KEY_SCOPES,
'global:member': MEMBER_API_KEY_SCOPES,
};
export const getApiKeyScopesForRole = (role: GlobalRole) => {
return MAP_ROLE_SCOPES[role];
};
export const getOwnerOnlyApiKeyScopes = () => {
const ownerScopes = new Set<ApiKeyScope>(MAP_ROLE_SCOPES['global:owner']);
const memberScopes = new Set<ApiKeyScope>(MAP_ROLE_SCOPES['global:member']);
memberScopes.forEach((item) => ownerScopes.delete(item));
return Array.from(ownerScopes);
};

View File

@@ -0,0 +1,55 @@
import type { NextFunction } from 'express';
import { mock } from 'jest-mock-extended';
import { License } from '@/license';
import { PublicApiKeyService } from '@/services/public-api-key.service';
import { mockInstance } from '@test/mocking';
import * as middlewares from '../shared/middlewares/global.middleware';
jest.spyOn(middlewares, 'globalScope').mockReturnValue(jest.fn());
const license = mockInstance(License);
const publicApiKeyService = mockInstance(PublicApiKeyService);
afterEach(() => {
jest.clearAllMocks();
});
describe('apiKeyHasScope', () => {
it('should return API key scope middleware if "feat:apiKeyScopes" is enabled', () => {
license.isApiKeyScopesEnabled.mockReturnValue(true);
publicApiKeyService.getApiKeyScopeMiddleware.mockReturnValue(jest.fn());
middlewares.apiKeyHasScope('credential:create');
// eslint-disable-next-line @typescript-eslint/unbound-method
expect(publicApiKeyService.getApiKeyScopeMiddleware).toHaveBeenCalledWith('credential:create');
});
it('should return empty middleware if "feat:apiKeyScopes" is disabled', async () => {
license.isApiKeyScopesEnabled.mockReturnValue(false);
publicApiKeyService.getApiKeyScopeMiddleware.mockReturnValue(jest.fn());
const responseMiddleware = middlewares.apiKeyHasScope('credential:create');
expect(middlewares.globalScope).not.toHaveBeenCalled();
const next: NextFunction = jest.fn();
await responseMiddleware(mock(), mock(), next);
expect(next).toHaveBeenCalled();
});
});
describe('apiKeyHasScopeWithGlobalScopeFallback', () => {
it('should return global middleware if "feat:apiKeyScopes" is disabled', () => {
license.isApiKeyScopesEnabled.mockReturnValue(false);
publicApiKeyService.getApiKeyScopeMiddleware.mockReturnValue(jest.fn());
middlewares.apiKeyHasScopeWithGlobalScopeFallback({ scope: 'credential:create' });
expect(middlewares.globalScope).toHaveBeenCalledWith('credential:create');
});
});

View File

@@ -2,11 +2,12 @@ import { Container } from '@n8n/di';
import type { Response } from 'express';
import type { AuditRequest } from '@/public-api/types';
import { globalScope } from '@/public-api/v1/shared/middlewares/global.middleware';
import { apiKeyHasScopeWithGlobalScopeFallback } from '../../shared/middlewares/global.middleware';
export = {
generateAudit: [
globalScope('securityAudit:generate'),
apiKeyHasScopeWithGlobalScopeFallback({ scope: 'securityAudit:generate' }),
async (req: AuditRequest.Generate, res: Response): Promise<Response> => {
try {
const { SecurityAuditService } = await import('@/security-audit/security-audit.service');

View File

@@ -20,12 +20,13 @@ import {
toJsonSchema,
} from './credentials.service';
import type { CredentialTypeRequest, CredentialRequest } from '../../../types';
import { projectScope } from '../../shared/middlewares/global.middleware';
import { apiKeyHasScope, projectScope } from '../../shared/middlewares/global.middleware';
export = {
createCredential: [
validCredentialType,
validCredentialsProperties,
apiKeyHasScope('credential:create'),
async (
req: CredentialRequest.Create,
res: express.Response,
@@ -47,6 +48,7 @@ export = {
},
],
transferCredential: [
apiKeyHasScope('credential:move'),
projectScope('credential:move', 'credential'),
async (req: CredentialRequest.Transfer, res: express.Response) => {
const body = z.object({ destinationProjectId: z.string() }).parse(req.body);
@@ -61,6 +63,7 @@ export = {
},
],
deleteCredential: [
apiKeyHasScope('credential:delete'),
projectScope('credential:delete', 'credential'),
async (
req: CredentialRequest.Delete,

View File

@@ -8,12 +8,13 @@ import { ExecutionRepository } from '@/databases/repositories/execution.reposito
import { EventService } from '@/events/event.service';
import type { ExecutionRequest } from '../../../types';
import { validCursor } from '../../shared/middlewares/global.middleware';
import { apiKeyHasScope, validCursor } from '../../shared/middlewares/global.middleware';
import { encodeNextCursor } from '../../shared/services/pagination.service';
import { getSharedWorkflowIds } from '../workflows/workflows.service';
export = {
deleteExecution: [
apiKeyHasScope('execution:delete'),
async (req: ExecutionRequest.Delete, res: express.Response): Promise<express.Response> => {
const sharedWorkflowsIds = await getSharedWorkflowIds(req.user, ['workflow:delete']);
@@ -58,6 +59,7 @@ export = {
},
],
getExecution: [
apiKeyHasScope('execution:read'),
async (req: ExecutionRequest.Get, res: express.Response): Promise<express.Response> => {
const sharedWorkflowsIds = await getSharedWorkflowIds(req.user, ['workflow:read']);
@@ -88,6 +90,7 @@ export = {
},
],
getExecutions: [
apiKeyHasScope('execution:list'),
validCursor,
async (req: ExecutionRequest.GetAll, res: express.Response): Promise<express.Response> => {
const {

View File

@@ -7,7 +7,11 @@ import { ProjectRepository } from '@/databases/repositories/project.repository';
import type { PaginatedRequest } from '@/public-api/types';
import type { AuthenticatedRequest } from '@/requests';
import { globalScope, isLicensed, validCursor } from '../../shared/middlewares/global.middleware';
import {
apiKeyHasScopeWithGlobalScopeFallback,
isLicensed,
validCursor,
} from '../../shared/middlewares/global.middleware';
import { encodeNextCursor } from '../../shared/services/pagination.service';
type GetAll = PaginatedRequest;
@@ -15,7 +19,7 @@ type GetAll = PaginatedRequest;
export = {
createProject: [
isLicensed('feat:projectRole:admin'),
globalScope('project:create'),
apiKeyHasScopeWithGlobalScopeFallback({ scope: 'project:create' }),
async (req: AuthenticatedRequest, res: Response) => {
const payload = CreateProjectDto.safeParse(req.body);
if (payload.error) {
@@ -29,7 +33,7 @@ export = {
],
updateProject: [
isLicensed('feat:projectRole:admin'),
globalScope('project:update'),
apiKeyHasScopeWithGlobalScopeFallback({ scope: 'project:update' }),
async (req: AuthenticatedRequest<{ projectId: string }>, res: Response) => {
const payload = UpdateProjectDto.safeParse(req.body);
if (payload.error) {
@@ -48,7 +52,7 @@ export = {
],
deleteProject: [
isLicensed('feat:projectRole:admin'),
globalScope('project:delete'),
apiKeyHasScopeWithGlobalScopeFallback({ scope: 'project:delete' }),
async (req: AuthenticatedRequest<{ projectId: string }>, res: Response) => {
const query = DeleteProjectDto.safeParse(req.query);
if (query.error) {
@@ -67,7 +71,7 @@ export = {
],
getProjects: [
isLicensed('feat:projectRole:admin'),
globalScope('project:list'),
apiKeyHasScopeWithGlobalScopeFallback({ scope: 'project:list' }),
validCursor,
async (req: GetAll, res: Response) => {
const { offset = 0, limit = 100 } = req.query;

View File

@@ -13,11 +13,11 @@ import type { ImportResult } from '@/environments.ee/source-control/types/import
import { EventService } from '@/events/event.service';
import type { AuthenticatedRequest } from '@/requests';
import { globalScope } from '../../shared/middlewares/global.middleware';
import { apiKeyHasScopeWithGlobalScopeFallback } from '../../shared/middlewares/global.middleware';
export = {
pull: [
globalScope('sourceControl:pull'),
apiKeyHasScopeWithGlobalScopeFallback({ scope: 'sourceControl:pull' }),
async (
req: AuthenticatedRequest,
res: express.Response,

View File

@@ -8,12 +8,15 @@ import { TagRepository } from '@/databases/repositories/tag.repository';
import { TagService } from '@/services/tag.service';
import type { TagRequest } from '../../../types';
import { globalScope, validCursor } from '../../shared/middlewares/global.middleware';
import {
apiKeyHasScopeWithGlobalScopeFallback,
validCursor,
} from '../../shared/middlewares/global.middleware';
import { encodeNextCursor } from '../../shared/services/pagination.service';
export = {
createTag: [
globalScope('tag:create'),
apiKeyHasScopeWithGlobalScopeFallback({ scope: 'tag:create' }),
async (req: TagRequest.Create, res: express.Response): Promise<express.Response> => {
const { name } = req.body;
@@ -28,7 +31,7 @@ export = {
},
],
updateTag: [
globalScope('tag:update'),
apiKeyHasScopeWithGlobalScopeFallback({ scope: 'tag:update' }),
async (req: TagRequest.Update, res: express.Response): Promise<express.Response> => {
const { id } = req.params;
const { name } = req.body;
@@ -50,7 +53,7 @@ export = {
},
],
deleteTag: [
globalScope('tag:delete'),
apiKeyHasScopeWithGlobalScopeFallback({ scope: 'tag:delete' }),
async (req: TagRequest.Delete, res: express.Response): Promise<express.Response> => {
const { id } = req.params;
@@ -66,7 +69,7 @@ export = {
},
],
getTags: [
globalScope('tag:read'),
apiKeyHasScopeWithGlobalScopeFallback({ scope: 'tag:list' }),
validCursor,
async (req: TagRequest.GetAll, res: express.Response): Promise<express.Response> => {
const { offset = 0, limit = 100 } = req.query;
@@ -89,7 +92,7 @@ export = {
},
],
getTag: [
globalScope('tag:read'),
apiKeyHasScopeWithGlobalScopeFallback({ scope: 'tag:read' }),
async (req: TagRequest.Get, res: express.Response): Promise<express.Response> => {
const { id } = req.params;

View File

@@ -11,7 +11,7 @@ import type { AuthenticatedRequest, UserRequest } from '@/requests';
import { clean, getAllUsersAndCount, getUser } from './users.service.ee';
import {
globalScope,
apiKeyHasScopeWithGlobalScopeFallback,
isLicensed,
validCursor,
validLicenseWithUserQuota,
@@ -25,7 +25,7 @@ type ChangeRole = AuthenticatedRequest<{ id: string }, {}, RoleChangeRequestDto,
export = {
getUser: [
validLicenseWithUserQuota,
globalScope('user:read'),
apiKeyHasScopeWithGlobalScopeFallback({ scope: 'user:read' }),
async (req: UserRequest.Get, res: express.Response) => {
const { includeRole = false } = req.query;
const { id } = req.params;
@@ -47,9 +47,9 @@ export = {
},
],
getUsers: [
apiKeyHasScopeWithGlobalScopeFallback({ scope: 'user:list' }),
validLicenseWithUserQuota,
validCursor,
globalScope(['user:list', 'user:read']),
async (req: UserRequest.Get, res: express.Response) => {
const { offset = 0, limit = 100, includeRole = false, projectId } = req.query;
@@ -80,7 +80,7 @@ export = {
},
],
createUser: [
globalScope('user:create'),
apiKeyHasScopeWithGlobalScopeFallback({ scope: 'user:create' }),
async (req: Create, res: Response) => {
const { data, error } = InviteUsersRequestDto.safeParse(req.body);
if (error) {
@@ -96,7 +96,7 @@ export = {
},
],
deleteUser: [
globalScope('user:delete'),
apiKeyHasScopeWithGlobalScopeFallback({ scope: 'user:delete' }),
async (req: Delete, res: Response) => {
await Container.get(UsersController).deleteUser(req);
@@ -105,7 +105,7 @@ export = {
],
changeRole: [
isLicensed('feat:advancedPermissions'),
globalScope('user:changeRole'),
apiKeyHasScopeWithGlobalScopeFallback({ scope: 'user:changeRole' }),
async (req: ChangeRole, res: Response) => {
const validation = RoleChangeRequestDto.safeParse(req.body);
if (validation.error) {

View File

@@ -6,7 +6,11 @@ import { VariablesController } from '@/environments.ee/variables/variables.contr
import type { PaginatedRequest } from '@/public-api/types';
import type { VariablesRequest } from '@/requests';
import { globalScope, isLicensed, validCursor } from '../../shared/middlewares/global.middleware';
import {
apiKeyHasScopeWithGlobalScopeFallback,
isLicensed,
validCursor,
} from '../../shared/middlewares/global.middleware';
import { encodeNextCursor } from '../../shared/services/pagination.service';
type Create = VariablesRequest.Create;
@@ -16,7 +20,7 @@ type GetAll = PaginatedRequest;
export = {
createVariable: [
isLicensed('feat:variables'),
globalScope('variable:create'),
apiKeyHasScopeWithGlobalScopeFallback({ scope: 'variable:create' }),
async (req: Create, res: Response) => {
await Container.get(VariablesController).createVariable(req);
@@ -25,7 +29,7 @@ export = {
],
deleteVariable: [
isLicensed('feat:variables'),
globalScope('variable:delete'),
apiKeyHasScopeWithGlobalScopeFallback({ scope: 'variable:delete' }),
async (req: Delete, res: Response) => {
await Container.get(VariablesController).deleteVariable(req);
@@ -34,7 +38,7 @@ export = {
],
getVariables: [
isLicensed('feat:variables'),
globalScope('variable:list'),
apiKeyHasScopeWithGlobalScopeFallback({ scope: 'variable:list' }),
validCursor,
async (req: GetAll, res: Response) => {
const { offset = 0, limit = 100 } = req.query;

View File

@@ -32,11 +32,16 @@ import {
updateTags,
} from './workflows.service';
import type { WorkflowRequest } from '../../../types';
import { projectScope, validCursor } from '../../shared/middlewares/global.middleware';
import {
apiKeyHasScope,
projectScope,
validCursor,
} from '../../shared/middlewares/global.middleware';
import { encodeNextCursor } from '../../shared/services/pagination.service';
export = {
createWorkflow: [
apiKeyHasScope('workflow:create'),
async (req: WorkflowRequest.Create, res: express.Response): Promise<express.Response> => {
const workflow = req.body;
@@ -71,6 +76,7 @@ export = {
},
],
transferWorkflow: [
apiKeyHasScope('workflow:move'),
projectScope('workflow:move', 'workflow'),
async (req: WorkflowRequest.Transfer, res: express.Response) => {
const { id: workflowId } = req.params;
@@ -87,6 +93,7 @@ export = {
},
],
deleteWorkflow: [
apiKeyHasScope('workflow:delete'),
projectScope('workflow:delete', 'workflow'),
async (req: WorkflowRequest.Get, res: express.Response): Promise<express.Response> => {
const { id: workflowId } = req.params;
@@ -102,6 +109,7 @@ export = {
},
],
getWorkflow: [
apiKeyHasScope('workflow:read'),
projectScope('workflow:read', 'workflow'),
async (req: WorkflowRequest.Get, res: express.Response): Promise<express.Response> => {
const { id } = req.params;
@@ -134,6 +142,7 @@ export = {
},
],
getWorkflows: [
apiKeyHasScope('workflow:list'),
validCursor,
async (req: WorkflowRequest.GetAll, res: express.Response): Promise<express.Response> => {
const {
@@ -234,6 +243,7 @@ export = {
},
],
updateWorkflow: [
apiKeyHasScope('workflow:update'),
projectScope('workflow:update', 'workflow'),
async (req: WorkflowRequest.Update, res: express.Response): Promise<express.Response> => {
const { id } = req.params;
@@ -304,6 +314,7 @@ export = {
},
],
activateWorkflow: [
apiKeyHasScope('workflow:activate'),
projectScope('workflow:update', 'workflow'),
async (req: WorkflowRequest.Activate, res: express.Response): Promise<express.Response> => {
const { id } = req.params;
@@ -342,6 +353,7 @@ export = {
},
],
deactivateWorkflow: [
apiKeyHasScope('workflow:deactivate'),
projectScope('workflow:update', 'workflow'),
async (req: WorkflowRequest.Activate, res: express.Response): Promise<express.Response> => {
const { id } = req.params;
@@ -375,6 +387,7 @@ export = {
},
],
getWorkflowTags: [
apiKeyHasScope('workflowTags:list'),
projectScope('workflow:read', 'workflow'),
async (req: WorkflowRequest.GetTags, res: express.Response): Promise<express.Response> => {
const { id } = req.params;
@@ -401,6 +414,7 @@ export = {
},
],
updateWorkflowTags: [
apiKeyHasScope('workflowTags:update'),
projectScope('workflow:update', 'workflow'),
async (req: WorkflowRequest.UpdateTags, res: express.Response): Promise<express.Response> => {
const { id } = req.params;

View File

@@ -1,13 +1,15 @@
/* eslint-disable @typescript-eslint/no-invalid-void-type */
import { Container } from '@n8n/di';
import type { Scope } from '@n8n/permissions';
import type { ApiKeyScope, Scope } from '@n8n/permissions';
import type express from 'express';
import type { NextFunction } from 'express';
import { FeatureNotLicensedError } from '@/errors/feature-not-licensed.error';
import type { BooleanLicenseFeature } from '@/interfaces';
import { License } from '@/license';
import { userHasScopes } from '@/permissions.ee/check-access';
import type { AuthenticatedRequest } from '@/requests';
import { PublicApiKeyService } from '@/services/public-api-key.service';
import type { PaginatedRequest } from '../../../types';
import { decodeCursor } from '../services/pagination.service';
@@ -74,6 +76,27 @@ export const validCursor = (
return next();
};
const emptyMiddleware = (_req: Request, _res: Response, next: NextFunction) => next();
export const apiKeyHasScope = (apiKeyScope: ApiKeyScope) => {
return Container.get(License).isApiKeyScopesEnabled()
? Container.get(PublicApiKeyService).getApiKeyScopeMiddleware(apiKeyScope)
: emptyMiddleware;
};
export const apiKeyHasScopeWithGlobalScopeFallback = (
config: { scope: ApiKeyScope & Scope } | { apiKeyScope: ApiKeyScope; globalScope: Scope },
) => {
if ('scope' in config) {
return Container.get(License).isApiKeyScopesEnabled()
? Container.get(PublicApiKeyService).getApiKeyScopeMiddleware(config.scope)
: globalScope(config.scope);
} else {
return Container.get(License).isApiKeyScopesEnabled()
? Container.get(PublicApiKeyService).getApiKeyScopeMiddleware(config.apiKeyScope)
: globalScope(config.globalScope);
}
};
export const validLicenseWithUserQuota = (
_: express.Request,
res: express.Response,

View File

@@ -1,5 +1,5 @@
import type { ProjectIcon, ProjectRole, ProjectType } from '@n8n/api-types';
import type { Scope } from '@n8n/permissions';
import type { AssignableRole, GlobalRole, Scope } from '@n8n/permissions';
import type express from 'express';
import type {
ICredentialDataDecryptedObject,
@@ -11,7 +11,7 @@ import type {
import type { CredentialsEntity } from '@/databases/entities/credentials-entity';
import type { Project } from '@/databases/entities/project';
import type { AssignableRole, GlobalRole, User } from '@/databases/entities/user';
import type { User } from '@/databases/entities/user';
import type { Variables } from '@/databases/entities/variables';
import type { WorkflowEntity } from '@/databases/entities/workflow-entity';
import type { WorkflowHistory } from '@/databases/entities/workflow-history';

View File

@@ -1,5 +1,7 @@
import { Container } from '@n8n/di';
import { mock } from 'jest-mock-extended';
import type { ApiKeyScope } from '@n8n/permissions';
import type { Response, NextFunction } from 'express';
import { mock, mockDeep } from 'jest-mock-extended';
import { DateTime } from 'luxon';
import type { InstanceSettings } from 'n8n-core';
import { randomString } from 'n8n-workflow';
@@ -9,8 +11,9 @@ import { ApiKeyRepository } from '@/databases/repositories/api-key.repository';
import { UserRepository } from '@/databases/repositories/user.repository';
import { getConnection } from '@/db';
import type { EventService } from '@/events/event.service';
import { getOwnerOnlyApiKeyScopes } from '@/public-api/permissions.ee';
import type { AuthenticatedRequest } from '@/requests';
import { createOwnerWithApiKey } from '@test-integration/db/users';
import { createAdminWithApiKey, createOwnerWithApiKey } from '@test-integration/db/users';
import * as testDb from '@test-integration/test-db';
import { JwtService } from '../jwt.service';
@@ -250,4 +253,268 @@ describe('PublicApiKeyService', () => {
expect(redactedApiKey).toBe('******4kZo');
});
});
describe('removeOwnerOnlyScopesFromApiKeys', () => {
it("it should remove all owner only scopes from user's API keys", async () => {
// Arrange
const adminUser = await createAdminWithApiKey();
const apiKeyId = adminUser.apiKeys[0].id;
const ownerOnlyScopes = getOwnerOnlyApiKeyScopes();
const publicApiKeyService = new PublicApiKeyService(
apiKeyRepository,
userRepository,
jwtService,
eventService,
);
// Act
await publicApiKeyService.removeOwnerOnlyScopesFromApiKeys(adminUser);
// Assert
const apiKeyOnDb = await apiKeyRepository.findOneByOrFail({ id: apiKeyId });
expect(ownerOnlyScopes.some((ownerScope) => apiKeyOnDb.scopes.includes(ownerScope))).toBe(
false,
);
});
});
describe('getApiKeyScopedMiddleware', () => {
it('should allow access when API key has required scope', async () => {
// Arrange
const owner = await createOwnerWithApiKey({
scopes: ['workflow:read', 'user:read'],
});
const [{ apiKey }] = owner.apiKeys;
const requiredScope = 'workflow:read' as ApiKeyScope;
const req = mockReqWith(apiKey, '/test', 'GET');
const res = mockDeep<Response>();
res.status.mockReturnThis();
res.json.mockReturnThis();
const next = jest.fn() as NextFunction;
const publicApiKeyService = new PublicApiKeyService(
apiKeyRepository,
userRepository,
jwtService,
eventService,
);
// Act
const middleware = publicApiKeyService.getApiKeyScopeMiddleware(requiredScope);
await middleware(req, res, next);
// Assert
expect(next).toHaveBeenCalled();
expect(res.status).not.toHaveBeenCalled();
expect(res.json).not.toHaveBeenCalled();
});
it('should deny access when API key does not have required scope', async () => {
// Arrange
const owner = await createOwnerWithApiKey({
scopes: ['user:read'],
});
const [{ apiKey }] = owner.apiKeys;
const requiredScope = 'workflow:read' as ApiKeyScope;
const req = mockReqWith(apiKey, '/test', 'GET');
const res = mockDeep<Response>();
res.status.mockReturnThis();
res.json.mockReturnThis();
const next = jest.fn() as NextFunction;
const publicApiKeyService = new PublicApiKeyService(
apiKeyRepository,
userRepository,
jwtService,
eventService,
);
// Act
const middleware = publicApiKeyService.getApiKeyScopeMiddleware(requiredScope);
await middleware(req, res, next);
// Assert
expect(next).not.toHaveBeenCalled();
expect(res.status).toHaveBeenCalledWith(403);
expect(res.json).toHaveBeenCalledWith({ message: 'Forbidden' });
});
it('should deny access when API key is invalid', async () => {
// Arrange
const invalidApiKey = 'invalid-api-key';
const requiredScope = 'workflow:read' as ApiKeyScope;
const req = mockReqWith(invalidApiKey, '/test', 'GET');
const res = mockDeep<Response>();
res.status.mockReturnThis();
res.json.mockReturnThis();
const next = jest.fn() as NextFunction;
const publicApiKeyService = new PublicApiKeyService(
apiKeyRepository,
userRepository,
jwtService,
eventService,
);
// Act
const middleware = publicApiKeyService.getApiKeyScopeMiddleware(requiredScope);
await middleware(req, res, next);
// Assert
expect(next).not.toHaveBeenCalled();
expect(res.status).toHaveBeenCalledWith(403);
expect(res.json).toHaveBeenCalledWith({ message: 'Forbidden' });
});
it('should deny access when header x-n8n-api-key is not present', async () => {
// Arrange
const requiredScope = 'workflow:read' as ApiKeyScope;
const req = mock<AuthenticatedRequest>({
path: '/test',
method: 'GET',
});
const res = mockDeep<Response>();
res.status.mockReturnThis();
res.json.mockReturnThis();
const next = jest.fn() as NextFunction;
const publicApiKeyService = new PublicApiKeyService(
apiKeyRepository,
userRepository,
jwtService,
eventService,
);
// Act
const middleware = publicApiKeyService.getApiKeyScopeMiddleware(requiredScope);
await middleware(req, res, next);
// Assert
expect(next).not.toHaveBeenCalled();
expect(res.status).toHaveBeenCalledWith(401);
expect(res.json).toHaveBeenCalled();
});
});
describe('apiKeyHasValidScopes', () => {
it('should return true if API key has the required scope', async () => {
// Arrange
const owner = await createOwnerWithApiKey({
scopes: ['workflow:read', 'user:read'],
});
const apiKey = owner.apiKeys[0].apiKey;
const requiredScope = 'workflow:read' as ApiKeyScope;
const publicApiKeyService = new PublicApiKeyService(
apiKeyRepository,
userRepository,
jwtService,
eventService,
);
// Act
const result = await publicApiKeyService.apiKeyHasValidScopes(apiKey, requiredScope);
// Assert
expect(result).toBe(true);
});
it('should return false if API key does not have the required scope', async () => {
// Arrange
const owner = await createOwnerWithApiKey({
scopes: ['user:read'],
});
const apiKey = owner.apiKeys[0].apiKey;
const requiredScope = 'workflow:read' as ApiKeyScope;
const publicApiKeyService = new PublicApiKeyService(
apiKeyRepository,
userRepository,
jwtService,
eventService,
);
// Act
const result = await publicApiKeyService.apiKeyHasValidScopes(apiKey, requiredScope);
// Assert
expect(result).toBe(false);
});
});
describe('apiKeyHasValidScopesForRole', () => {
it('should return true if API key has the required scope for the role', async () => {
// Arrange
const publicApiKeyService = new PublicApiKeyService(
apiKeyRepository,
userRepository,
jwtService,
eventService,
);
const ownerOnlyScopes = getOwnerOnlyApiKeyScopes();
// Act
const result = publicApiKeyService.apiKeyHasValidScopesForRole(
'global:owner',
ownerOnlyScopes,
);
// Assert
expect(result).toBe(true);
});
it('should return false if API key does not have the required scope for the role', async () => {
// Arrange
const publicApiKeyService = new PublicApiKeyService(
apiKeyRepository,
userRepository,
jwtService,
eventService,
);
const ownerOnlyScopes = getOwnerOnlyApiKeyScopes();
// Act
const result = publicApiKeyService.apiKeyHasValidScopesForRole(
'global:member',
ownerOnlyScopes,
);
// Assert
expect(result).toBe(false);
});
});
});

View File

@@ -18,7 +18,7 @@ describe('UserService', () => {
});
const urlService = new UrlService(globalConfig);
const userRepository = mockInstance(UserRepository);
const userService = new UserService(mock(), userRepository, mock(), urlService, mock());
const userService = new UserService(mock(), userRepository, mock(), urlService, mock(), mock());
const commonMockUser = Object.assign(new User(), {
id: uuid(),

View File

@@ -195,6 +195,7 @@ export class FrontendService {
workflowHistory: false,
workerView: false,
advancedPermissions: false,
apiKeyScopes: false,
projects: {
team: {
limit: 0,
@@ -322,6 +323,7 @@ export class FrontendService {
this.license.isWorkflowHistoryLicensed() && config.getEnv('workflowHistory.enabled'),
workerView: this.license.isWorkerViewLicensed(),
advancedPermissions: this.license.isAdvancedPermissionsLicensed(),
apiKeyScopes: this.license.isApiKeyScopesEnabled(),
});
if (this.license.isLdapEnabled()) {

View File

@@ -1,6 +1,10 @@
import type { UnixTimestamp, UpdateApiKeyRequestDto } from '@n8n/api-types';
import type { CreateApiKeyRequestDto } from '@n8n/api-types/src/dto/api-keys/create-api-key-request.dto';
import { Service } from '@n8n/di';
import type { GlobalRole, ApiKeyScope } from '@n8n/permissions';
// eslint-disable-next-line n8n-local-rules/misplaced-n8n-typeorm-import
import type { EntityManager } from '@n8n/typeorm';
import type { NextFunction, Request, Response } from 'express';
import { TokenExpiredError } from 'jsonwebtoken';
import type { OpenAPIV3 } from 'openapi-types';
@@ -9,6 +13,7 @@ import type { User } from '@/databases/entities/user';
import { ApiKeyRepository } from '@/databases/repositories/api-key.repository';
import { UserRepository } from '@/databases/repositories/user.repository';
import { EventService } from '@/events/event.service';
import { getApiKeyScopesForRole, getOwnerOnlyApiKeyScopes } from '@/public-api/permissions.ee';
import type { AuthenticatedRequest } from '@/requests';
import { JwtService } from './jwt.service';
@@ -32,13 +37,17 @@ export class PublicApiKeyService {
* Creates a new public API key for the specified user.
* @param user - The user for whom the API key is being created.
*/
async createPublicApiKeyForUser(user: User, { label, expiresAt }: CreateApiKeyRequestDto) {
async createPublicApiKeyForUser(
user: User,
{ label, expiresAt, scopes }: CreateApiKeyRequestDto,
) {
const apiKey = this.generateApiKey(user, expiresAt);
await this.apiKeyRepository.insert(
this.apiKeyRepository.create({
userId: user.id,
apiKey,
label,
scopes,
}),
);
@@ -62,8 +71,12 @@ export class PublicApiKeyService {
await this.apiKeyRepository.delete({ userId: user.id, id: apiKeyId });
}
async updateApiKeyForUser(user: User, apiKeyId: string, { label }: UpdateApiKeyRequestDto) {
await this.apiKeyRepository.update({ id: apiKeyId, userId: user.id }, { label });
async updateApiKeyForUser(
user: User,
apiKeyId: string,
{ label, scopes }: UpdateApiKeyRequestDto,
) {
await this.apiKeyRepository.update({ id: apiKeyId, userId: user.id }, { label, scopes });
}
private async getUserForApiKey(apiKey: string) {
@@ -78,9 +91,6 @@ export class PublicApiKeyService {
/**
* Redacts an API key by replacing a portion of it with asterisks.
*
* The function keeps the last `REDACT_API_KEY_REVEAL_COUNT` characters of the API key visible
* and replaces the rest with asterisks, up to a maximum length defined by `REDACT_API_KEY_MAX_LENGTH`.
*
* @example
* ```typescript
* const redactedKey = PublicApiKeyService.redactApiKey('12345-abcdef-67890');
@@ -134,17 +144,74 @@ export class PublicApiKeyService {
};
}
private generateApiKey = (user: User, expiresAt: UnixTimestamp) => {
private generateApiKey(user: User, expiresAt: UnixTimestamp) {
const nowInSeconds = Math.floor(Date.now() / 1000);
return this.jwtService.sign(
{ sub: user.id, iss: API_KEY_ISSUER, aud: API_KEY_AUDIENCE },
{ ...(expiresAt && { expiresIn: expiresAt - nowInSeconds }) },
);
};
}
private getApiKeyExpiration = (apiKey: string) => {
const decoded = this.jwtService.decode(apiKey);
return decoded?.exp ?? null;
};
apiKeyHasValidScopesForRole(role: GlobalRole, apiKeyScopes: ApiKeyScope[]) {
const scopesForRole = getApiKeyScopesForRole(role);
return apiKeyScopes.every((scope) => scopesForRole.includes(scope));
}
async apiKeyHasValidScopes(apiKey: string, endpointScope: ApiKeyScope) {
const apiKeyData = await this.apiKeyRepository.findOne({
where: { apiKey },
select: { scopes: true },
});
if (!apiKeyData) return false;
return apiKeyData.scopes.includes(endpointScope);
}
getApiKeyScopeMiddleware(endpointScope: ApiKeyScope) {
return async (req: Request, res: Response, next: NextFunction) => {
const apiKey = req.headers['x-n8n-api-key'];
if (apiKey === undefined || typeof apiKey !== 'string') {
res.status(401).json({ message: 'Unauthorized' });
return;
}
const valid = await this.apiKeyHasValidScopes(apiKey, endpointScope);
if (!valid) {
res.status(403).json({ message: 'Forbidden' });
return;
}
next();
};
}
async removeOwnerOnlyScopesFromApiKeys(user: User, tx?: EntityManager) {
const manager = tx ?? this.apiKeyRepository.manager;
const ownerOnlyScopes = getOwnerOnlyApiKeyScopes();
const userApiKeys = await manager.find(ApiKey, {
where: { userId: user.id },
});
const keysWithOwnerScopes = userApiKeys.filter((apiKey) =>
apiKey.scopes.some((scope) => ownerOnlyScopes.includes(scope)),
);
return await Promise.all(
keysWithOwnerScopes.map(
async (currentApiKey) =>
await manager.update(ApiKey, currentApiKey.id, {
scopes: currentApiKey.scopes.filter((scope) => !ownerOnlyScopes.includes(scope)),
}),
),
);
}
}

View File

@@ -1,6 +1,7 @@
import type { ProjectRole } from '@n8n/api-types';
import { Service } from '@n8n/di';
import { combineScopes, type Resource, type Scope } from '@n8n/permissions';
import { combineScopes } from '@n8n/permissions';
import type { GlobalRole, Resource, Scope } from '@n8n/permissions';
import { UnexpectedError } from 'n8n-workflow';
import type { CredentialsEntity } from '@/databases/entities/credentials-entity';
@@ -10,7 +11,7 @@ import type {
SharedCredentials,
} from '@/databases/entities/shared-credentials';
import type { SharedWorkflow, WorkflowSharingRole } from '@/databases/entities/shared-workflow';
import type { GlobalRole, User } from '@/databases/entities/user';
import type { User } from '@/databases/entities/user';
import { License } from '@/license';
import {
GLOBAL_ADMIN_SCOPES,

View File

@@ -1,9 +1,11 @@
import type { RoleChangeRequestDto } from '@n8n/api-types';
import { Service } from '@n8n/di';
import type { AssignableRole } from '@n8n/permissions';
import { Logger } from 'n8n-core';
import type { IUserSettings } from 'n8n-workflow';
import { UnexpectedError } from 'n8n-workflow';
import type { User, AssignableRole } from '@/databases/entities/user';
import { User } from '@/databases/entities/user';
import { UserRepository } from '@/databases/repositories/user.repository';
import { InternalServerError } from '@/errors/response-errors/internal-server.error';
import { EventService } from '@/events/event.service';
@@ -13,6 +15,8 @@ import type { UserRequest } from '@/requests';
import { UrlService } from '@/services/url.service';
import { UserManagementMailer } from '@/user-management/email';
import { PublicApiKeyService } from './public-api-key.service';
@Service()
export class UserService {
constructor(
@@ -21,6 +25,7 @@ export class UserService {
private readonly mailer: UserManagementMailer,
private readonly urlService: UrlService,
private readonly eventService: EventService,
private readonly publicApiKeyService: PublicApiKeyService,
) {}
async update(userId: string, data: Partial<User>) {
@@ -227,4 +232,19 @@ export class UserService {
return { usersInvited, usersCreated: toCreateUsers.map(({ email }) => email) };
}
async changeUserRole(user: User, targetUser: User, newRole: RoleChangeRequestDto) {
return await this.userRepository.manager.transaction(async (trx) => {
await trx.update(User, { id: targetUser.id }, { role: newRole.newRoleName });
const adminDowngradedToMember =
user.role === 'global:owner' &&
targetUser.role === 'global:admin' &&
newRole.newRoleName === 'global:member';
if (adminDowngradedToMember) {
await this.publicApiKeyService.removeOwnerOnlyScopesFromApiKeys(targetUser, trx);
}
});
}
}

View File

@@ -1,9 +1,13 @@
import type { ApiKeyWithRawValue } from '@n8n/api-types';
import { GlobalConfig } from '@n8n/config';
import { Container } from '@n8n/di';
import type { ApiKeyScope } from '@n8n/permissions';
import { mock } from 'jest-mock-extended';
import type { User } from '@/databases/entities/user';
import { ApiKeyRepository } from '@/databases/repositories/api-key.repository';
import type { License } from '@/license';
import { getApiKeyScopesForRole, getOwnerOnlyApiKeyScopes } from '@/public-api/permissions.ee';
import { PublicApiKeyService } from '@/services/public-api-key.service';
import { mockInstance } from '@test/mocking';
@@ -15,6 +19,7 @@ import * as utils from './shared/utils/';
const testServer = utils.setupTestServer({ endpointGroups: ['apiKeys'] });
let publicApiKeyService: PublicApiKeyService;
const license = mock<License>();
beforeAll(() => {
publicApiKeyService = Container.get(PublicApiKeyService);
@@ -60,7 +65,7 @@ describe('Owner shell', () => {
const newApiKeyResponse = await testServer
.authAgentFor(ownerShell)
.post('/api-keys')
.send({ label: 'My API Key', expiresAt: null });
.send({ label: 'My API Key', expiresAt: null, scopes: ['workflow:create'] });
const newApiKey = newApiKeyResponse.body.data as ApiKeyWithRawValue;
@@ -78,19 +83,28 @@ describe('Owner shell', () => {
apiKey: newApiKey.rawApiKey,
createdAt: expect.any(Date),
updatedAt: expect.any(Date),
scopes: ['workflow:create'],
});
expect(newApiKey.expiresAt).toBeNull();
expect(newApiKey.rawApiKey).toBeDefined();
});
test('POST /api-keys should fail to create api key with invalid scope', async () => {
await testServer
.authAgentFor(ownerShell)
.post('/api-keys')
.send({ label: 'My API Key', expiresAt: null, scopes: ['wrong'] })
.expect(400);
});
test('POST /api-keys should create an api key with expiration', async () => {
const expiresAt = Date.now() + 1000;
const newApiKeyResponse = await testServer
.authAgentFor(ownerShell)
.post('/api-keys')
.send({ label: 'My API Key', expiresAt });
.send({ label: 'My API Key', expiresAt, scopes: ['workflow:create'] });
const newApiKey = newApiKeyResponse.body.data as ApiKeyWithRawValue;
@@ -108,24 +122,138 @@ describe('Owner shell', () => {
apiKey: newApiKey.rawApiKey,
createdAt: expect.any(Date),
updatedAt: expect.any(Date),
scopes: ['workflow:create'],
});
expect(newApiKey.expiresAt).toBe(expiresAt);
expect(newApiKey.rawApiKey).toBeDefined();
});
test("POST /api-keys should create an api key with scopes allow in the user's role", async () => {
const expiresAt = Date.now() + 1000;
const newApiKeyResponse = await testServer
.authAgentFor(ownerShell)
.post('/api-keys')
.send({ label: 'My API Key', expiresAt, scopes: ['user:create'] });
const newApiKey = newApiKeyResponse.body.data as ApiKeyWithRawValue;
expect(newApiKeyResponse.statusCode).toBe(200);
expect(newApiKey).toBeDefined();
const newStoredApiKey = await Container.get(ApiKeyRepository).findOneByOrFail({
userId: ownerShell.id,
});
expect(newStoredApiKey).toEqual({
id: expect.any(String),
label: 'My API Key',
userId: ownerShell.id,
apiKey: newApiKey.rawApiKey,
createdAt: expect.any(Date),
updatedAt: expect.any(Date),
scopes: ['user:create'],
});
expect(newApiKey.expiresAt).toBe(expiresAt);
expect(newApiKey.rawApiKey).toBeDefined();
});
test('PATCH /api-keys should update API key label', async () => {
const newApiKeyResponse = await testServer
.authAgentFor(ownerShell)
.post('/api-keys')
.send({ label: 'My API Key', expiresAt: null, scopes: ['user:create'] });
const newApiKey = newApiKeyResponse.body.data as ApiKeyWithRawValue;
await testServer
.authAgentFor(ownerShell)
.patch(`/api-keys/${newApiKey.id}`)
.send({ label: 'updated label', scopes: ['user:create'] });
const newStoredApiKey = await Container.get(ApiKeyRepository).findOneByOrFail({
userId: ownerShell.id,
});
expect(newStoredApiKey).toEqual({
id: expect.any(String),
label: 'updated label',
userId: ownerShell.id,
apiKey: newApiKey.rawApiKey,
createdAt: expect.any(Date),
updatedAt: expect.any(Date),
scopes: ['user:create'],
});
});
test('PATCH /api-keys should update API key scopes', async () => {
const newApiKeyResponse = await testServer
.authAgentFor(ownerShell)
.post('/api-keys')
.send({ label: 'My API Key', expiresAt: null, scopes: ['user:create'] });
const newApiKey = newApiKeyResponse.body.data as ApiKeyWithRawValue;
await testServer
.authAgentFor(ownerShell)
.patch(`/api-keys/${newApiKey.id}`)
.send({ label: 'updated label', scopes: ['user:create', 'workflow:create'] });
const newStoredApiKey = await Container.get(ApiKeyRepository).findOneByOrFail({
userId: ownerShell.id,
});
expect(newStoredApiKey).toEqual({
id: expect.any(String),
label: 'updated label',
userId: ownerShell.id,
apiKey: newApiKey.rawApiKey,
createdAt: expect.any(Date),
updatedAt: expect.any(Date),
scopes: ['user:create', 'workflow:create'],
});
});
test('PATCH /api-keys should not modify API key expiration', async () => {
const newApiKeyResponse = await testServer
.authAgentFor(ownerShell)
.post('/api-keys')
.send({ label: 'My API Key', expiresAt: null, scopes: ['user:create'] });
const newApiKey = newApiKeyResponse.body.data as ApiKeyWithRawValue;
await testServer
.authAgentFor(ownerShell)
.patch(`/api-keys/${newApiKey.id}`)
.send({ label: 'updated label', expiresAt: 123, scopes: ['user:create'] });
const getApiKeysResponse = await testServer.authAgentFor(ownerShell).get('/api-keys');
const allApiKeys = getApiKeysResponse.body.data as ApiKeyWithRawValue[];
const updatedApiKey = allApiKeys.find((apiKey) => apiKey.id === newApiKey.id);
expect(updatedApiKey?.expiresAt).toBe(null);
});
test('GET /api-keys should fetch the api key redacted', async () => {
const expirationDateInTheFuture = Date.now() + 1000;
const apiKeyWithNoExpiration = await testServer
.authAgentFor(ownerShell)
.post('/api-keys')
.send({ label: 'My API Key', expiresAt: null });
.send({ label: 'My API Key', expiresAt: null, scopes: ['workflow:create'] });
const apiKeyWithExpiration = await testServer
.authAgentFor(ownerShell)
.post('/api-keys')
.send({ label: 'My API Key 2', expiresAt: expirationDateInTheFuture });
.send({
label: 'My API Key 2',
expiresAt: expirationDateInTheFuture,
scopes: ['workflow:create'],
});
const retrieveAllApiKeysResponse = await testServer.authAgentFor(ownerShell).get('/api-keys');
@@ -139,6 +267,7 @@ describe('Owner shell', () => {
createdAt: expect.any(String),
updatedAt: expect.any(String),
expiresAt: expirationDateInTheFuture,
scopes: ['workflow:create'],
});
expect(retrieveAllApiKeysResponse.body.data[0]).toEqual({
@@ -149,6 +278,7 @@ describe('Owner shell', () => {
createdAt: expect.any(String),
updatedAt: expect.any(String),
expiresAt: null,
scopes: ['workflow:create'],
});
});
@@ -156,7 +286,7 @@ describe('Owner shell', () => {
const newApiKeyResponse = await testServer
.authAgentFor(ownerShell)
.post('/api-keys')
.send({ label: 'My API Key', expiresAt: null });
.send({ label: 'My API Key', expiresAt: null, scopes: ['workflow:create'] });
const deleteApiKeyResponse = await testServer
.authAgentFor(ownerShell)
@@ -167,6 +297,16 @@ describe('Owner shell', () => {
expect(deleteApiKeyResponse.body.data.success).toBe(true);
expect(retrieveAllApiKeysResponse.body.data.length).toBe(0);
});
test('GET /api-keys/scopes should return scopes for the role', async () => {
const apiKeyScopesResponse = await testServer.authAgentFor(ownerShell).get('/api-keys/scopes');
const scopes = apiKeyScopesResponse.body.data as ApiKeyScope[];
const scopesForRole = getApiKeyScopesForRole(ownerShell.role);
expect(scopes).toEqual(scopesForRole);
});
});
describe('Member', () => {
@@ -185,7 +325,7 @@ describe('Member', () => {
const newApiKeyResponse = await testServer
.authAgentFor(member)
.post('/api-keys')
.send({ label: 'My API Key', expiresAt: null });
.send({ label: 'My API Key', expiresAt: null, scopes: ['workflow:create'] });
expect(newApiKeyResponse.statusCode).toBe(200);
expect(newApiKeyResponse.body.data.apiKey).toBeDefined();
@@ -202,6 +342,7 @@ describe('Member', () => {
apiKey: newApiKeyResponse.body.data.rawApiKey,
createdAt: expect.any(Date),
updatedAt: expect.any(Date),
scopes: ['workflow:create'],
});
expect(newApiKeyResponse.body.data.expiresAt).toBeNull();
@@ -214,7 +355,7 @@ describe('Member', () => {
const newApiKeyResponse = await testServer
.authAgentFor(member)
.post('/api-keys')
.send({ label: 'My API Key', expiresAt });
.send({ label: 'My API Key', expiresAt, scopes: ['workflow:create'] });
const newApiKey = newApiKeyResponse.body.data as ApiKeyWithRawValue;
@@ -232,21 +373,57 @@ describe('Member', () => {
apiKey: newApiKey.rawApiKey,
createdAt: expect.any(Date),
updatedAt: expect.any(Date),
scopes: ['workflow:create'],
});
expect(newApiKey.expiresAt).toBe(expiresAt);
expect(newApiKey.rawApiKey).toBeDefined();
});
test('POST /api-keys should fail if max number of API keys reached', async () => {
await testServer.authAgentFor(member).post('/api-keys').send({ label: 'My API Key' });
test("POST /api-keys should create an api key with scopes allowed in the user's role", async () => {
const expiresAt = Date.now() + 1000;
license.isApiKeyScopesEnabled.mockReturnValue(true);
const secondApiKey = await testServer
const newApiKeyResponse = await testServer
.authAgentFor(member)
.post('/api-keys')
.send({ label: 'My API Key' });
.send({ label: 'My API Key', expiresAt, scopes: ['workflow:create'] });
expect(secondApiKey.statusCode).toBe(400);
const newApiKey = newApiKeyResponse.body.data as ApiKeyWithRawValue;
expect(newApiKeyResponse.statusCode).toBe(200);
expect(newApiKey).toBeDefined();
const newStoredApiKey = await Container.get(ApiKeyRepository).findOneByOrFail({
userId: member.id,
});
expect(newStoredApiKey).toEqual({
id: expect.any(String),
label: 'My API Key',
userId: member.id,
apiKey: newApiKey.rawApiKey,
createdAt: expect.any(Date),
updatedAt: expect.any(Date),
scopes: ['workflow:create'],
});
expect(newApiKey.expiresAt).toBe(expiresAt);
expect(newApiKey.rawApiKey).toBeDefined();
});
test("POST /api-keys should fail to create api key with scopes not allowed in the user's role", async () => {
const expiresAt = Date.now() + 1000;
license.isApiKeyScopesEnabled.mockReturnValue(true);
const notAllowedScope = getOwnerOnlyApiKeyScopes()[0];
const newApiKeyResponse = await testServer
.authAgentFor(member)
.post('/api-keys')
.send({ label: 'My API Key', expiresAt, scopes: [notAllowedScope] });
expect(newApiKeyResponse.statusCode).toBe(400);
});
test('GET /api-keys should fetch the api key redacted', async () => {
@@ -255,12 +432,16 @@ describe('Member', () => {
const apiKeyWithNoExpiration = await testServer
.authAgentFor(member)
.post('/api-keys')
.send({ label: 'My API Key', expiresAt: null });
.send({ label: 'My API Key', expiresAt: null, scopes: ['workflow:create'] });
const apiKeyWithExpiration = await testServer
.authAgentFor(member)
.post('/api-keys')
.send({ label: 'My API Key 2', expiresAt: expirationDateInTheFuture });
.send({
label: 'My API Key 2',
expiresAt: expirationDateInTheFuture,
scopes: ['workflow:create'],
});
const retrieveAllApiKeysResponse = await testServer.authAgentFor(member).get('/api-keys');
@@ -274,6 +455,7 @@ describe('Member', () => {
createdAt: expect.any(String),
updatedAt: expect.any(String),
expiresAt: expirationDateInTheFuture,
scopes: ['workflow:create'],
});
expect(retrieveAllApiKeysResponse.body.data[0]).toEqual({
@@ -284,6 +466,7 @@ describe('Member', () => {
createdAt: expect.any(String),
updatedAt: expect.any(String),
expiresAt: null,
scopes: ['workflow:create'],
});
});
@@ -291,7 +474,7 @@ describe('Member', () => {
const newApiKeyResponse = await testServer
.authAgentFor(member)
.post('/api-keys')
.send({ label: 'My API Key', expiresAt: null });
.send({ label: 'My API Key', expiresAt: null, scopes: ['workflow:create'] });
const deleteApiKeyResponse = await testServer
.authAgentFor(member)
@@ -302,4 +485,14 @@ describe('Member', () => {
expect(deleteApiKeyResponse.body.data.success).toBe(true);
expect(retrieveAllApiKeysResponse.body.data.length).toBe(0);
});
test('GET /api-keys/scopes should return scopes for the role', async () => {
const apiKeyScopesResponse = await testServer.authAgentFor(member).get('/api-keys/scopes');
const scopes = apiKeyScopesResponse.body.data as ApiKeyScope[];
const scopesForRole = getApiKeyScopesForRole(member.role);
expect(scopes).toEqual(scopesForRole);
});
});

View File

@@ -1,11 +1,10 @@
import type { ProjectRole } from '@n8n/api-types';
import { Container } from '@n8n/di';
import type { Scope } from '@n8n/permissions';
import type { GlobalRole, Scope } from '@n8n/permissions';
import { EntityNotFoundError } from '@n8n/typeorm';
import { ActiveWorkflowManager } from '@/active-workflow-manager';
import type { Project } from '@/databases/entities/project';
import type { GlobalRole } from '@/databases/entities/user';
import { FolderRepository } from '@/databases/repositories/folder.repository';
import { ProjectRelationRepository } from '@/databases/repositories/project-relation.repository';
import { ProjectRepository } from '@/databases/repositories/project.repository';

File diff suppressed because it is too large Load Diff

View File

@@ -39,12 +39,6 @@ describe('With license unlimited quota:users', () => {
await authOwnerAgent.get('/users').expect(401);
});
test('should fail due to member trying to access owner only endpoint', async () => {
const member = await createMemberWithApiKey();
const authMemberAgent = testServer.publicApiAgentFor(member);
await authMemberAgent.get('/users').expect(403);
});
test('should return all users', async () => {
const owner = await createOwnerWithApiKey();
@@ -139,6 +133,7 @@ describe('With license unlimited quota:users', () => {
test('should fail due to member trying to access owner only endpoint', async () => {
const member = await createMemberWithApiKey();
const authMemberAgent = testServer.publicApiAgentFor(member);
await authMemberAgent.get(`/users/${member.id}`).expect(403);
});

View File

@@ -13,6 +13,7 @@ import * as testDb from '../shared/test-db';
describe('Users in Public API', () => {
const testServer = setupTestServer({ endpointGroups: ['publicApi'] });
mockInstance(Telemetry);
beforeAll(async () => {

View File

@@ -1,10 +1,9 @@
import type { ProjectRole } from '@n8n/api-types';
import { Container } from '@n8n/di';
import type { Scope } from '@n8n/permissions';
import type { GlobalRole, Scope } from '@n8n/permissions';
import type { CredentialSharingRole } from '@/databases/entities/shared-credentials';
import type { WorkflowSharingRole } from '@/databases/entities/shared-workflow';
import type { GlobalRole } from '@/databases/entities/user';
import { RoleService } from '@/services/role.service';
import { createMember } from './shared/db/users';

View File

@@ -1,17 +1,24 @@
import { Container } from '@n8n/di';
import type { ApiKeyScope, GlobalRole } from '@n8n/permissions';
import { hash } from 'bcryptjs';
import { AuthIdentity } from '@/databases/entities/auth-identity';
import { type GlobalRole, type User } from '@/databases/entities/user';
import { type User } from '@/databases/entities/user';
import { AuthIdentityRepository } from '@/databases/repositories/auth-identity.repository';
import { AuthUserRepository } from '@/databases/repositories/auth-user.repository';
import { UserRepository } from '@/databases/repositories/user.repository';
import { MfaService } from '@/mfa/mfa.service';
import { TOTPService } from '@/mfa/totp.service';
import { getApiKeyScopesForRole } from '@/public-api/permissions.ee';
import { PublicApiKeyService } from '@/services/public-api-key.service';
import { randomEmail, randomName, randomValidPassword } from '../random';
type ApiKeyOptions = {
expiresAt?: number | null;
scopes?: ApiKeyScope[];
};
// pre-computed bcrypt hash for the string 'password', using `await hash('password', 10)`
const passwordHash = '$2a$10$njedH7S6V5898mj6p0Jr..IGY9Ms.qNwR7RbSzzX9yubJocKfvGGK';
@@ -82,28 +89,35 @@ export async function createUserWithMfaEnabled(
export const addApiKey = async (
user: User,
{ expiresAt = null }: { expiresAt?: number | null } = {},
{ expiresAt = null, scopes = [] }: { expiresAt?: number | null; scopes?: ApiKeyScope[] } = {},
) => {
return await Container.get(PublicApiKeyService).createPublicApiKeyForUser(user, {
label: randomName(),
expiresAt,
scopes: scopes.length ? scopes : getApiKeyScopesForRole(user.role),
});
};
export async function createOwnerWithApiKey({
expiresAt = null,
}: { expiresAt?: number | null } = {}) {
export async function createOwnerWithApiKey({ expiresAt = null, scopes = [] }: ApiKeyOptions = {}) {
const owner = await createOwner();
const apiKey = await addApiKey(owner, { expiresAt });
const apiKey = await addApiKey(owner, { expiresAt, scopes });
owner.apiKeys = [apiKey];
return owner;
}
export async function createMemberWithApiKey({
expiresAt = null,
}: { expiresAt?: number | null } = {}) {
scopes = [],
}: ApiKeyOptions = {}) {
const member = await createMember();
const apiKey = await addApiKey(member, { expiresAt });
const apiKey = await addApiKey(member, { expiresAt, scopes });
member.apiKeys = [apiKey];
return member;
}
export async function createAdminWithApiKey({ expiresAt = null, scopes = [] }: ApiKeyOptions = {}) {
const member = await createAdmin();
const apiKey = await addApiKey(member, { expiresAt, scopes });
member.apiKeys = [apiKey];
return member;
}

View File

@@ -1544,7 +1544,8 @@ export type EnterpriseEditionFeatureKey =
| 'DebugInEditor'
| 'WorkflowHistory'
| 'WorkerView'
| 'AdvancedPermissions';
| 'AdvancedPermissions'
| 'ApiKeyScopes';
export type EnterpriseEditionFeatureValue = keyof Omit<FrontendSettings['enterprise'], 'projects'>;

View File

@@ -36,6 +36,7 @@ export const defaultSettings: FrontendSettings = {
externalSecrets: false,
workerView: false,
advancedPermissions: false,
apiKeyScopes: false,
projects: {
team: {
limit: 1,

View File

@@ -29,6 +29,7 @@ import {
} from '@/constants';
import type { INodeUi, IWorkflowDb } from '@/Interface';
import { CanvasNodeRenderType } from '@/types';
import type { FrontendSettings } from '@n8n/api-types';
export const mockNode = ({
id = uuid(),
@@ -205,6 +206,35 @@ export function createTestNode(node: Partial<INode> = {}): INode {
};
}
export function createMockEnterpriseSettings(
overrides: Partial<FrontendSettings['enterprise']> = {},
): FrontendSettings['enterprise'] {
return {
sharing: false,
ldap: false,
saml: false,
logStreaming: false,
advancedExecutionFilters: false,
variables: false,
sourceControl: false,
auditLogs: false,
externalSecrets: false,
showNonProdBanner: false,
debugInEditor: false,
binaryDataS3: false,
workflowHistory: false,
workerView: false,
advancedPermissions: false,
apiKeyScopes: false,
projects: {
team: {
limit: 0,
},
},
...overrides, // Override with any passed properties
};
}
export function createTestTaskData(partialData: Partial<ITaskData>): ITaskData {
return {
startTime: 0,

View File

@@ -6,11 +6,16 @@ import type {
ApiKey,
ApiKeyWithRawValue,
} from '@n8n/api-types';
import type { ApiKeyScope } from '@n8n/permissions';
export async function getApiKeys(context: IRestApiContext): Promise<ApiKey[]> {
return await makeRestApiRequest(context, 'GET', '/api-keys');
}
export async function getApiKeyScopes(context: IRestApiContext): Promise<ApiKeyScope[]> {
return await makeRestApiRequest(context, 'GET', '/api-keys/scopes');
}
export async function createApiKey(
context: IRestApiContext,
payload: CreateApiKeyRequestDto,

View File

@@ -8,6 +8,8 @@ import { fireEvent } from '@testing-library/vue';
import { useApiKeysStore } from '@/stores/apiKeys.store';
import { DateTime } from 'luxon';
import type { ApiKeyWithRawValue } from '@n8n/api-types';
import { useSettingsStore } from '@/stores/settings.store';
import { createMockEnterpriseSettings } from '@/__tests__/mocks';
const renderComponent = createComponentRenderer(ApiKeyEditModal, {
pinia: createTestingPinia({
@@ -29,13 +31,17 @@ const testApiKey: ApiKeyWithRawValue = {
updatedAt: new Date().toString(),
rawApiKey: '123456',
expiresAt: 0,
scopes: ['user:create', 'user:list'],
};
const apiKeysStore = mockedStore(useApiKeysStore);
const settingsStore = mockedStore(useSettingsStore);
describe('ApiKeyCreateOrEditModal', () => {
beforeEach(() => {
createAppModals();
apiKeysStore.availableScopes = ['user:create', 'user:list'];
settingsStore.settings.enterprise = createMockEnterpriseSettings({ apiKeyScopes: false });
});
afterEach(() => {
@@ -87,6 +93,7 @@ describe('ApiKeyCreateOrEditModal', () => {
updatedAt: new Date().toString(),
rawApiKey: '***456',
expiresAt: 0,
scopes: ['user:create', 'user:list'],
});
const { getByText, getByPlaceholderText, getByTestId } = renderComponent({
@@ -184,6 +191,116 @@ describe('ApiKeyCreateOrEditModal', () => {
expect(getByText('new api key')).toBeInTheDocument();
});
test('should allow creating API key with scopes when feat:apiKeyScopes is enabled', async () => {
settingsStore.settings.enterprise = createMockEnterpriseSettings({ apiKeyScopes: true });
apiKeysStore.createApiKey.mockResolvedValue(testApiKey);
const { getByText, getByPlaceholderText, getByTestId, getAllByText } = renderComponent({
props: {
mode: 'new',
},
});
await retry(() => expect(getByText('Create API Key')).toBeInTheDocument());
expect(getByText('Label')).toBeInTheDocument();
const inputLabel = getByPlaceholderText('e.g Internal Project');
const saveButton = getByText('Save');
const scopesSelect = getByTestId('scopes-select');
expect(inputLabel).toBeInTheDocument();
expect(scopesSelect).toBeInTheDocument();
expect(saveButton).toBeInTheDocument();
await fireEvent.update(inputLabel, 'new label');
await fireEvent.click(scopesSelect);
const userCreateScope = getByText('user:create');
expect(userCreateScope).toBeInTheDocument();
await fireEvent.click(userCreateScope);
const [userCreateTag, userCreateSelectOption] = getAllByText('user:create');
expect(userCreateTag).toBeInTheDocument();
expect(userCreateSelectOption).toBeInTheDocument();
await fireEvent.click(saveButton);
expect(getByText('API Key Created')).toBeInTheDocument();
expect(getByText('Done')).toBeInTheDocument();
expect(
getByText('Make sure to copy your API key now as you will not be able to see this again.'),
).toBeInTheDocument();
expect(getByText('Click to copy')).toBeInTheDocument();
expect(getByText('new api key')).toBeInTheDocument();
});
test('should not let the user select scopes and show upgrade banner when feat:apiKeyScopes is disabled', async () => {
settingsStore.settings.enterprise = createMockEnterpriseSettings({ apiKeyScopes: false });
apiKeysStore.createApiKey.mockResolvedValue(testApiKey);
const { getByText, getByPlaceholderText, getByTestId, getAllByText } = renderComponent({
props: {
mode: 'new',
},
});
await retry(() => expect(getByText('Create API Key')).toBeInTheDocument());
expect(getByText('Label')).toBeInTheDocument();
const inputLabel = getByPlaceholderText('e.g Internal Project');
const saveButton = getByText('Save');
expect(getByText('Upgrade')).toBeInTheDocument();
expect(getByText('to unlock the ability to modify API key scopes')).toBeInTheDocument();
const scopesSelect = getByTestId('scopes-select');
expect(inputLabel).toBeInTheDocument();
expect(scopesSelect).toBeInTheDocument();
expect(saveButton).toBeInTheDocument();
await fireEvent.update(inputLabel, 'new label');
await fireEvent.click(scopesSelect);
const userCreateScope = getAllByText('user:create');
const [userCreateTag, userCreateSelectOption] = userCreateScope;
expect(userCreateTag).toBeInTheDocument();
expect(userCreateSelectOption).toBeInTheDocument();
expect(userCreateSelectOption).toBeInTheDocument();
expect(userCreateSelectOption.parentNode).toHaveClass('is-disabled');
await fireEvent.click(userCreateSelectOption);
await fireEvent.click(saveButton);
expect(getByText('API Key Created')).toBeInTheDocument();
expect(getByText('Done')).toBeInTheDocument();
expect(
getByText('Make sure to copy your API key now as you will not be able to see this again.'),
).toBeInTheDocument();
expect(getByText('Click to copy')).toBeInTheDocument();
expect(getByText('new api key')).toBeInTheDocument();
});
test('should allow editing API key label', async () => {
apiKeysStore.apiKeys = [testApiKey];
@@ -218,6 +335,9 @@ describe('ApiKeyCreateOrEditModal', () => {
await fireEvent.click(editButton);
expect(apiKeysStore.updateApiKey).toHaveBeenCalledWith('123', { label: 'updated api key' });
expect(apiKeysStore.updateApiKey).toHaveBeenCalledWith('123', {
label: 'updated api key',
scopes: ['user:create', 'user:list'],
});
});
});

View File

@@ -1,6 +1,6 @@
<script lang="ts" setup>
import Modal from '@/components/Modal.vue';
import { API_KEY_CREATE_OR_EDIT_MODAL_KEY } from '@/constants';
import { API_KEY_CREATE_OR_EDIT_MODAL_KEY, EnterpriseEditionFeature } from '@/constants';
import { computed, onMounted, ref } from 'vue';
import { useUIStore } from '@/stores/ui.store';
import { createEventBus } from '@n8n/utils/event-bus';
@@ -13,6 +13,9 @@ import type { BaseTextKey } from '@/plugins/i18n';
import { N8nText } from '@n8n/design-system';
import { DateTime } from 'luxon';
import type { ApiKey, ApiKeyWithRawValue, CreateApiKeyRequestDto } from '@n8n/api-types';
import ApiKeyScopes from '@/components/ApiKeyScopes.vue';
import type { ApiKeyScope } from '@n8n/permissions';
import { useSettingsStore } from '@/stores/settings.store';
const EXPIRATION_OPTIONS = {
'7_DAYS': 7,
@@ -28,7 +31,7 @@ const { showError, showMessage } = useToast();
const uiStore = useUIStore();
const rootStore = useRootStore();
const { createApiKey, updateApiKey, apiKeysById } = useApiKeysStore();
const { createApiKey, updateApiKey, apiKeysById, availableScopes } = useApiKeysStore();
const documentTitle = useDocumentTitle();
const label = ref('');
@@ -40,6 +43,14 @@ const rawApiKey = ref('');
const customExpirationDate = ref('');
const showExpirationDateSelector = ref(false);
const apiKeyCreationDate = ref('');
const selectedScopes = ref<ApiKeyScope[]>([]);
const settingsStore = useSettingsStore();
const apiKeyStore = useApiKeysStore();
const apiKeyScopesEnabled = computed(
() => settingsStore.isEnterpriseFeatureEnabled[EnterpriseEditionFeature.ApiKeyScopes],
);
const calculateExpirationDate = (daysFromNow: number) => {
const date = DateTime.now()
@@ -88,7 +99,11 @@ const allFormFieldsAreSet = computed(() => {
(expirationDaysFromNow.value === EXPIRATION_OPTIONS.CUSTOM && customExpirationDate.value) ||
expirationDate.value;
return label.value && (props.mode === 'edit' ? true : isExpirationDateSet);
return (
label.value &&
(!apiKeyScopesEnabled.value ? true : selectedScopes.value.length) &&
(props.mode === 'edit' ? true : isExpirationDateSet)
);
});
const isCustomDateInThePast = (date: Date) => Date.now() > date.getTime();
@@ -104,6 +119,11 @@ onMounted(() => {
const apiKey = apiKeysById[props.activeId];
label.value = apiKey.label ?? '';
apiKeyCreationDate.value = getApiKeyCreationTime(apiKey);
selectedScopes.value = !apiKeyScopesEnabled.value ? apiKeyStore.availableScopes : apiKey.scopes;
}
if (props.mode === 'new' && !apiKeyScopesEnabled.value) {
selectedScopes.value = availableScopes;
}
});
@@ -111,6 +131,10 @@ function onInput(value: string): void {
label.value = value;
}
function onScopeSelectionChanged(scopes: ApiKeyScope[]) {
selectedScopes.value = scopes;
}
const getApiKeyCreationTime = (apiKey: ApiKey): string => {
const time = DateTime.fromMillis(Date.parse(apiKey.createdAt)).toFormat('ccc, MMM d yyyy');
return i18n.baseText('settings.api.creationTime', { interpolate: { time } });
@@ -119,7 +143,7 @@ const getApiKeyCreationTime = (apiKey: ApiKey): string => {
async function onEdit() {
try {
loading.value = true;
await updateApiKey(props.activeId, { label: label.value });
await updateApiKey(props.activeId, { label: label.value, scopes: selectedScopes.value });
showMessage({
type: 'success',
title: i18n.baseText('settings.api.update.toast'),
@@ -152,6 +176,7 @@ const onSave = async () => {
const payload: CreateApiKeyRequestDto = {
label: label.value,
expiresAt: expirationUnixTimestamp,
scopes: selectedScopes.value,
};
try {
@@ -218,7 +243,7 @@ async function handleEnterKey(event: KeyboardEvent) {
width="600px"
:lock-scroll="false"
:close-on-esc="true"
:close-on-click-outside="true"
:close-on-click-modal="false"
:show-close="true"
>
<template #content>
@@ -260,6 +285,7 @@ async function handleEnterKey(event: KeyboardEvent) {
v-model="expirationDaysFromNow"
size="large"
filterable
readonly
data-test-id="expiration-select"
@update:model-value="onSelect"
>
@@ -291,6 +317,12 @@ async function handleEnterKey(event: KeyboardEvent) {
:disabled-date="isCustomDateInThePast"
/>
</div>
<ApiKeyScopes
v-model="selectedScopes"
:available-scopes="availableScopes"
:enabled="apiKeyScopesEnabled"
@update:model-value="onScopeSelectionChanged"
/>
</div>
</div>
</template>

View File

@@ -0,0 +1,186 @@
<script setup>
import { ref, computed, watch } from 'vue';
// eslint-disable-next-line import/no-extraneous-dependencies
import { ElSelect, ElOption, ElOptionGroup } from 'element-plus';
import { capitalCase } from 'change-case';
import { useI18n } from '@/composables/useI18n';
import { usePageRedirectionHelper } from '@/composables/usePageRedirectionHelper';
// Define props
const props = defineProps({
modelValue: {
type: Array,
default: () => [],
},
availableScopes: {
type: Array,
default: () => [],
},
enabled: {
type: Boolean,
default: false,
},
});
const emit = defineEmits(['update:modelValue']);
const selectedScopes = ref(props.modelValue);
const i18n = useI18n();
const { goToUpgrade } = usePageRedirectionHelper();
const checkAll = ref(false);
const indeterminate = ref(false);
const groupedScopes = computed(() => {
const groups = {};
props.availableScopes.forEach((scope) => {
const [resource, action] = scope.split(':');
if (!groups[resource]) {
groups[resource] = [];
}
if (action) {
groups[resource].push(action);
}
});
return groups;
});
watch(selectedScopes, (newValue) => {
if (newValue.length === props.availableScopes.length) {
indeterminate.value = false;
checkAll.value = true;
} else if (newValue.length > 0) {
indeterminate.value = true;
} else if (newValue.length === 0) {
indeterminate.value = false;
checkAll.value = false;
}
emit('update:modelValue', newValue);
});
watch(checkAll, (newValue) => {
if (newValue) {
selectedScopes.value = props.availableScopes;
} else {
selectedScopes.value = [];
}
});
function goToUpgradeApiKeyScopes() {
void goToUpgrade('api-key-scopes', 'upgrade-api-key-scopes');
}
</script>
<template>
<div :class="$style['api-key-scopes']">
<div ref="popperContainer"></div>
<N8nInputLabel :label="i18n.baseText('settings.api.scopes.label')" color="text-dark">
<ElSelect
v-model="selectedScopes"
data-test-id="scopes-select"
:popper-class="$style['scopes-dropdown-container']"
:teleported="true"
multiple
collapse-tags
:max-collapse-tags="10"
placement="top"
:reserve-keyword="false"
:placeholder="i18n.baseText('settings.api.scopes.placeholder')"
:append-to="popperContainer"
>
<template #header>
<el-checkbox
v-model="checkAll"
:disabled="!enabled"
:class="$style['scopes-checkbox']"
:indeterminate="indeterminate"
>
{{ i18n.baseText('settings.api.scopes.selectAll') }}
</el-checkbox>
</template>
<template v-for="(actions, resource) in groupedScopes" :key="resource">
<ElOptionGroup :disabled="!enabled" :label="capitalCase(resource).toUpperCase()">
<ElOption
v-for="action in actions"
:key="`${resource}:${action}`"
:label="`${resource}:${action}`"
:value="`${resource}:${action}`"
/>
</ElOptionGroup>
</template>
</ElSelect>
</N8nInputLabel>
<N8nNotice v-if="!enabled">
<i18n-t keypath="settings.api.scopes.upgrade">
<template #link>
<n8n-link size="small" @click="goToUpgradeApiKeyScopes">
{{ i18n.baseText('settings.api.scopes.upgrade.link') }}
</n8n-link>
</template>
</i18n-t>
</N8nNotice>
</div>
</template>
<style module>
.api-key-scopes :global(.el-tag) {
padding: var(--spacing-3xs);
}
.api-key-scopes :global(.el-tag__close) {
color: white;
margin-left: var(--spacing-3xs);
background-color: var(--color-text-base);
}
.api-key-scopes :global(.el-checkbox) {
margin-left: var(--spacing-xs);
}
.scopes-dropdown-container :global(.el-select-group__title) {
font-size: var(--font-size-2xs);
color: var(--color-text-dark);
font-weight: var(--font-weight-bold);
border-bottom: var(--spacing-5xs) solid var(--color-text-lighter);
padding-left: var(--spacing-xs);
}
.scopes-dropdown-container :global(.el-select-dropdown__item) {
color: var(--color-text-base);
font-weight: var(--font-weight-regular);
padding-left: var(--spacing-xs);
}
.scopes-dropdown-container
:global(.el-select-dropdown.is-multiple .el-select-dropdown__item.selected) {
font-weight: var(--font-weight-bold);
}
.scopes-dropdown-container :global(.el-select-group__wrap:not(:last-of-type)) {
padding: 0px;
margin-bottom: var(--spacing-xs);
}
.scopes-dropdown-container :global(.el-checkbox) {
margin-left: var(--spacing-2xs);
}
.scopes-dropdown-container :global(.el-select-dropdown__header) {
margin-top: var(--spacing-xs);
padding-bottom: var(--spacing-xs);
border-bottom: var(--spacing-5xs) solid var(--color-text-lighter);
}
.scopes-checkbox {
display: flex;
}
.scopes-dropdown-container :global(.el-select-group__wrap::after) {
display: none;
}
</style>

View File

@@ -634,6 +634,7 @@ export const EnterpriseEditionFeature: Record<
WorkflowHistory: 'workflowHistory',
WorkerView: 'workerView',
AdvancedPermissions: 'advancedPermissions',
ApiKeyScopes: 'apiKeyScopes',
};
export const MAIN_NODE_PANEL_WIDTH = 390;

View File

@@ -1902,6 +1902,8 @@
"settings.users.userRoleUpdated.message": "{user} has been successfully updated to a {role}",
"settings.users.userRoleUpdatedError": "Unable to updated role",
"settings.api": "API",
"settings.api.scopes.upgrade": "{link} to unlock the ability to modify API key scopes",
"settings.api.scopes.upgrade.link": "Upgrade",
"settings.n8napi": "n8n API",
"settings.log-streaming": "Log Streaming",
"settings.log-streaming.heading": "Log Streaming",
@@ -1984,6 +1986,9 @@
"settings.api.view.modal.done.button": "Done",
"settings.api.view.modal.edit.button": "Edit",
"settings.api.view.modal.save.button": "Save",
"settings.api.scopes.placeholder": "Select",
"settings.api.scopes.selectAll": "Select All",
"settings.api.scopes.label": "Scopes",
"settings.version": "Version",
"settings.usageAndPlan.title": "Usage and plan",
"settings.usageAndPlan.description": "Youre on the {name} {type}",

View File

@@ -5,9 +5,11 @@ import { useRootStore } from '@/stores/root.store';
import * as publicApiApi from '@/api/api-keys';
import { computed, ref } from 'vue';
import type { ApiKey, CreateApiKeyRequestDto, UpdateApiKeyRequestDto } from '@n8n/api-types';
import type { ApiKeyScope } from '@n8n/permissions';
export const useApiKeysStore = defineStore(STORES.API_KEYS, () => {
const apiKeys = ref<ApiKey[]>([]);
const availableScopes = ref<ApiKeyScope[]>([]);
const rootStore = useRootStore();
@@ -25,6 +27,11 @@ export const useApiKeysStore = defineStore(STORES.API_KEYS, () => {
);
});
const getApiKeyAvailableScopes = async () => {
availableScopes.value = await publicApiApi.getApiKeyScopes(rootStore.restApiContext);
return availableScopes.value;
};
const getAndCacheApiKeys = async () => {
if (apiKeys.value.length) return apiKeys.value;
apiKeys.value = await publicApiApi.getApiKeys(rootStore.restApiContext);
@@ -46,6 +53,7 @@ export const useApiKeysStore = defineStore(STORES.API_KEYS, () => {
const updateApiKey = async (id: string, payload: UpdateApiKeyRequestDto) => {
await publicApiApi.updateApiKey(rootStore.restApiContext, id, payload);
apiKeysById.value[id].label = payload.label;
apiKeysById.value[id].scopes = payload.scopes;
};
return {
@@ -53,8 +61,10 @@ export const useApiKeysStore = defineStore(STORES.API_KEYS, () => {
createApiKey,
deleteApiKey,
updateApiKey,
getApiKeyAvailableScopes,
apiKeysSortByCreationDate,
apiKeysById,
apiKeys,
availableScopes,
};
});

View File

@@ -107,6 +107,7 @@ describe('SettingsApiView', () => {
updatedAt: new Date().toString(),
apiKey: '****Atcr',
expiresAt: null,
scopes: ['user:create'],
},
{
id: '2',
@@ -115,6 +116,7 @@ describe('SettingsApiView', () => {
updatedAt: new Date().toString(),
apiKey: '****Bdcr',
expiresAt: dateInTheFuture.toSeconds(),
scopes: ['user:create'],
},
{
id: '3',
@@ -123,6 +125,7 @@ describe('SettingsApiView', () => {
updatedAt: new Date().toString(),
apiKey: '****Wtcr',
expiresAt: dateInThePast.toSeconds(),
scopes: ['user:create'],
},
];
@@ -163,6 +166,7 @@ describe('SettingsApiView', () => {
updatedAt: new Date().toString(),
apiKey: '****Atcr',
expiresAt: null,
scopes: ['user:create'],
},
{
id: '2',
@@ -171,6 +175,7 @@ describe('SettingsApiView', () => {
updatedAt: new Date().toString(),
apiKey: '****Bdcr',
expiresAt: dateInTheFuture.toSeconds(),
scopes: ['user:create'],
},
{
id: '3',
@@ -179,6 +184,7 @@ describe('SettingsApiView', () => {
updatedAt: new Date().toString(),
apiKey: '****Wtcr',
expiresAt: dateInThePast.toSeconds(),
scopes: ['user:create'],
},
];
@@ -212,6 +218,7 @@ describe('SettingsApiView', () => {
updatedAt: new Date().toString(),
apiKey: '****Atcr',
expiresAt: null,
scopes: ['user:create'],
},
];

View File

@@ -28,7 +28,7 @@ const telemetry = useTelemetry();
const loading = ref(false);
const apiKeysStore = useApiKeysStore();
const { getAndCacheApiKeys, deleteApiKey } = apiKeysStore;
const { getAndCacheApiKeys, deleteApiKey, getApiKeyAvailableScopes } = apiKeysStore;
const { apiKeysSortByCreationDate } = storeToRefs(apiKeysStore);
const { isSwaggerUIEnabled, publicApiPath, publicApiLatestVersion } = settingsStore;
const { baseUrl } = useRootStore();
@@ -55,17 +55,17 @@ onMounted(async () => {
if (!isPublicApiEnabled) return;
await getApiKeys();
await getApiKeysAndScopes();
});
function onUpgrade() {
void goToUpgrade('settings-n8n-api', 'upgrade-api', 'redirect');
}
async function getApiKeys() {
async function getApiKeysAndScopes() {
try {
loading.value = true;
await getAndCacheApiKeys();
await Promise.all([getAndCacheApiKeys(), getApiKeyAvailableScopes()]);
} catch (error) {
showError(error, i18n.baseText('settings.api.view.error'));
} finally {

3
pnpm-lock.yaml generated
View File

@@ -320,6 +320,9 @@ importers:
packages/@n8n/api-types:
dependencies:
'@n8n/permissions':
specifier: workspace:*
version: link:../permissions
n8n-workflow:
specifier: workspace:*
version: link:../../workflow