mirror of
https://github.com/Abdulazizzn/n8n-enterprise-unlocked.git
synced 2025-12-16 17:46:45 +00:00
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:
@@ -28,6 +28,8 @@
|
||||
"n8n-workflow": "workspace:*",
|
||||
"xss": "catalog:",
|
||||
"zod": "catalog:",
|
||||
"zod-class": "0.0.16"
|
||||
"zod-class": "0.0.16",
|
||||
"@n8n/permissions": "workspace:*"
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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,
|
||||
}) {}
|
||||
|
||||
@@ -136,6 +136,7 @@ export interface FrontendSettings {
|
||||
workflowHistory: boolean;
|
||||
workerView: boolean;
|
||||
advancedPermissions: boolean;
|
||||
apiKeyScopes: boolean;
|
||||
projects: {
|
||||
team: {
|
||||
limit: number;
|
||||
|
||||
16
packages/@n8n/api-types/src/schemas/scopes.schema.ts
Normal file
16
packages/@n8n/api-types/src/schemas/scopes.schema.ts
Normal 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[];
|
||||
});
|
||||
@@ -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;
|
||||
|
||||
@@ -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'>;
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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> = {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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']);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
];
|
||||
|
||||
@@ -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,
|
||||
];
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
import { AddScopesColumnToApiKeys1742918400000 as BaseMigration } from '../common/1742918400000-AddScopesColumnToApiKeys';
|
||||
|
||||
export class AddScopesColumnToApiKeys1742918400000 extends BaseMigration {
|
||||
transaction = false as const;
|
||||
}
|
||||
@@ -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 };
|
||||
|
||||
@@ -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> {
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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',
|
||||
];
|
||||
24
packages/cli/src/public-api/permissions.ee/index.ts
Normal file
24
packages/cli/src/public-api/permissions.ee/index.ts
Normal 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);
|
||||
};
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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');
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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()) {
|
||||
|
||||
@@ -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)),
|
||||
}),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -1544,7 +1544,8 @@ export type EnterpriseEditionFeatureKey =
|
||||
| 'DebugInEditor'
|
||||
| 'WorkflowHistory'
|
||||
| 'WorkerView'
|
||||
| 'AdvancedPermissions';
|
||||
| 'AdvancedPermissions'
|
||||
| 'ApiKeyScopes';
|
||||
|
||||
export type EnterpriseEditionFeatureValue = keyof Omit<FrontendSettings['enterprise'], 'projects'>;
|
||||
|
||||
|
||||
@@ -36,6 +36,7 @@ export const defaultSettings: FrontendSettings = {
|
||||
externalSecrets: false,
|
||||
workerView: false,
|
||||
advancedPermissions: false,
|
||||
apiKeyScopes: false,
|
||||
projects: {
|
||||
team: {
|
||||
limit: 1,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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'],
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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>
|
||||
|
||||
186
packages/frontend/editor-ui/src/components/ApiKeyScopes.vue
Normal file
186
packages/frontend/editor-ui/src/components/ApiKeyScopes.vue
Normal 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>
|
||||
@@ -634,6 +634,7 @@ export const EnterpriseEditionFeature: Record<
|
||||
WorkflowHistory: 'workflowHistory',
|
||||
WorkerView: 'workerView',
|
||||
AdvancedPermissions: 'advancedPermissions',
|
||||
ApiKeyScopes: 'apiKeyScopes',
|
||||
};
|
||||
|
||||
export const MAIN_NODE_PANEL_WIDTH = 390;
|
||||
|
||||
@@ -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": "You’re on the {name} {type}",
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
});
|
||||
|
||||
@@ -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'],
|
||||
},
|
||||
];
|
||||
|
||||
|
||||
@@ -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
3
pnpm-lock.yaml
generated
@@ -320,6 +320,9 @@ importers:
|
||||
|
||||
packages/@n8n/api-types:
|
||||
dependencies:
|
||||
'@n8n/permissions':
|
||||
specifier: workspace:*
|
||||
version: link:../permissions
|
||||
n8n-workflow:
|
||||
specifier: workspace:*
|
||||
version: link:../../workflow
|
||||
|
||||
Reference in New Issue
Block a user