n8n/packages/cli/test/integration/access-control/resource-access-matrix.test.ts
Sandra Zollner 8cd75d2f2d
Some checks are pending
Build: Benchmark Image / build (push) Waiting to run
CI: Master (Build, Test, Lint) / Build for Github Cache (push) Waiting to run
CI: Master (Build, Test, Lint) / Unit tests (22.x) (push) Waiting to run
CI: Master (Build, Test, Lint) / Unit tests (24.14.1) (push) Waiting to run
CI: Master (Build, Test, Lint) / Unit tests (25.x) (push) Waiting to run
CI: Master (Build, Test, Lint) / Lint (push) Waiting to run
CI: Master (Build, Test, Lint) / Performance (push) Waiting to run
CI: Master (Build, Test, Lint) / Notify Slack on failure (push) Blocked by required conditions
Util: Sync API Docs / sync-public-api (push) Waiting to run
feat(core): Enable credential creation per project in public API (#28240)
2026-04-13 12:22:52 +00:00

443 lines
14 KiB
TypeScript

import {
createTeamProject,
getPersonalProject,
linkUserToProject,
createWorkflow,
randomCredentialPayload,
testDb,
mockInstance,
} from '@n8n/backend-test-utils';
import type { Project, User, Role } from '@n8n/db';
import { UserManagementMailer } from '@/user-management/email';
import { createCustomRoleWithScopeSlugs, cleanupRolesAndScopes } from '../shared/db/roles';
import { createOwner, createMember } from '../shared/db/users';
import type { SuperAgentTest } from '../shared/types';
import * as utils from '../shared/utils/';
const testServer = utils.setupTestServer({
endpointGroups: ['workflows', 'credentials'],
enabledFeatures: ['feat:sharing', 'feat:customRoles'],
quotas: {
'quota:maxTeamProjects': -1,
},
});
// Foundation users and projects
let owner: User;
let testUser: User;
let teamProject: Project;
let testUserPersonalProject: Project;
// Custom role definitions (8 total - covering all combinations)
let workflowReadOnlyRole: Role;
let workflowAllOperationsRole: Role;
let credentialReadOnlyRole: Role;
let credentialAllOperationsRole: Role;
// Authentication agents
let ownerAgent: SuperAgentTest;
let testUserAgent: SuperAgentTest;
// Test data - created fresh for each test
let testWorkflowId: string;
let testWorkflowVersionId: string;
let testCredentialId: string;
describe('Resource Access Control Matrix Tests', () => {
beforeAll(async () => {
mockInstance(UserManagementMailer, {
invite: jest.fn(),
passwordReset: jest.fn(),
});
// Create foundation users
owner = await createOwner();
testUser = await createMember();
// Get projects
testUserPersonalProject = await getPersonalProject(testUser);
teamProject = await createTeamProject('Access Control Test Project', owner);
// Create authentication agents
ownerAgent = testServer.authAgentFor(owner);
testUserAgent = testServer.authAgentFor(testUser);
await utils.initCredentialsTypes();
// Create custom roles with specific scopes
workflowReadOnlyRole = await createCustomRoleWithScopeSlugs(
['workflow:read', 'workflow:list'],
{
roleType: 'project',
displayName: 'Workflow Read-Only',
description: 'Can only read and list workflows',
},
);
workflowAllOperationsRole = await createCustomRoleWithScopeSlugs(
[
'workflow:share',
'workflow:execute',
'workflow:read',
'workflow:list',
'workflow:create',
'workflow:update',
'workflow:delete',
],
{
roleType: 'project',
displayName: 'Workflow All Operations',
description: 'Full workflow access (CRUD + list)',
},
);
credentialReadOnlyRole = await createCustomRoleWithScopeSlugs(
['credential:read', 'credential:list'],
{
roleType: 'project',
displayName: 'Credential Read-Only',
description: 'Can only read and list credentials',
},
);
credentialAllOperationsRole = await createCustomRoleWithScopeSlugs(
[
'credential:read',
'credential:list',
'credential:create',
'credential:update',
'credential:delete',
],
{
roleType: 'project',
displayName: 'Credential All Operations',
description: 'Full credential access (CRUD + list)',
},
);
});
beforeEach(async () => {
// Clean up any existing shared resources
await testDb.truncate(['SharedWorkflow', 'SharedCredentials', 'ProjectRelation']);
// Create fresh test data for each test
const workflow = await createWorkflow({ name: 'Matrix Test Workflow' }, teamProject);
testWorkflowId = workflow.id;
testWorkflowVersionId = workflow.versionId;
// Create test credential via owner
const credentialPayload = randomCredentialPayload();
const credentialResponse = await ownerAgent
.post('/credentials')
.send({ ...credentialPayload, projectId: teamProject.id })
.expect(200);
testCredentialId = credentialResponse.body.data.id;
});
afterAll(async () => {
await testDb.truncate(['User', 'ProjectRelation']);
await cleanupRolesAndScopes();
});
describe('Foundation Setup Validation', () => {
test('should have created all required custom roles', () => {
expect(workflowReadOnlyRole.scopes).toHaveLength(2);
expect(workflowAllOperationsRole.scopes).toHaveLength(7);
expect(credentialReadOnlyRole.scopes).toHaveLength(2);
expect(credentialAllOperationsRole.scopes).toHaveLength(5);
});
test('should have functional test setup', async () => {
expect(testWorkflowId).toBeDefined();
expect(testCredentialId).toBeDefined();
expect(teamProject).toBeDefined();
// Verify owner can access test data
await ownerAgent.get(`/workflows/${testWorkflowId}`).expect(200);
await ownerAgent.get(`/credentials/${testCredentialId}`).expect(200);
});
});
describe('Workflow Access Control Matrix', () => {
describe('Workflow Read-Only Role', () => {
beforeEach(async () => {
await linkUserToProject(testUser, teamProject, workflowReadOnlyRole.slug);
});
test('POST /workflows should return 400', async () => {
const workflowPayload = {
name: 'New Workflow',
active: false,
nodes: [
{
id: 'uuid-1234',
parameters: {},
name: 'Start',
type: 'n8n-nodes-base.manualTrigger',
typeVersion: 1,
position: [240, 300],
},
],
connections: {},
projectId: teamProject.id,
};
await testUserAgent.post('/workflows').send(workflowPayload).expect(400);
});
test('GET /workflows should return 200', async () => {
const response = await testUserAgent.get('/workflows').expect(200);
expect(Array.isArray(response.body.data)).toBe(true);
expect(response.body.data.length).toBeGreaterThan(0);
});
test('GET /workflows/new should return 403', async () => {
await testUserAgent.get(`/workflows/new?projectId=${teamProject.id}`).expect(403);
});
test('GET /workflows/:id should return 200', async () => {
const response = await testUserAgent.get(`/workflows/${testWorkflowId}`).expect(200);
expect(response.body.data.name).toBe('Matrix Test Workflow');
});
test('PATCH /workflows/:id should return 403', async () => {
await testUserAgent
.patch(`/workflows/${testWorkflowId}`)
.send({ name: 'Updated Name', versionId: testWorkflowVersionId })
.expect(403);
});
test('DELETE /workflows/:id should return 403', async () => {
await testUserAgent.delete(`/workflows/${testWorkflowId}`).expect(403);
});
test('POST /workflows/:id/archive should return 403', async () => {
await testUserAgent.post(`/workflows/${testWorkflowId}/archive`).send().expect(403);
});
test('POST /workflows/:id/unarchive should return 403', async () => {
await testUserAgent.post(`/workflows/${testWorkflowId}/unarchive`).send().expect(403);
});
test('POST /workflows/:id/run should return 403', async () => {
const runPayload = {
workflowData: { id: testWorkflowId, name: 'Test', nodes: [], connections: {} },
};
await testUserAgent.post(`/workflows/${testWorkflowId}/run`).send(runPayload).expect(403);
});
test('PUT /workflows/:id/share should return 403', async () => {
await testUserAgent
.put(`/workflows/${testWorkflowId}/share`)
.send({ shareWithIds: [testUserPersonalProject.id] })
.expect(403);
});
test('PUT /workflows/:id/transfer should return 403', async () => {
await testUserAgent
.put(`/workflows/${testWorkflowId}/transfer`)
.send({ destinationProjectId: testUserPersonalProject.id })
.expect(403);
});
});
describe('Workflow All-Operations Role', () => {
beforeEach(async () => {
await linkUserToProject(testUser, teamProject, workflowAllOperationsRole.slug);
});
test('POST /workflows should return 200', async () => {
const workflowPayload = {
name: 'All-Ops Workflow',
active: false,
nodes: [
{
id: 'uuid-1234',
parameters: {},
name: 'Start',
type: 'n8n-nodes-base.manualTrigger',
typeVersion: 1,
position: [240, 300],
},
],
connections: {},
projectId: teamProject.id,
};
const response = await testUserAgent.post('/workflows').send(workflowPayload).expect(200);
expect(response.body.data.name).toBe('All-Ops Workflow');
});
test('GET /workflows should return 200', async () => {
const response = await testUserAgent.get('/workflows').expect(200);
expect(Array.isArray(response.body.data)).toBe(true);
expect(response.body.data.length).toBeGreaterThan(0);
});
test('GET /workflows/new should return 200', async () => {
await testUserAgent.get(`/workflows/new?projectId=${teamProject.id}`).expect(200);
});
test('GET /workflows/:id should return 200', async () => {
const response = await testUserAgent.get(`/workflows/${testWorkflowId}`).expect(200);
expect(response.body.data.name).toBe('Matrix Test Workflow');
});
test('PATCH /workflows/:id should return 200', async () => {
const response = await testUserAgent
.patch(`/workflows/${testWorkflowId}`)
.send({ name: 'Updated by All-Ops', versionId: testWorkflowVersionId })
.expect(200);
expect(response.body.data.name).toBe('Updated by All-Ops');
});
test('POST /workflows/:id/archive should return 200', async () => {
// All-operations role (includes workflow:delete) can successfully archive workflows
// Archive operation is allowed when user has full CRUD permissions
await testUserAgent.post(`/workflows/${testWorkflowId}/archive`).send().expect(200);
});
test('DELETE /workflows/:id should return 200', async () => {
await testUserAgent.post(`/workflows/${testWorkflowId}/archive`).send().expect(200);
await testUserAgent.delete(`/workflows/${testWorkflowId}`).expect(200);
});
test('POST /workflows/:id/unarchive should return 200', async () => {
await testUserAgent.post(`/workflows/${testWorkflowId}/archive`).send().expect(200);
await testUserAgent.post(`/workflows/${testWorkflowId}/unarchive`).send().expect(200);
});
test('PUT /workflows/:id/share should return 200', async () => {
// Sharing requires workflow:share scope (not included in CRUD operations)
await testUserAgent
.put(`/workflows/${testWorkflowId}/share`)
.send({ shareWithIds: [testUserPersonalProject.id] })
.expect(200);
});
});
});
describe('Credential Access Control Matrix', () => {
describe('Credential Read-Only Role', () => {
beforeEach(async () => {
await linkUserToProject(testUser, teamProject, credentialReadOnlyRole.slug);
});
test('GET /credentials should return 200', async () => {
const response = await testUserAgent.get('/credentials').expect(200);
expect(Array.isArray(response.body.data)).toBe(true);
expect(response.body.data.length).toBeGreaterThan(0);
});
test('GET /credentials/new should return 200', async () => {
await testUserAgent.get('/credentials/new').expect(200);
});
test('GET /credentials/:id should return 200', async () => {
const response = await testUserAgent.get(`/credentials/${testCredentialId}`).expect(200);
expect(response.body.data).toBeDefined();
});
test('POST /credentials should return 403', async () => {
const credentialPayload = randomCredentialPayload();
await testUserAgent
.post('/credentials')
.send({ ...credentialPayload, projectId: teamProject.id })
.expect(403);
});
test('PATCH /credentials/:id should return 403', async () => {
const updatePayload = { name: 'Updated Credential Name' };
await testUserAgent
.patch(`/credentials/${testCredentialId}`)
.send(updatePayload)
.expect(403);
});
test('DELETE /credentials/:id should return 403', async () => {
await testUserAgent.delete(`/credentials/${testCredentialId}`).expect(403);
});
test('PUT /credentials/:id/share should return 403', async () => {
await testUserAgent
.put(`/credentials/${testCredentialId}/share`)
.send({ shareWithIds: [testUserPersonalProject.id] })
.expect(403);
});
test('PUT /credentials/:id/transfer should return 403', async () => {
await testUserAgent
.put(`/credentials/${testCredentialId}/transfer`)
.send({ destinationProjectId: testUserPersonalProject.id })
.expect(403);
});
});
describe('Credential All-Operations Role', () => {
beforeEach(async () => {
await linkUserToProject(testUser, teamProject, credentialAllOperationsRole.slug);
});
test('GET /credentials should return 200', async () => {
const response = await testUserAgent.get('/credentials').expect(200);
expect(Array.isArray(response.body.data)).toBe(true);
expect(response.body.data.length).toBeGreaterThan(0);
});
test('GET /credentials/new should return 200', async () => {
await testUserAgent.get('/credentials/new').expect(200);
});
test('GET /credentials/:id should return 200', async () => {
const response = await testUserAgent.get(`/credentials/${testCredentialId}`).expect(200);
expect(response.body.data).toBeDefined();
});
test('POST /credentials should return 200', async () => {
const credentialPayload = randomCredentialPayload();
const response = await testUserAgent
.post('/credentials')
.send({ ...credentialPayload, projectId: teamProject.id })
.expect(200);
expect(response.body.data.name).toBe(credentialPayload.name);
});
test('PATCH /credentials/:id should return 200', async () => {
const original = await testUserAgent.get(`/credentials/${testCredentialId}`).expect(200);
const updatePayload = { ...original.body.data, name: 'Updated by All-Ops', data: {} };
const response = await testUserAgent
.patch(`/credentials/${testCredentialId}`)
.send(updatePayload)
.expect(200);
expect(response.body.data.name).toBe('Updated by All-Ops');
});
test('DELETE /credentials/:id should return 200', async () => {
await testUserAgent.delete(`/credentials/${testCredentialId}`).expect(200);
});
test('PUT /credentials/:id/share should return 403', async () => {
// Sharing requires credential:share scope (not included in CRUD operations)
await testUserAgent
.put(`/credentials/${testCredentialId}/share`)
.send({ shareWithIds: [testUserPersonalProject.id] })
.expect(403);
});
test('PUT /credentials/:id/transfer should return 403', async () => {
// Transfer requires credential:move scope (not included in CRUD operations)
await testUserAgent
.put(`/credentials/${testCredentialId}/transfer`)
.send({ destinationProjectId: testUserPersonalProject.id })
.expect(403);
});
});
});
});