diff --git a/packages/@n8n/config/src/configs/__tests__/canvas-only.config.test.ts b/packages/@n8n/config/src/configs/__tests__/canvas-only.config.test.ts new file mode 100644 index 00000000000..0f048e5f497 --- /dev/null +++ b/packages/@n8n/config/src/configs/__tests__/canvas-only.config.test.ts @@ -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'), + ); + }); + }); +}); diff --git a/packages/@n8n/config/src/configs/canvas-only.config.ts b/packages/@n8n/config/src/configs/canvas-only.config.ts new file mode 100644 index 00000000000..4080da1bf79 --- /dev/null +++ b/packages/@n8n/config/src/configs/canvas-only.config.ts @@ -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[] = []; +} diff --git a/packages/@n8n/config/src/index.ts b/packages/@n8n/config/src/index.ts index 96e6c416d6c..a93d4f16d8a 100644 --- a/packages/@n8n/config/src/index.ts +++ b/packages/@n8n/config/src/index.ts @@ -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') diff --git a/packages/@n8n/config/test/config.test.ts b/packages/@n8n/config/test/config.test.ts index 9b6322a4d80..3873df6973e 100644 --- a/packages/@n8n/config/test/config.test.ts +++ b/packages/@n8n/config/test/config.test.ts @@ -78,7 +78,10 @@ describe('GlobalConfig', () => { proxy_hops: 0, ssl_key: '', ssl_cert: '', - canvasOnly: false, + canvasOnly: { + enabled: false, + personalSpaceScopeDenyList: [], + }, editorBaseUrl: '', webhookUrl: '', dataTable: { diff --git a/packages/@n8n/db/src/services/auth.roles.service.ts b/packages/@n8n/db/src/services/auth.roles.service.ts index 03f83d9a99e..19efaee46b3 100644 --- a/packages/@n8n/db/src/services/auth.roles.service.ts +++ b/packages/@n8n/db/src/services/auth.roles.service.ts @@ -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 diff --git a/packages/cli/src/services/__tests__/auth.roles.service.test.ts b/packages/cli/src/services/__tests__/auth.roles.service.test.ts index 9bc0cebc326..27baa14af13 100644 --- a/packages/cli/src/services/__tests__/auth.roles.service.test.ts +++ b/packages/cli/src/services/__tests__/auth.roles.service.test.ts @@ -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({ + 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) => 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 { + 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(); diff --git a/packages/cli/src/services/frontend.service.ts b/packages/cli/src/services/frontend.service.ts index 91361e43ad3..29528ff4e4b 100644 --- a/packages/cli/src/services/frontend.service.ts +++ b/packages/cli/src/services/frontend.service.ts @@ -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, }, diff --git a/packages/cli/test/integration/auth-roles-canvas-only.test.ts b/packages/cli/test/integration/auth-roles-canvas-only.test.ts new file mode 100644 index 00000000000..23acf4f8c3f --- /dev/null +++ b/packages/cli/test/integration/auth-roles-canvas-only.test.ts @@ -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'); + }); +});