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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 0 additions & 1 deletion packages/@n8n/api-types/src/frontend-settings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -54,7 +54,6 @@ export interface IEnterpriseSettings {
binaryDataS3: boolean;
workerView: boolean;
advancedPermissions: boolean;
apiKeyScopes: boolean;
workflowDiffs: boolean;
namedVersions: boolean;
provisioning: boolean;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,23 +4,19 @@ import { Container } from '@n8n/di';
import { mock } from 'jest-mock-extended';

import { EventService } from '@/events/event.service';
import { License } from '@/license';
import { PublicApiKeyService } from '@/services/public-api-key.service';

import { ApiKeysController } from '../api-keys.controller';

describe('ApiKeysController', () => {
const publicApiKeyService = mockInstance(PublicApiKeyService);
const eventService = mockInstance(EventService);
const license = mockInstance(License);

const controller = Container.get(ApiKeysController);

let req: AuthenticatedRequest;
beforeAll(() => {
req = { user: { id: '123' } } as AuthenticatedRequest;
// Mock license as enabled by default for API key scopes
license.isLicensed.mockReturnValue(true);
});

describe('createAPIKey', () => {
Expand Down
34 changes: 5 additions & 29 deletions packages/cli/src/controllers/api-keys.controller.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,4 @@
import { CreateApiKeyRequestDto, UpdateApiKeyRequestDto } from '@n8n/api-types';
import { LICENSE_FEATURES } from '@n8n/constants';
import { AuthenticatedRequest } from '@n8n/db';
import {
Body,
Expand All @@ -11,12 +10,11 @@ import {
Post,
RestController,
} from '@n8n/decorators';
import { getApiKeyScopesForRole, type ApiKeyScope } from '@n8n/permissions';
import { getApiKeyScopesForRole } from '@n8n/permissions';
import type { RequestHandler } from 'express';

import { BadRequestError } from '@/errors/response-errors/bad-request.error';
import { EventService } from '@/events/event.service';
import { License } from '@/license';
import { isApiEnabled } from '@/public-api';
import { PublicApiKeyService } from '@/services/public-api-key.service';

Expand All @@ -33,7 +31,6 @@ export class ApiKeysController {
constructor(
private readonly eventService: EventService,
private readonly publicApiKeyService: PublicApiKeyService,
private readonly license: License,
) {}

/**
Expand All @@ -46,16 +43,11 @@ export class ApiKeysController {
_res: Response,
@Body body: CreateApiKeyRequestDto,
) {
const scopes = this.resolveScopesForUser(req.user, body.scopes);

if (!this.publicApiKeyService.apiKeyHasValidScopesForRole(req.user, scopes)) {
if (!this.publicApiKeyService.apiKeyHasValidScopesForRole(req.user, body.scopes)) {
throw new BadRequestError('Invalid scopes for user role');
}

const newApiKey = await this.publicApiKeyService.createPublicApiKeyForUser(req.user, {
...body,
scopes,
});
const newApiKey = await this.publicApiKeyService.createPublicApiKeyForUser(req.user, body);

this.eventService.emit('public-api-key-created', { user: req.user, publicApi: false });

Expand Down Expand Up @@ -101,16 +93,11 @@ export class ApiKeysController {
@Param('id') apiKeyId: string,
@Body body: UpdateApiKeyRequestDto,
) {
const scopes = this.resolveScopesForUser(req.user, body.scopes);

if (!this.publicApiKeyService.apiKeyHasValidScopesForRole(req.user, scopes)) {
if (!this.publicApiKeyService.apiKeyHasValidScopesForRole(req.user, body.scopes)) {
throw new BadRequestError('Invalid scopes for user role');
}

await this.publicApiKeyService.updateApiKeyForUser(req.user, apiKeyId, {
...body,
scopes,
});
await this.publicApiKeyService.updateApiKeyForUser(req.user, apiKeyId, body);

return { success: true };
}
Expand All @@ -121,15 +108,4 @@ export class ApiKeysController {
const scopes = getApiKeyScopesForRole(req.user);
return scopes;
}

/**
* Resolves the scopes to be used for an API key based on license status.
* If the API Key Scopes feature is not licensed, returns all available scopes for the user's role.
* Otherwise, returns the requested scopes.
*/
private resolveScopesForUser(user: AuthenticatedRequest['user'], requestedScopes: ApiKeyScope[]) {
return this.license.isLicensed(LICENSE_FEATURES.API_KEY_SCOPES)
? requestedScopes
: getApiKeyScopesForRole(user);
}
}
5 changes: 0 additions & 5 deletions packages/cli/src/license.ts
Original file line number Diff line number Diff line change
Expand Up @@ -280,11 +280,6 @@ export class License implements LicenseProvider {
return this.isLicensed(LICENSE_FEATURES.SAML);
}

/** @deprecated Use `LicenseState.isApiKeyScopesLicensed` instead. */
isApiKeyScopesEnabled() {
return this.isLicensed(LICENSE_FEATURES.API_KEY_SCOPES);
}

/** @deprecated Use `LicenseState.isAiAssistantLicensed` instead. */
isAiAssistantEnabled() {
return this.isLicensed(LICENSE_FEATURES.AI_ASSISTANT);
Expand Down
30 changes: 4 additions & 26 deletions packages/cli/src/public-api/v1/__tests__/global.middleware.test.ts
Original file line number Diff line number Diff line change
@@ -1,55 +1,33 @@
import { mockInstance } from '@n8n/backend-test-utils';
import type { NextFunction } from 'express';
import { mock } from 'jest-mock-extended';

import { License } from '@/license';
import { PublicApiKeyService } from '@/services/public-api-key.service';

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);
it('should return API key scope middleware', () => {
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);
it('should return API key scope middleware', () => {
publicApiKeyService.getApiKeyScopeMiddleware.mockReturnValue(jest.fn());

middlewares.apiKeyHasScopeWithGlobalScopeFallback({ scope: 'credential:create' });

expect(middlewares.globalScope).toHaveBeenCalledWith('credential:create');
// eslint-disable-next-line @typescript-eslint/unbound-method
expect(publicApiKeyService.getApiKeyScopeMiddleware).toHaveBeenCalledWith('credential:create');
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -4,16 +4,13 @@ import { Container } from '@n8n/di';
import type { Response } from 'express';
import type { AuthenticatedRequest } from '@n8n/db';

import { License } from '@/license';

import * as discoverService from '../discover.service';

const handler = require('../discover.handler');

describe('Discover Handler', () => {
let mockApiKeyRepository: jest.Mocked<ApiKeyRepository>;
let mockResponse: Partial<Response>;
const mockLicense = { isApiKeyScopesEnabled: jest.fn().mockReturnValue(true) };

beforeEach(() => {
jest.clearAllMocks();
Expand All @@ -22,7 +19,6 @@ describe('Discover Handler', () => {

jest.spyOn(Container, 'get').mockImplementation((serviceClass) => {
if (serviceClass === ApiKeyRepository) return mockApiKeyRepository as any;
if (serviceClass === License) return mockLicense as any;
return {} as any;
});

Expand Down Expand Up @@ -93,7 +89,6 @@ describe('Discover Handler', () => {
expect(mockResponse.json).toHaveBeenCalledWith({ data: mockDiscoverResponse });
expect(discoverService.buildDiscoverResponse).toHaveBeenCalledWith(scopes, {
includeSchemas: false,
scopesEnabled: true,
resource: undefined,
operation: undefined,
});
Expand Down Expand Up @@ -146,7 +141,6 @@ describe('Discover Handler', () => {

expect(discoverService.buildDiscoverResponse).toHaveBeenCalledWith(scopes, {
includeSchemas: true,
scopesEnabled: true,
resource: undefined,
operation: undefined,
});
Expand All @@ -173,7 +167,6 @@ describe('Discover Handler', () => {

expect(discoverService.buildDiscoverResponse).toHaveBeenCalledWith(scopes, {
includeSchemas: false,
scopesEnabled: true,
resource: 'workflow',
operation: 'create',
});
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,6 @@ import type { AuthenticatedRequest } from '@n8n/db';
import { Container } from '@n8n/di';
import type express from 'express';

import { License } from '@/license';

import { buildDiscoverResponse } from './discover.service';

const API_KEY_AUDIENCE = 'public-api';
Expand Down Expand Up @@ -40,11 +38,9 @@ export = {
return res.status(401).json({ message: 'Unauthorized' });
}

const scopesEnabled = Container.get(License).isApiKeyScopesEnabled();
const includeSchemas = req.query.include === 'schemas';
const response = await buildDiscoverResponse(apiKeyRecord.scopes, {
includeSchemas,
scopesEnabled,
resource: firstString(req.query.resource),
operation: firstString(req.query.operation),
});
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,6 @@ export interface DiscoverResponse {

export interface DiscoverOptions {
includeSchemas?: boolean;
scopesEnabled?: boolean;
resource?: string;
operation?: string;
}
Expand Down Expand Up @@ -161,13 +160,8 @@ export async function buildDiscoverResponse(
const allEndpoints = await parseEndpointsFromSpec();
const scopeSet = new Set(callerScopes);
const includeSchemas = options?.includeSchemas === true;
const scopesEnabled = options?.scopesEnabled !== false;

// When scopes are not licensed (community edition), show all endpoints
// since all API keys have unrestricted access regardless of stored scopes
const filtered = scopesEnabled
? allEndpoints.filter((ep) => ep.scope === null || scopeSet.has(ep.scope))
: allEndpoints;
const filtered = allEndpoints.filter((ep) => ep.scope === null || scopeSet.has(ep.scope));

const resources: Record<string, ResourceInfo> = {};

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,6 @@ import type { AuthenticatedRequest } from '@n8n/db';
import { Container } from '@n8n/di';
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 { NotFoundError } from '@/errors/response-errors/not-found.error';
Expand Down Expand Up @@ -87,9 +86,6 @@ export const validCursor = (
return next();
};

const emptyMiddleware = (_req: express.Request, _res: express.Response, next: NextFunction) =>
next();

export type ScopeTaggedMiddleware = ((...args: unknown[]) => unknown) & {
__apiKeyScope: ApiKeyScope;
};
Expand All @@ -106,24 +102,15 @@ function tagMiddleware(
}

export const apiKeyHasScope = (apiKeyScope: ApiKeyScope) => {
const middleware = Container.get(License).isApiKeyScopesEnabled()
? Container.get(PublicApiKeyService).getApiKeyScopeMiddleware(apiKeyScope)
: emptyMiddleware;
const middleware = Container.get(PublicApiKeyService).getApiKeyScopeMiddleware(apiKeyScope);
return tagMiddleware(middleware, apiKeyScope);
};

export const apiKeyHasScopeWithGlobalScopeFallback = (
config: { scope: ApiKeyScope & Scope } | { apiKeyScope: ApiKeyScope; globalScope: Scope },
) => {
const apiKeyScope = 'scope' in config ? config.scope : config.apiKeyScope;
if (!Container.get(License).isApiKeyScopesEnabled()) {
const fallbackScope = 'scope' in config ? config.scope : config.globalScope;
return tagMiddleware(globalScope(fallbackScope), apiKeyScope);
}
return tagMiddleware(
Container.get(PublicApiKeyService).getApiKeyScopeMiddleware(apiKeyScope),
apiKeyScope,
);
const scope = 'scope' in config ? config.scope : config.apiKeyScope;
return tagMiddleware(Container.get(PublicApiKeyService).getApiKeyScopeMiddleware(scope), scope);
};

export const validLicenseWithUserQuota = (
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -116,7 +116,7 @@ describe('FrontendService', () => {
isDebugInEditorLicensed: jest.fn().mockReturnValue(false),
isWorkerViewLicensed: jest.fn().mockReturnValue(false),
isAdvancedPermissionsLicensed: jest.fn().mockReturnValue(false),
isApiKeyScopesEnabled: jest.fn().mockReturnValue(false),

getVariablesLimit: jest.fn().mockReturnValue(0),
getTeamProjectLimit: jest.fn().mockReturnValue(0),
isBinaryDataS3Licensed: jest.fn().mockReturnValue(false),
Expand Down
4 changes: 2 additions & 2 deletions packages/cli/src/services/frontend.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -319,7 +319,7 @@ export class FrontendService {
binaryDataS3: false,
workerView: false,
advancedPermissions: false,
apiKeyScopes: false,

workflowDiffs: false,
namedVersions: false,
provisioning: false,
Expand Down Expand Up @@ -469,7 +469,7 @@ export class FrontendService {
binaryDataS3: isS3Available && isS3Selected && isS3Licensed,
workerView: this.license.isWorkerViewLicensed(),
advancedPermissions: this.license.isAdvancedPermissionsLicensed(),
apiKeyScopes: this.license.isApiKeyScopesEnabled(),

workflowDiffs: this.licenseState.isWorkflowDiffsLicensed(),
namedVersions: this.license.isLicensed(LICENSE_FEATURES.NAMED_VERSIONS),
customRoles: this.licenseState.isCustomRolesLicensed(),
Expand Down
Loading
Loading