Security: Sync from Public / sync-from-public (push) Has been cancelled
Test: Benchmark Nightly / build (push) Has been cancelled
Test: Benchmark Nightly / Notify Cats on failure (push) Has been cancelled
CI: Python / Checks (push) Has been cancelled
Test: Evals Python / Workflow Comparison Python (push) Has been cancelled
Util: Check Docs URLs / check-docs-urls (push) Has been cancelled
Test: Visual Storybook / Cloudflare Pages (push) Has been cancelled
Test: E2E Performance / build-and-test-performance (push) Has been cancelled
Test: Workflows Nightly / Run Workflow Tests (push) Has been cancelled
Util: Cleanup CI Docker Images / Delete stale CI images (push) Has been cancelled
Test: Benchmark Destroy Env / build (push) Has been cancelled
Util: Update Node Popularity / update-popularity (push) Has been cancelled
Test: E2E Coverage Weekly / Coverage Tests (push) Has been cancelled
424 lines
14 KiB
TypeScript
424 lines
14 KiB
TypeScript
import {
|
|
createTeamProject,
|
|
getPersonalProject,
|
|
linkUserToProject,
|
|
createWorkflow,
|
|
randomCredentialPayload,
|
|
mockInstance,
|
|
} from '@n8n/backend-test-utils';
|
|
import type { Project, User } from '@n8n/db';
|
|
import {
|
|
PROJECT_ADMIN_ROLE_SLUG,
|
|
PROJECT_EDITOR_ROLE_SLUG,
|
|
PROJECT_VIEWER_ROLE_SLUG,
|
|
} from '@n8n/permissions';
|
|
|
|
import { UserManagementMailer } from '@/user-management/email';
|
|
|
|
import { cleanupRolesAndScopes } from '../shared/db/roles';
|
|
import { createOwner, createMember } from '../shared/db/users';
|
|
import type { SuperAgentTest } from '../shared/types';
|
|
import * as utils from '../shared/utils/';
|
|
|
|
/**
|
|
* Built-in Role Matrix Testing
|
|
*
|
|
* Tests the behavior of built-in project roles:
|
|
* - Project Admin: Full project access (all permissions)
|
|
* - Project Editor: Full CRUD access (create, read, update, delete)
|
|
* - Project Viewer: Read-only access (read and list only)
|
|
* - Personal Project Owner: Full access within personal projects
|
|
*/
|
|
|
|
const testServer = utils.setupTestServer({
|
|
endpointGroups: ['workflows', 'credentials'],
|
|
enabledFeatures: ['feat:sharing', 'feat:customRoles'],
|
|
quotas: {
|
|
'quota:maxTeamProjects': -1,
|
|
},
|
|
});
|
|
|
|
let owner: User;
|
|
let member1: User;
|
|
let member2: User;
|
|
let member3: User;
|
|
|
|
// Projects for different test scenarios
|
|
let teamProjectA: Project;
|
|
let member1PersonalProject: Project;
|
|
|
|
// Authentication agents
|
|
let ownerAgent: SuperAgentTest;
|
|
let member1Agent: SuperAgentTest;
|
|
let member2Agent: SuperAgentTest;
|
|
let member3Agent: SuperAgentTest;
|
|
|
|
describe('Built-in Role Matrix Testing', () => {
|
|
beforeAll(async () => {
|
|
mockInstance(UserManagementMailer, {
|
|
invite: jest.fn(),
|
|
passwordReset: jest.fn(),
|
|
});
|
|
|
|
await utils.initCredentialsTypes();
|
|
|
|
// Create standard users
|
|
owner = await createOwner();
|
|
member1 = await createMember();
|
|
member2 = await createMember();
|
|
member3 = await createMember();
|
|
|
|
// Get personal projects
|
|
member1PersonalProject = await getPersonalProject(member1);
|
|
|
|
// Create team projects for testing
|
|
teamProjectA = await createTeamProject('Team Project A', owner);
|
|
|
|
// Create authentication agents
|
|
ownerAgent = testServer.authAgentFor(owner);
|
|
member1Agent = testServer.authAgentFor(member1);
|
|
member2Agent = testServer.authAgentFor(member2);
|
|
member3Agent = testServer.authAgentFor(member3);
|
|
});
|
|
|
|
afterAll(async () => {
|
|
await cleanupRolesAndScopes();
|
|
});
|
|
|
|
describe('Project Admin Role - Full Project Access', () => {
|
|
beforeEach(async () => {
|
|
// Link member1 to teamProjectA as project admin
|
|
await linkUserToProject(member1, teamProjectA, PROJECT_ADMIN_ROLE_SLUG);
|
|
});
|
|
|
|
test('project admin should have full workflow access', async () => {
|
|
// Create a workflow in the project via HTTP endpoint (project admin has workflow:create scope)
|
|
const workflowPayload = {
|
|
name: 'Test Admin Workflow',
|
|
active: false,
|
|
nodes: [
|
|
{
|
|
id: 'uuid-1234',
|
|
parameters: {},
|
|
name: 'Start',
|
|
type: 'n8n-nodes-base.manualTrigger',
|
|
typeVersion: 1,
|
|
position: [240, 300],
|
|
},
|
|
],
|
|
connections: {},
|
|
staticData: null,
|
|
settings: {
|
|
saveExecutionProgress: true,
|
|
},
|
|
projectId: teamProjectA.id,
|
|
};
|
|
|
|
const adminWorkflowResponse = await member1Agent
|
|
.post('/workflows')
|
|
.send(workflowPayload)
|
|
.expect(200);
|
|
|
|
const workflow = adminWorkflowResponse.body.data;
|
|
expect(workflow.name).toBe('Test Admin Workflow');
|
|
|
|
// Test workflow list access
|
|
const listResponse = await member1Agent.get('/workflows').expect(200);
|
|
expect(listResponse.body.data).toHaveLength(1);
|
|
expect(listResponse.body.data[0].name).toBe('Test Admin Workflow');
|
|
|
|
// Test workflow read access
|
|
const getResponse = await member1Agent.get(`/workflows/${workflow.id}`).expect(200);
|
|
expect(getResponse.body.data.name).toBe('Test Admin Workflow');
|
|
|
|
// Test workflow update access
|
|
const updateResponse = await member1Agent
|
|
.patch(`/workflows/${workflow.id}`)
|
|
.send({ name: 'Updated Admin Workflow', versionId: workflow.versionId })
|
|
.expect(200);
|
|
expect(updateResponse.body.data.name).toBe('Updated Admin Workflow');
|
|
|
|
// Test workflow delete access
|
|
await member1Agent.post(`/workflows/${workflow.id}/archive`).send().expect(200);
|
|
await member1Agent.delete(`/workflows/${workflow.id}`).send().expect(200);
|
|
});
|
|
|
|
test('project admin should have full credential access', async () => {
|
|
// Create a credential payload
|
|
const credentialPayload = randomCredentialPayload();
|
|
|
|
// Test credential create access
|
|
const adminCredentialResponse = await member1Agent
|
|
.post('/credentials')
|
|
.send({ ...credentialPayload, projectId: teamProjectA.id })
|
|
.expect(200);
|
|
|
|
const credentialId = adminCredentialResponse.body.data.id;
|
|
expect(adminCredentialResponse.body.data.name).toBe(credentialPayload.name);
|
|
|
|
// Test credential list access
|
|
const listResponse = await member1Agent.get('/credentials').expect(200);
|
|
expect(listResponse.body.data).toHaveLength(1);
|
|
expect(listResponse.body.data[0].name).toBe(credentialPayload.name);
|
|
|
|
// Test credential read access
|
|
const getResponse = await member1Agent.get(`/credentials/${credentialId}`).expect(200);
|
|
expect(getResponse.body.data.name).toBe(credentialPayload.name);
|
|
|
|
// Test credential update access
|
|
const updateResponse = await member1Agent
|
|
.patch(`/credentials/${credentialId}`)
|
|
.send({ ...credentialPayload, name: 'Updated Admin Credential' })
|
|
.expect(200);
|
|
expect(updateResponse.body.data.name).toBe('Updated Admin Credential');
|
|
|
|
// Test credential delete access
|
|
await member1Agent.delete(`/credentials/${credentialId}`).expect(200);
|
|
});
|
|
});
|
|
|
|
describe('Project Editor Role - Full CRUD Access', () => {
|
|
beforeEach(async () => {
|
|
// Link member2 to teamProjectA as project editor
|
|
await linkUserToProject(member2, teamProjectA, PROJECT_EDITOR_ROLE_SLUG);
|
|
});
|
|
|
|
test('project editor should have full workflow CRUD access', async () => {
|
|
// Create a workflow in the project via HTTP endpoint (project editor has workflow:create scope)
|
|
const workflowPayload = {
|
|
name: 'Test Editor Workflow',
|
|
active: false,
|
|
nodes: [
|
|
{
|
|
id: 'uuid-1234',
|
|
parameters: {},
|
|
name: 'Start',
|
|
type: 'n8n-nodes-base.manualTrigger',
|
|
typeVersion: 1,
|
|
position: [240, 300],
|
|
},
|
|
],
|
|
connections: {},
|
|
staticData: null,
|
|
settings: {
|
|
saveExecutionProgress: true,
|
|
},
|
|
projectId: teamProjectA.id,
|
|
};
|
|
|
|
const editorWorkflowResponse = await member2Agent
|
|
.post('/workflows')
|
|
.send(workflowPayload)
|
|
.expect(200);
|
|
|
|
const workflow = editorWorkflowResponse.body.data;
|
|
expect(workflow.name).toBe('Test Editor Workflow');
|
|
|
|
// Test workflow list access
|
|
const listResponse = await member2Agent.get('/workflows').expect(200);
|
|
expect(listResponse.body.data).toHaveLength(1);
|
|
|
|
// Test workflow read access
|
|
const getResponse = await member2Agent.get(`/workflows/${workflow.id}`).expect(200);
|
|
expect(getResponse.body.data.name).toBe('Test Editor Workflow');
|
|
|
|
// Test workflow update access
|
|
const updateResponse = await member2Agent
|
|
.patch(`/workflows/${workflow.id}`)
|
|
.send({ name: 'Updated Editor Workflow', versionId: workflow.versionId })
|
|
.expect(200);
|
|
expect(updateResponse.body.data.name).toBe('Updated Editor Workflow');
|
|
|
|
// Test workflow delete access (should succeed - editors have delete permission)
|
|
await member2Agent.post(`/workflows/${workflow.id}/archive`).send().expect(200);
|
|
await member2Agent.delete(`/workflows/${workflow.id}`).send().expect(200);
|
|
});
|
|
|
|
test('project editor should have full credential CRUD access', async () => {
|
|
// Create a credential payload
|
|
const credentialPayload = randomCredentialPayload();
|
|
|
|
// Test credential create access
|
|
const editorCredentialResponse = await member2Agent
|
|
.post('/credentials')
|
|
.send({ ...credentialPayload, projectId: teamProjectA.id })
|
|
.expect(200);
|
|
|
|
const credentialId = editorCredentialResponse.body.data.id;
|
|
expect(editorCredentialResponse.body.data.name).toBe(credentialPayload.name);
|
|
|
|
// Test credential list access
|
|
const listResponse = await member2Agent.get('/credentials').expect(200);
|
|
expect(listResponse.body.data).toHaveLength(1);
|
|
|
|
// Test credential read access
|
|
const getResponse = await member2Agent.get(`/credentials/${credentialId}`).expect(200);
|
|
expect(getResponse.body.data.name).toBe(credentialPayload.name);
|
|
|
|
// Test credential update access
|
|
const updateResponse = await member2Agent
|
|
.patch(`/credentials/${credentialId}`)
|
|
.send({ ...credentialPayload, name: 'Updated Editor Credential' })
|
|
.expect(200);
|
|
expect(updateResponse.body.data.name).toBe('Updated Editor Credential');
|
|
|
|
// Test credential delete access (should succeed - editors have delete permission)
|
|
await member2Agent.delete(`/credentials/${credentialId}`).expect(200);
|
|
});
|
|
});
|
|
|
|
describe('Project Viewer Role - Read-Only Access (No Create/Update/Delete)', () => {
|
|
beforeEach(async () => {
|
|
// Link member3 to teamProjectA as project viewer
|
|
await linkUserToProject(member3, teamProjectA, PROJECT_VIEWER_ROLE_SLUG);
|
|
});
|
|
|
|
test('project viewer should have read-only workflow access (no create/update/delete permissions)', async () => {
|
|
// Create a workflow in the project using helper
|
|
const workflow = await createWorkflow({ name: 'Read-Only Test Workflow' }, teamProjectA);
|
|
|
|
// Test workflow list access
|
|
const listResponse = await member3Agent.get('/workflows').expect(200);
|
|
expect(listResponse.body.data).toHaveLength(1);
|
|
expect(listResponse.body.data[0].name).toBe('Read-Only Test Workflow');
|
|
|
|
// Test workflow read access
|
|
const getResponse = await member3Agent.get(`/workflows/${workflow.id}`).expect(200);
|
|
expect(getResponse.body.data.name).toBe('Read-Only Test Workflow');
|
|
|
|
// Test workflow create access (should be forbidden)
|
|
const workflowPayload = {
|
|
name: 'New Viewer Workflow',
|
|
active: false,
|
|
nodes: [
|
|
{
|
|
id: 'uuid-1234',
|
|
parameters: {},
|
|
name: 'Start',
|
|
type: 'n8n-nodes-base.manualTrigger',
|
|
typeVersion: 1,
|
|
position: [240, 300],
|
|
},
|
|
],
|
|
connections: {},
|
|
projectId: teamProjectA.id,
|
|
};
|
|
|
|
// Project viewer doesn't have workflow:create permissions in this team project
|
|
await member3Agent.post('/workflows').send(workflowPayload).expect(400);
|
|
|
|
// Test workflow update access (should be forbidden)
|
|
await member3Agent
|
|
.patch(`/workflows/${workflow.id}`)
|
|
.send({ name: 'Updated Viewer Workflow', versionId: workflow.versionId })
|
|
.expect(403);
|
|
|
|
// Test workflow delete access (should be forbidden)
|
|
await member3Agent.post(`/workflows/${workflow.id}/archive`).send().expect(403);
|
|
});
|
|
|
|
test('project viewer should have read-only credential access (no create/update/delete permissions)', async () => {
|
|
// Create a credential via owner first
|
|
const credentialPayload = randomCredentialPayload();
|
|
const ownerCredentialResponse = await ownerAgent
|
|
.post('/credentials')
|
|
.send({ ...credentialPayload, projectId: teamProjectA.id })
|
|
.expect(200);
|
|
|
|
const credentialId = ownerCredentialResponse.body.data.id;
|
|
|
|
// Test credential list access
|
|
const listResponse = await member3Agent.get('/credentials').expect(200);
|
|
expect(listResponse.body.data).toHaveLength(1);
|
|
expect(listResponse.body.data[0].name).toBe(credentialPayload.name);
|
|
|
|
// Test credential read access
|
|
const getResponse = await member3Agent.get(`/credentials/${credentialId}`).expect(200);
|
|
expect(getResponse.body.data.name).toBe(credentialPayload.name);
|
|
|
|
// Test credential create access (should be forbidden)
|
|
await member3Agent
|
|
.post('/credentials')
|
|
.send({ ...randomCredentialPayload(), projectId: teamProjectA.id })
|
|
.expect(400);
|
|
|
|
// Test credential update access (should be forbidden)
|
|
await member3Agent
|
|
.patch(`/credentials/${credentialId}`)
|
|
.send({ ...credentialPayload, name: 'Updated Viewer Credential' })
|
|
.expect(403);
|
|
|
|
// Test credential delete access (should be forbidden)
|
|
await member3Agent.delete(`/credentials/${credentialId}`).expect(403);
|
|
});
|
|
});
|
|
|
|
describe('Personal Project Owner Role - Personal Project Full Access', () => {
|
|
test('personal project owner should have full access in their own project', async () => {
|
|
// Create a workflow in member1's personal project via HTTP endpoint (personal owner has workflow:create scope)
|
|
const workflowPayload = {
|
|
name: 'Test Personal Workflow',
|
|
active: false,
|
|
nodes: [
|
|
{
|
|
id: 'uuid-1234',
|
|
parameters: {},
|
|
name: 'Start',
|
|
type: 'n8n-nodes-base.manualTrigger',
|
|
typeVersion: 1,
|
|
position: [240, 300],
|
|
},
|
|
],
|
|
connections: {},
|
|
staticData: null,
|
|
settings: {
|
|
saveExecutionProgress: true,
|
|
},
|
|
projectId: member1PersonalProject.id,
|
|
};
|
|
|
|
const personalWorkflowResponse = await member1Agent
|
|
.post('/workflows')
|
|
.send(workflowPayload)
|
|
.expect(200);
|
|
|
|
const workflow = personalWorkflowResponse.body.data;
|
|
expect(workflow.name).toBe('Test Personal Workflow');
|
|
|
|
// Test workflow list access - filter for personal project workflows only
|
|
const listResponse = await member1Agent.get('/workflows').expect(200);
|
|
const personalWorkflows = listResponse.body.data.filter(
|
|
(wf: any) => wf.homeProject && wf.homeProject.id === member1PersonalProject.id,
|
|
);
|
|
expect(personalWorkflows).toHaveLength(1);
|
|
expect(personalWorkflows[0].name).toBe('Test Personal Workflow');
|
|
|
|
// Test workflow operations
|
|
await member1Agent.get(`/workflows/${workflow.id}`).expect(200);
|
|
await member1Agent
|
|
.patch(`/workflows/${workflow.id}`)
|
|
.send({ name: 'Updated Personal Workflow', versionId: workflow.versionId })
|
|
.expect(200);
|
|
await member1Agent.post(`/workflows/${workflow.id}/archive`).send().expect(200);
|
|
await member1Agent.delete(`/workflows/${workflow.id}`).send().expect(200);
|
|
|
|
// Test credential operations
|
|
const credentialPayload = randomCredentialPayload();
|
|
const personalCredentialResponse = await member1Agent
|
|
.post('/credentials')
|
|
.send({ ...credentialPayload, projectId: member1PersonalProject.id })
|
|
.expect(200);
|
|
|
|
const credentialId = personalCredentialResponse.body.data.id;
|
|
expect(personalCredentialResponse.body.data.name).toBe(credentialPayload.name);
|
|
|
|
await member1Agent.get(`/credentials/${credentialId}`).expect(200);
|
|
await member1Agent
|
|
.patch(`/credentials/${credentialId}`)
|
|
.send({ ...credentialPayload, name: 'Updated Personal Credential' })
|
|
.expect(200);
|
|
await member1Agent.delete(`/credentials/${credentialId}`).expect(200);
|
|
});
|
|
});
|
|
});
|