From d09b8a11fb097e47b04dbe04ed1d330f736fb507 Mon Sep 17 00:00:00 2001 From: "n8n-assistant[bot]" <100856346+n8n-assistant[bot]@users.noreply.github.com> Date: Fri, 2 Oct 2026 09:40:09 +0000 Subject: [PATCH] fix(API): Let global members grant variable scopes to API keys (backport to release-candidate/2.42.x) (#40150) Co-authored-by: Uddish Verma Co-authored-by: Claude Fable 5.1 --- .../src/public-api-permissions.ee.ts | 9 +++++ ...public-api-key.service.integration.test.ts | 33 +++++++++++-------- .../integration/public-api/variables.test.ts | 33 +++++++++++++++++++ 3 files changed, 61 insertions(+), 14 deletions(-) diff --git a/packages/@n8n/permissions/src/public-api-permissions.ee.ts b/packages/@n8n/permissions/src/public-api-permissions.ee.ts index f287eeda316..3c074395a13 100644 --- a/packages/@n8n/permissions/src/public-api-permissions.ee.ts +++ b/packages/@n8n/permissions/src/public-api-permissions.ee.ts @@ -146,6 +146,10 @@ export const MEMBER_API_KEY_SCOPES: ApiKeyScope[] = [ 'credential:update', 'credential:move', 'credential:delete', + 'variable:create', + 'variable:delete', + 'variable:list', + 'variable:update', 'dataTable:create', 'dataTable:read', 'dataTable:update', @@ -206,6 +210,11 @@ export const API_KEY_SCOPES_FOR_IMPLICIT_PERSONAL_PROJECT: ApiKeyScope[] = [ 'credential:update', 'credential:move', 'credential:delete', + // Members hold these for team projects where they are editor or admin. + // The variables service checks the project role on every request. + 'variable:create', + 'variable:delete', + 'variable:update', 'dataTable:create', 'dataTable:read', 'dataTable:update', diff --git a/packages/cli/src/services/__tests__/public-api-key.service.integration.test.ts b/packages/cli/src/services/__tests__/public-api-key.service.integration.test.ts index 585db50b123..840d5a48013 100644 --- a/packages/cli/src/services/__tests__/public-api-key.service.integration.test.ts +++ b/packages/cli/src/services/__tests__/public-api-key.service.integration.test.ts @@ -1,7 +1,11 @@ import { testDb } from '@n8n/backend-test-utils'; import { ApiKeyRepository, GLOBAL_MEMBER_ROLE, GLOBAL_OWNER_ROLE } from '@n8n/db'; import { Container } from '@n8n/di'; -import { getOwnerOnlyApiKeyScopes, type ApiKeyScope } from '@n8n/permissions'; +import { + getOwnerOnlyApiKeyScopes, + MEMBER_API_KEY_SCOPES, + type ApiKeyScope, +} from '@n8n/permissions'; import type { InstanceSettings } from 'n8n-core'; import { mock } from 'vitest-mock-extended'; @@ -70,6 +74,18 @@ describe('PublicApiKeyService', () => { false, ); }); + + it('should keep every scope a member may hold', async () => { + const adminUser = await createAdminWithApiKey({ + scopes: [...MEMBER_API_KEY_SCOPES, 'user:create'], + }); + const apiKeyId = adminUser.apiKeys[0].id; + + await publicApiKeyService.removeOwnerOnlyScopesFromApiKeys(adminUser); + + const apiKeyOnDb = await apiKeyRepository.findOneByOrFail({ id: apiKeyId }); + expect(apiKeyOnDb.scopes).toEqual(MEMBER_API_KEY_SCOPES); + }); }); describe('apiKeyHasValidScopes', () => { @@ -145,25 +161,14 @@ describe('PublicApiKeyService', () => { expect(result).toBe(false); }); - it('should let a member grant folder scopes, which apply to their own projects', async () => { - // Arrange - const folderScopes: ApiKeyScope[] = [ - 'folder:create', - 'folder:read', - 'folder:update', - 'folder:delete', - 'folder:list', - ]; - - // Act + it('should let a member grant every member scope', async () => { const result = publicApiKeyService.apiKeyHasValidScopesForRole( { role: GLOBAL_MEMBER_ROLE, }, - folderScopes, + MEMBER_API_KEY_SCOPES, ); - // Assert expect(result).toBe(true); }); }); diff --git a/packages/cli/test/integration/public-api/variables.test.ts b/packages/cli/test/integration/public-api/variables.test.ts index 1dc22d13887..27058bd695d 100644 --- a/packages/cli/test/integration/public-api/variables.test.ts +++ b/packages/cli/test/integration/public-api/variables.test.ts @@ -352,6 +352,39 @@ describe('Variables in Public API', () => { ); }); + it('should let a member create a variable in a project they can edit', async () => { + testServer.license.enable('feat:variables'); + const member = await createMemberWithApiKey({ scopes: ['variable:create'] }); + await linkUserToProject(member, project, 'project:editor'); + + const response = await testServer + .publicApiAgentFor(member) + .post('/variables') + .send({ key: 'key', value: 'value', projectId: project.id }); + + expect(response.status).toBe(201); + const created = await getVariableByKey('key'); + expect(created).not.toBeNull(); + await expect(getVariableByIdOrFail(created!.id)).resolves.toEqual( + expect.objectContaining({ project: expect.objectContaining({ id: project.id }) }), + ); + }); + + it('should not let a member create a global variable', async () => { + testServer.license.enable('feat:variables'); + const member = await createMemberWithApiKey({ scopes: ['variable:create'] }); + await linkUserToProject(member, project, 'project:admin'); + + const response = await testServer + .publicApiAgentFor(member) + .post('/variables') + .send({ key: 'key', value: 'value' }); + + expect(response.status).toBe(403); + expect(response.body).toHaveProperty('message', 'You are not allowed to create a variable'); + await expect(getVariableByKey('key')).resolves.toBeNull(); + }); + it('if not licensed, should reject', async () => { const variablePayload = { key: 'key', value: 'value' };