fix: Remove variable:read as a valid API key scope and make variable:update selectable in the UI (no-changelog) (#15356)

Co-authored-by: Ricardo Espinoza <ricardo@n8n.io>
This commit is contained in:
Ria Scholz
2025-05-16 19:36:43 +02:00
committed by GitHub
parent 435d43fc5b
commit a1ee96d560
6 changed files with 67 additions and 39 deletions

View File

@@ -30,7 +30,7 @@ export const RESOURCES = {
export const API_KEY_RESOURCES = { export const API_KEY_RESOURCES = {
tag: [...DEFAULT_OPERATIONS] as const, tag: [...DEFAULT_OPERATIONS] as const,
workflow: [...DEFAULT_OPERATIONS, 'move', 'activate', 'deactivate'] as const, workflow: [...DEFAULT_OPERATIONS, 'move', 'activate', 'deactivate'] as const,
variable: DEFAULT_OPERATIONS, variable: ['create', 'update', 'delete', 'list'] as const,
securityAudit: ['generate'] as const, securityAudit: ['generate'] as const,
project: ['create', 'update', 'delete', 'list'] as const, project: ['create', 'update', 'delete', 'list'] as const,
user: ['read', 'list', 'create', 'changeRole', 'delete'] as const, user: ['read', 'list', 'create', 'changeRole', 'delete'] as const,

View File

@@ -15,6 +15,7 @@ export const OWNER_API_KEY_SCOPES: ApiKeyScope[] = [
'variable:create', 'variable:create',
'variable:delete', 'variable:delete',
'variable:list', 'variable:list',
'variable:update',
'tag:create', 'tag:create',
'tag:read', 'tag:read',
'tag:update', 'tag:update',

View File

@@ -1,4 +1,4 @@
import type { TagEntity } from '@n8n/db'; import type { TagEntity, Variables } from '@n8n/db';
import { ApiKeyRepository } from '@n8n/db'; import { ApiKeyRepository } from '@n8n/db';
import { CredentialsRepository } from '@n8n/db'; import { CredentialsRepository } from '@n8n/db';
import { ProjectRepository } from '@n8n/db'; import { ProjectRepository } from '@n8n/db';
@@ -22,7 +22,7 @@ import {
createUser, createUser,
getUserById, getUserById,
} from '@test-integration/db/users'; } from '@test-integration/db/users';
import { createVariable, getVariableOrFail } from '@test-integration/db/variables'; import { createVariable, getVariableByIdOrFail } from '@test-integration/db/variables';
import { createWorkflow } from '@test-integration/db/workflows'; import { createWorkflow } from '@test-integration/db/workflows';
import { randomName } from '@test-integration/random'; import { randomName } from '@test-integration/random';
import type { CredentialPayload, SaveCredentialFunction } from '@test-integration/types'; import type { CredentialPayload, SaveCredentialFunction } from '@test-integration/types';
@@ -57,6 +57,7 @@ describe('Public API endpoints with feat:apiKeyScopes enabled', () => {
'quota:maxTeamProjects': -1, 'quota:maxTeamProjects': -1,
}, },
}); });
let apiKeyRepository: ApiKeyRepository; let apiKeyRepository: ApiKeyRepository;
beforeAll(async () => { beforeAll(async () => {
@@ -979,7 +980,7 @@ describe('Public API endpoints with feat:apiKeyScopes enabled', () => {
* Assert * Assert
*/ */
expect(response.status).toBe(201); expect(response.status).toBe(201);
await expect(getVariableOrFail(response.body.id)).resolves.toEqual( await expect(getVariableByIdOrFail(response.body.id)).resolves.toEqual(
expect.objectContaining(variablePayload), expect.objectContaining(variablePayload),
); );
}); });
@@ -1005,6 +1006,36 @@ describe('Public API endpoints with feat:apiKeyScopes enabled', () => {
expect(response.status).toBe(403); expect(response.status).toBe(403);
}); });
}); });
describe('PUT /variables/:id', () => {
const variablePayload = { key: 'updatedKey', value: 'updatedValue' };
let variable: Variables;
beforeEach(async () => {
variable = await createVariable();
});
it('should update a variable if API key has scope "variable:update"', async () => {
const owner = await createOwnerWithApiKey({ scopes: ['variable:update'] });
const response = await testServer
.publicApiAgentFor(owner)
.put(`/variables/${variable.id}`)
.send(variablePayload);
expect(response.status).toBe(204);
const updatedVariable = await getVariableByIdOrFail(variable.id);
expect(updatedVariable).toEqual(expect.objectContaining(variablePayload));
});
test('should fail to update variable when API key doesn\'t have "variable:update" scope', async () => {
const owner = await createOwnerWithApiKey({ scopes: ['variable:list'] });
await testServer
.publicApiAgentFor(owner)
.put(`/variables/${variable.id}`)
.send(variablePayload);
});
});
}); });
describe('projects', () => { describe('projects', () => {

View File

@@ -2,7 +2,7 @@ import type { User, Variables } from '@n8n/db';
import { FeatureNotLicensedError } from '@/errors/feature-not-licensed.error'; import { FeatureNotLicensedError } from '@/errors/feature-not-licensed.error';
import { createOwnerWithApiKey } from '@test-integration/db/users'; import { createOwnerWithApiKey } from '@test-integration/db/users';
import { createVariable, getVariableOrFail } from '@test-integration/db/variables'; import { createVariable, getVariableByIdOrFail } from '@test-integration/db/variables';
import { setupTestServer } from '@test-integration/utils'; import { setupTestServer } from '@test-integration/utils';
import * as testDb from '../shared/test-db'; import * as testDb from '../shared/test-db';
@@ -83,7 +83,7 @@ describe('Variables in Public API', () => {
* Assert * Assert
*/ */
expect(response.status).toBe(201); expect(response.status).toBe(201);
await expect(getVariableOrFail(response.body.id)).resolves.toEqual( await expect(getVariableByIdOrFail(response.body.id)).resolves.toEqual(
expect.objectContaining(variablePayload), expect.objectContaining(variablePayload),
); );
}); });
@@ -126,7 +126,7 @@ describe('Variables in Public API', () => {
.send(variablePayload); .send(variablePayload);
expect(response.status).toBe(204); expect(response.status).toBe(204);
const updatedVariable = await getVariableOrFail(variable.id); const updatedVariable = await getVariableByIdOrFail(variable.id);
expect(updatedVariable).toEqual(expect.objectContaining(variablePayload)); expect(updatedVariable).toEqual(expect.objectContaining(variablePayload));
}); });
@@ -164,7 +164,7 @@ describe('Variables in Public API', () => {
* Assert * Assert
*/ */
expect(response.status).toBe(204); expect(response.status).toBe(204);
await expect(getVariableOrFail(variable.id)).rejects.toThrow(); await expect(getVariableByIdOrFail(variable.id)).rejects.toThrow();
}); });
it('if not licensed, should reject', async () => { it('if not licensed, should reject', async () => {

View File

@@ -3,10 +3,34 @@ import { VariablesRepository } from '@n8n/db';
import { Container } from '@n8n/di'; import { Container } from '@n8n/di';
import { randomString } from 'n8n-workflow'; import { randomString } from 'n8n-workflow';
import { VariablesService } from '@/environments.ee/variables/variables.service.ee';
export async function createVariable(key = randomString(5), value = randomString(5)) { export async function createVariable(key = randomString(5), value = randomString(5)) {
return await Container.get(VariablesRepository).save({ id: generateNanoId(), key, value }); const result = await Container.get(VariablesRepository).save({
id: generateNanoId(),
key,
value,
});
await Container.get(VariablesService).updateCache();
return result;
} }
export async function getVariableOrFail(id: string) { export async function getVariableByIdOrFail(id: string) {
return await Container.get(VariablesRepository).findOneOrFail({ where: { id } }); return await Container.get(VariablesRepository).findOneOrFail({ where: { id } });
} }
export async function getVariableByKey(key: string) {
return await Container.get(VariablesRepository).findOne({
where: {
key,
},
});
}
export async function getVariableById(id: string) {
return await Container.get(VariablesRepository).findOne({
where: {
id,
},
});
}

View File

@@ -1,10 +1,8 @@
import type { Variables } from '@n8n/db'; import type { Variables } from '@n8n/db';
import { generateNanoId } from '@n8n/db';
import { VariablesRepository } from '@n8n/db';
import { Container } from '@n8n/di'; import { Container } from '@n8n/di';
import { VariablesService } from '@/environments.ee/variables/variables.service.ee';
import { CacheService } from '@/services/cache/cache.service'; import { CacheService } from '@/services/cache/cache.service';
import { createVariable, getVariableById, getVariableByKey } from '@test-integration/db/variables';
import { createOwner, createUser } from './shared/db/users'; import { createOwner, createUser } from './shared/db/users';
import * as testDb from './shared/test-db'; import * as testDb from './shared/test-db';
@@ -17,32 +15,6 @@ let authMemberAgent: SuperAgentTest;
const testServer = utils.setupTestServer({ endpointGroups: ['variables'] }); const testServer = utils.setupTestServer({ endpointGroups: ['variables'] });
const license = testServer.license; const license = testServer.license;
async function createVariable(key: string, value: string) {
const result = await Container.get(VariablesRepository).save({
id: generateNanoId(),
key,
value,
});
await Container.get(VariablesService).updateCache();
return result;
}
async function getVariableByKey(key: string) {
return await Container.get(VariablesRepository).findOne({
where: {
key,
},
});
}
async function getVariableById(id: string) {
return await Container.get(VariablesRepository).findOne({
where: {
id,
},
});
}
beforeAll(async () => { beforeAll(async () => {
const owner = await createOwner(); const owner = await createOwner();
authOwnerAgent = testServer.authAgentFor(owner); authOwnerAgent = testServer.authAgentFor(owner);