feat(core): Support disabling certain permission scopes in personal space for canvas only (no-changelog) (#38858)

Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
Konstantin Tieber 2026-09-21 16:07:42 +00:00 committed by GitHub
parent c4527242c9
commit 4faeb001ba
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
8 changed files with 398 additions and 7 deletions

View File

@ -0,0 +1,88 @@
import { Container } from '@n8n/di';
import { CanvasOnlyConfig } from '../canvas-only.config';
describe('CanvasOnlyConfig', () => {
const originalEnv = process.env;
beforeEach(() => {
Container.reset();
vi.restoreAllMocks();
});
afterEach(() => {
process.env = originalEnv;
});
describe('defaults', () => {
test('enabled is false', () => {
process.env = {};
expect(Container.get(CanvasOnlyConfig).enabled).toBe(false);
});
test('personalSpaceScopeDenyList is empty', () => {
process.env = {};
expect(Container.get(CanvasOnlyConfig).personalSpaceScopeDenyList).toEqual([]);
});
});
describe('N8N_CANVAS_ONLY', () => {
test('sets enabled to true', () => {
process.env = { N8N_CANVAS_ONLY: 'true' };
expect(Container.get(CanvasOnlyConfig).enabled).toBe(true);
});
});
describe('N8N_CANVAS_ONLY_PERSONAL_SPACE_SCOPE_DENY_LIST', () => {
test('parses a comma-separated list of deniable scopes', () => {
process.env = {
N8N_CANVAS_ONLY_PERSONAL_SPACE_SCOPE_DENY_LIST: 'credential:create,agent:create',
};
expect(Container.get(CanvasOnlyConfig).personalSpaceScopeDenyList).toEqual([
'credential:create',
'agent:create',
]);
});
test('accepts every deniable scope', () => {
process.env = {
N8N_CANVAS_ONLY_PERSONAL_SPACE_SCOPE_DENY_LIST:
'credential:create,dataTable:create,agent:create',
};
expect(Container.get(CanvasOnlyConfig).personalSpaceScopeDenyList).toEqual([
'credential:create',
'dataTable:create',
'agent:create',
]);
});
test('trims whitespace, drops empty entries and removes duplicates', () => {
process.env = {
N8N_CANVAS_ONLY_PERSONAL_SPACE_SCOPE_DENY_LIST:
' credential:create , ,agent:create,credential:create,',
};
expect(Container.get(CanvasOnlyConfig).personalSpaceScopeDenyList).toEqual([
'credential:create',
'agent:create',
]);
});
test('treats a blank value as an empty list', () => {
process.env = { N8N_CANVAS_ONLY_PERSONAL_SPACE_SCOPE_DENY_LIST: '' };
expect(Container.get(CanvasOnlyConfig).personalSpaceScopeDenyList).toEqual([]);
});
test.each([
['a scope that is not deniable', 'credential:create,workflow:create'],
['an unknown value', 'nonsense'],
])('warns and keeps the default when the list contains %s', (_label, value) => {
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
process.env = { N8N_CANVAS_ONLY_PERSONAL_SPACE_SCOPE_DENY_LIST: value };
expect(Container.get(CanvasOnlyConfig).personalSpaceScopeDenyList).toEqual([]);
expect(warn).toHaveBeenCalledWith(
expect.stringContaining('Invalid value for N8N_CANVAS_ONLY_PERSONAL_SPACE_SCOPE_DENY_LIST'),
);
});
});
});

View File

@ -0,0 +1,47 @@
import { z } from 'zod';
import { Config, Env } from '../decorators';
/**
* The scopes an operator may take away from the personal space role in
* canvas-only mode. Every other scope of that role stays fixed.
*/
export const CANVAS_ONLY_PERSONAL_SPACE_DENIABLE_SCOPES = [
'credential:create',
'dataTable:create',
'agent:create',
] as const;
export type CanvasOnlyPersonalSpaceDeniableScope =
(typeof CANVAS_ONLY_PERSONAL_SPACE_DENIABLE_SCOPES)[number];
const deniableScopeSchema = z.enum(CANVAS_ONLY_PERSONAL_SPACE_DENIABLE_SCOPES);
/**
* Parses the comma-separated deny list. One entry outside the deniable scopes
* rejects the whole value, so the `Env` decorator warns and keeps the default.
*/
const personalSpaceScopeDenyListSchema = z
.string()
.transform((raw) =>
[...new Set(raw.split(',').map((entry) => entry.trim()))].filter((entry) => entry.length > 0),
)
.pipe(z.array(deniableScopeSchema));
@Config
export class CanvasOnlyConfig {
/** Whether to enable canvas-only mode, hiding the chrome UI. */
@Env('N8N_CANVAS_ONLY')
enabled: boolean = false;
/**
* Comma-separated scopes to remove from the personal space role, which every
* user holds in their own personal project. Only `credential:create`,
* `dataTable:create` and `agent:create` are accepted. Has no effect unless
* `N8N_CANVAS_ONLY` is `true`. Applied by the role sync on every start.
*
* @example `N8N_CANVAS_ONLY_PERSONAL_SPACE_SCOPE_DENY_LIST=credential:create,agent:create`
*/
@Env('N8N_CANVAS_ONLY_PERSONAL_SPACE_SCOPE_DENY_LIST', personalSpaceScopeDenyListSchema)
personalSpaceScopeDenyList: CanvasOnlyPersonalSpaceDeniableScope[] = [];
}

View File

@ -8,6 +8,7 @@ import { AiGatewayConfig } from './configs/ai-gateway.config';
import { AiConfig } from './configs/ai.config';
import { AuthConfig } from './configs/auth.config';
import { CacheConfig } from './configs/cache.config';
import { CanvasOnlyConfig } from './configs/canvas-only.config';
import { ChatHubConfig } from './configs/chat-hub.config';
import { ChatTriggerConfig } from './configs/chat-trigger.config';
import { CollaborationConfig } from './configs/collaboration.config';
@ -92,6 +93,11 @@ export { McpClientConfig } from './configs/mcp-client.config';
export { McpServerConfig } from './configs/mcp-server.config';
export { MfaConfig } from './configs/mfa.config';
export { HiringBannerConfig } from './configs/hiring-banner.config';
export {
CanvasOnlyConfig,
CANVAS_ONLY_PERSONAL_SPACE_DENIABLE_SCOPES,
type CanvasOnlyPersonalSpaceDeniableScope,
} from './configs/canvas-only.config';
export { HttpRequestConfig } from './configs/http-request.config';
export { PersonalizationConfig } from './configs/personalization.config';
export { NodesConfig } from './configs/nodes.config';
@ -269,9 +275,8 @@ export class GlobalConfig {
@Env('N8N_SSL_CERT')
ssl_cert: string = '';
/** Whether to enable canvas-only mode, hiding the chrome UI. */
@Env('N8N_CANVAS_ONLY')
canvasOnly: boolean = false;
@Nested
canvasOnly: CanvasOnlyConfig;
/** Public URL where the editor is accessible. Also used for emails sent from n8n. */
@Env('N8N_EDITOR_BASE_URL')

View File

@ -78,7 +78,10 @@ describe('GlobalConfig', () => {
proxy_hops: 0,
ssl_key: '',
ssl_cert: '',
canvasOnly: false,
canvasOnly: {
enabled: false,
personalSpaceScopeDenyList: [],
},
editorBaseUrl: '',
webhookUrl: '',
dataTable: {

View File

@ -1,4 +1,5 @@
import { Logger } from '@n8n/backend-common';
import { GlobalConfig } from '@n8n/config';
import { Service } from '@n8n/di';
// eslint-disable-next-line import-x/order
import {
@ -25,6 +26,7 @@ export class AuthRolesService {
constructor(
private readonly logger: Logger,
private readonly dbLockService: DbLockService,
private readonly globalConfig: GlobalConfig,
) {}
private async syncScopes(tx: EntityManager) {
@ -144,6 +146,22 @@ export class AuthRolesService {
return scopes;
}
/**
* Canvas-only mode lets an operator take some scopes away from the personal
* owner role through `N8N_CANVAS_ONLY_PERSONAL_SPACE_SCOPE_DENY_LIST`.
* The config already limits the list to the scopes that may be removed.
*/
private removeCanvasOnlyPersonalOwnerScopes(scopes: string[]) {
const { enabled, personalSpaceScopeDenyList } = this.globalConfig.canvasOnly;
if (!enabled || personalSpaceScopeDenyList.length === 0) return scopes;
this.logger.debug(
`Canvas-only mode - removing ${personalSpaceScopeDenyList.join(', ')} scopes from ${PROJECT_OWNER_ROLE_SLUG} role`,
);
const denied: readonly string[] = personalSpaceScopeDenyList;
return scopes.filter((slug) => !denied.includes(slug));
}
private async getExternalSecretsSystemRolesScopes(
roleSlug: string,
tx: EntityManager,
@ -172,7 +190,8 @@ export class AuthRolesService {
/**
* Modifies the expected scopes for a role based on settings.
* Uses a "closed first" approach: certain scopes are not in the base definition
* and are added when the corresponding setting is enabled.
* and are added when the corresponding setting is enabled. Canvas-only mode
* can also remove scopes from the personal owner role through config.
*/
private async updateScopesBasedOnSettings(
roleSlug: string,
@ -183,6 +202,7 @@ export class AuthRolesService {
// Special handling for project:personalOwner role
if (roleSlug === PROJECT_OWNER_ROLE_SLUG) {
scopes.push(...(await this.getPersonalOwnerSettingsScopes(tx)));
return this.removeCanvasOnlyPersonalOwnerScopes(scopes);
}
// External secrets system roles scopes

View File

@ -1,5 +1,6 @@
import { Logger } from '@n8n/backend-common';
import { mockInstance } from '@n8n/backend-test-utils';
import { CANVAS_ONLY_PERSONAL_SPACE_DENIABLE_SCOPES, type GlobalConfig } from '@n8n/config';
import { AuthRolesService, Role, Scope } from '@n8n/db';
import type { DbLock, DbLockService } from '@n8n/db';
import {
@ -43,7 +44,11 @@ describe('AuthRolesService', () => {
await fn(mockEntityManager),
);
const authRolesService = new AuthRolesService(logger, dbLockService);
const globalConfig = mock<GlobalConfig>({
canvasOnly: { enabled: false, personalSpaceScopeDenyList: [] },
});
const authRolesService = new AuthRolesService(logger, dbLockService, globalConfig);
// Helper functions for creating test data
function createScope(
@ -118,6 +123,8 @@ describe('AuthRolesService', () => {
// Default: no settings rows (backward compat: undefined values => grant scopes)
mockEntityManager.findBy.mockResolvedValue([]);
mockEntityManager.findOneBy.mockResolvedValue(null);
globalConfig.canvasOnly.enabled = false;
globalConfig.canvasOnly.personalSpaceScopeDenyList = [];
dbLockService.withLock.mockImplementation(
async (_lockId: DbLock, fn: (tx: EntityManager) => Promise<unknown>) =>
await fn(mockEntityManager),
@ -623,6 +630,95 @@ describe('AuthRolesService', () => {
});
});
describe('canvas-only personal space role', () => {
const DENIABLE_SCOPES = [...CANVAS_ONLY_PERSONAL_SPACE_DENIABLE_SCOPES];
/** The scope slugs the sync gives the personal owner role on a fresh instance. */
async function syncedPersonalOwnerScopes(): Promise<string[]> {
setupDefaultMocks(createAllScopes());
await authRolesService.init();
const personalOwnerCall = roleRepository.create.mock.calls.find(
(call) => (call[0] as Role).slug === PROJECT_OWNER_ROLE_SLUG,
);
expect(personalOwnerCall).toBeDefined();
return (personalOwnerCall?.[0] as Role).scopes.map((s: Scope) => s.slug);
}
test('should give the personalOwner role every deniable scope when the deny list is empty', async () => {
globalConfig.canvasOnly.enabled = true;
globalConfig.canvasOnly.personalSpaceScopeDenyList = [];
const scopeSlugs = await syncedPersonalOwnerScopes();
expect(scopeSlugs).toEqual(expect.arrayContaining(DENIABLE_SCOPES));
});
test('should remove the denied scopes when canvas-only mode is on', async () => {
globalConfig.canvasOnly.enabled = true;
globalConfig.canvasOnly.personalSpaceScopeDenyList = ['credential:create'];
const scopeSlugs = await syncedPersonalOwnerScopes();
expect(scopeSlugs).not.toContain('credential:create');
expect(scopeSlugs).toContain('dataTable:create');
expect(scopeSlugs).toContain('agent:create');
expect(scopeSlugs).toContain('workflow:create');
});
test('should remove every deniable scope when all are denied', async () => {
globalConfig.canvasOnly.enabled = true;
globalConfig.canvasOnly.personalSpaceScopeDenyList = DENIABLE_SCOPES;
const scopeSlugs = await syncedPersonalOwnerScopes();
for (const scope of DENIABLE_SCOPES) {
expect(scopeSlugs).not.toContain(scope);
}
expect(scopeSlugs).toContain('workflow:create');
});
test('should ignore the deny list when canvas-only mode is off', async () => {
globalConfig.canvasOnly.enabled = false;
globalConfig.canvasOnly.personalSpaceScopeDenyList = ['credential:create'];
const scopeSlugs = await syncedPersonalOwnerScopes();
expect(scopeSlugs).toContain('credential:create');
});
test('should update an existing personalOwner role to remove the denied scopes', async () => {
globalConfig.canvasOnly.enabled = true;
globalConfig.canvasOnly.personalSpaceScopeDenyList = ['credential:create'];
const allScopes = createAllScopes();
const personalOwnerRoleDef = ALL_ROLES.project.find(
(r) => r.slug === PROJECT_OWNER_ROLE_SLUG,
)!;
const existingRole = createRole(PROJECT_OWNER_ROLE_SLUG, {
displayName: personalOwnerRoleDef.displayName,
description: personalOwnerRoleDef.description ?? null,
roleType: 'project',
scopes: allScopes.filter((s) => personalOwnerRoleDef.scopes.includes(s.slug)),
});
scopeRepository.find.mockResolvedValue(allScopes);
roleRepository.find.mockResolvedValue([existingRole]);
roleRepository.save.mockImplementation(async (entities) => entities as never);
await authRolesService.init();
const projectRoleSaveCall = roleRepository.save.mock.calls.find((call) => {
const roles = call[0] as Role[];
return Array.isArray(roles) && roles.some((r) => r?.roleType === 'project');
});
expect(projectRoleSaveCall).toBeDefined();
const savedRoles = projectRoleSaveCall?.[0] as Role[];
const updatedRole = savedRoles.find((r) => r?.slug === PROJECT_OWNER_ROLE_SLUG);
const scopeSlugs = updatedRole?.scopes.map((s) => s.slug) ?? [];
expect(scopeSlugs).not.toContain('credential:create');
expect(scopeSlugs).toContain('dataTable:create');
});
});
describe('personal space sharing', () => {
test('should add sharing scopes to personalOwner role when setting is null (backward compatibility)', async () => {
const allScopes = createAllScopes();

View File

@ -433,7 +433,7 @@ export class FrontendService {
agentEvalsEnabled: this.globalConfig.evaluation.agentEvalsEnabled,
},
activeModules: this.moduleRegistry.getActiveModules(),
canvasOnly: this.globalConfig.canvasOnly,
canvasOnly: this.globalConfig.canvasOnly.enabled,
collaboration: {
crdt: this.globalConfig.collaboration.crdt,
},

View File

@ -0,0 +1,132 @@
import { testDb } from '@n8n/backend-test-utils';
import { GlobalConfig } from '@n8n/config';
import { AuthRolesService, RoleRepository, type User } from '@n8n/db';
import { Container } from '@n8n/di';
import { PROJECT_OWNER_ROLE_SLUG } from '@n8n/permissions';
import { RoleCacheService } from '@/services/role-cache.service';
import { createMemberWithApiKey } from './shared/db/users';
import * as utils from './shared/utils/';
describe('N8N_CANVAS_ONLY_PERSONAL_SPACE_SCOPE_DENY_LIST', () => {
const testServer = utils.setupTestServer({ endpointGroups: ['publicApi'] });
const { canvasOnly } = Container.get(GlobalConfig);
let member: User;
const credentialPayload = () => ({
name: 'test credential',
type: 'githubApi',
data: { accessToken: 'abcdefghijklmnopqrstuvwxyz', user: 'test', server: 'testServer' },
});
const workflowPayload = () => ({
name: 'test workflow',
nodes: [],
connections: {},
staticData: null,
settings: { executionOrder: 'v1' },
});
const storedScopes = async () => {
const role = await Container.get(RoleRepository).findBySlug(PROJECT_OWNER_ROLE_SLUG);
return role?.scopes.map((s) => s.slug) ?? [];
};
/** Re-runs the system role sync, as a restart does, and drops the role cache. */
const syncRoles = async () => {
await Container.get(AuthRolesService).init();
await Container.get(RoleCacheService).invalidateCache();
};
beforeAll(async () => {
await testDb.init();
await utils.initCredentialsTypes();
});
beforeEach(async () => {
await testDb.truncate([
'SharedCredentials',
'CredentialsEntity',
'SharedWorkflow',
'WorkflowEntity',
'User',
]);
member = await createMemberWithApiKey();
});
afterEach(async () => {
canvasOnly.enabled = false;
canvasOnly.personalSpaceScopeDenyList = [];
await syncRoles();
});
it('stops credential creation in the personal project but keeps workflows working', async () => {
const before = await testServer
.publicApiAgentFor(member)
.post('/credentials')
.send(credentialPayload());
expect(before.status).toBe(200);
canvasOnly.enabled = true;
canvasOnly.personalSpaceScopeDenyList = ['credential:create'];
await syncRoles();
expect(await storedScopes()).not.toContain('credential:create');
const after = await testServer
.publicApiAgentFor(member)
.post('/credentials')
.send(credentialPayload());
expect(after.status).toBe(403);
const workflow = await testServer
.publicApiAgentFor(member)
.post('/workflows')
.send(workflowPayload());
expect(workflow.status).toBe(200);
});
it('removes every denied scope and keeps the rest of the role', async () => {
canvasOnly.enabled = true;
canvasOnly.personalSpaceScopeDenyList = [
'credential:create',
'dataTable:create',
'agent:create',
];
await syncRoles();
const scopes = await storedScopes();
expect(scopes).not.toContain('credential:create');
expect(scopes).not.toContain('dataTable:create');
expect(scopes).not.toContain('agent:create');
expect(scopes).toContain('workflow:create');
expect(scopes).toContain('credential:read');
});
it('gives the scopes back once the deny list is cleared', async () => {
canvasOnly.enabled = true;
canvasOnly.personalSpaceScopeDenyList = ['credential:create'];
await syncRoles();
expect(await storedScopes()).not.toContain('credential:create');
canvasOnly.personalSpaceScopeDenyList = [];
await syncRoles();
expect(await storedScopes()).toContain('credential:create');
const created = await testServer
.publicApiAgentFor(member)
.post('/credentials')
.send(credentialPayload());
expect(created.status).toBe(200);
});
it('ignores the deny list when canvas-only mode is off', async () => {
canvasOnly.enabled = false;
canvasOnly.personalSpaceScopeDenyList = ['credential:create'];
await syncRoles();
expect(await storedScopes()).toContain('credential:create');
});
});