mirror of
https://github.com/n8n-io/n8n.git
synced 2026-05-31 16:57:08 +02:00
422 lines
17 KiB
TypeScript
422 lines
17 KiB
TypeScript
import {
|
|
createTeamProject,
|
|
linkUserToProject,
|
|
randomCredentialPayload,
|
|
createWorkflow,
|
|
mockInstance,
|
|
testDb,
|
|
} 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/';
|
|
|
|
/**
|
|
* Cross-Project Access Control Testing
|
|
*
|
|
* Tests role isolation and permission boundaries across different projects:
|
|
* - Role isolation between projects (permissions should not bleed across projects)
|
|
* - Multi-project role assignments (users with different roles in different projects)
|
|
* - Permission boundaries with complex project setups
|
|
* - Unauthorized cross-project resource manipulation prevention
|
|
* - Project-scoped role validation and enforcement
|
|
*/
|
|
|
|
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 teamProjectB: Project;
|
|
|
|
// Custom roles for testing (using existing scope system)
|
|
let customWorkflowReader: Role;
|
|
let customWorkflowWriter: Role;
|
|
let customCredentialReader: Role;
|
|
let customCredentialWriter: Role;
|
|
let customMixedReader: Role;
|
|
|
|
// Authentication agents
|
|
let ownerAgent: SuperAgentTest;
|
|
let member1Agent: SuperAgentTest;
|
|
let member2Agent: SuperAgentTest;
|
|
let member3Agent: SuperAgentTest;
|
|
|
|
describe('Cross-Project Access Control Tests', () => {
|
|
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();
|
|
|
|
// Create team projects for testing
|
|
teamProjectA = await createTeamProject('Team Project A', owner);
|
|
teamProjectB = await createTeamProject('Team Project B', owner);
|
|
|
|
// Create authentication agents
|
|
ownerAgent = testServer.authAgentFor(owner);
|
|
member1Agent = testServer.authAgentFor(member1);
|
|
member2Agent = testServer.authAgentFor(member2);
|
|
member3Agent = testServer.authAgentFor(member3);
|
|
|
|
// Create custom roles using predefined scope slugs from the permissions system
|
|
customWorkflowReader = await createCustomRoleWithScopeSlugs(
|
|
['workflow:read', 'workflow:list'],
|
|
{
|
|
roleType: 'project',
|
|
displayName: 'Custom Workflow Reader',
|
|
description: 'Can read and list workflows only',
|
|
},
|
|
);
|
|
|
|
customWorkflowWriter = await createCustomRoleWithScopeSlugs(
|
|
['workflow:read', 'workflow:list', 'workflow:create', 'workflow:update'],
|
|
{
|
|
roleType: 'project',
|
|
displayName: 'Custom Workflow Writer',
|
|
description: 'Can read, list, create and update workflows',
|
|
},
|
|
);
|
|
|
|
customCredentialReader = await createCustomRoleWithScopeSlugs(
|
|
['credential:read', 'credential:list'],
|
|
{
|
|
roleType: 'project',
|
|
displayName: 'Custom Credential Reader',
|
|
description: 'Can read and list credentials only',
|
|
},
|
|
);
|
|
|
|
customCredentialWriter = await createCustomRoleWithScopeSlugs(
|
|
['credential:read', 'credential:list', 'credential:create', 'credential:update'],
|
|
{
|
|
roleType: 'project',
|
|
displayName: 'Custom Credential Writer',
|
|
description: 'Can read, list, create and update credentials',
|
|
},
|
|
);
|
|
|
|
customMixedReader = await createCustomRoleWithScopeSlugs(
|
|
['workflow:read', 'workflow:list', 'credential:read', 'credential:list'],
|
|
{
|
|
roleType: 'project',
|
|
displayName: 'Custom Mixed Reader',
|
|
description: 'Can read and list both workflows and credentials',
|
|
},
|
|
);
|
|
});
|
|
|
|
afterAll(async () => {
|
|
await testDb.truncate(['User', 'ProjectRelation']);
|
|
await cleanupRolesAndScopes();
|
|
});
|
|
|
|
test('should enforce role isolation between projects', async () => {
|
|
// Member1 has workflow writer role in teamProjectA only
|
|
await linkUserToProject(member1, teamProjectA, customWorkflowWriter.slug);
|
|
|
|
// Member2 has credential writer role in teamProjectB only
|
|
await linkUserToProject(member2, teamProjectB, customCredentialWriter.slug);
|
|
|
|
// Create resources in both projects via owner
|
|
const workflowA = await createWorkflow({ name: 'Project A Workflow' }, teamProjectA);
|
|
const workflowB = await createWorkflow({ name: 'Project B Workflow' }, teamProjectB);
|
|
|
|
const credentialAPayload = randomCredentialPayload();
|
|
const credentialAResponse = await ownerAgent
|
|
.post('/credentials')
|
|
.send({ ...credentialAPayload, projectId: teamProjectA.id })
|
|
.expect(200);
|
|
|
|
const credentialBPayload = randomCredentialPayload();
|
|
const credentialBResponse = await ownerAgent
|
|
.post('/credentials')
|
|
.send({ ...credentialBPayload, projectId: teamProjectB.id })
|
|
.expect(200);
|
|
|
|
// Member1 should only see workflows from teamProjectA
|
|
const member1WorkflowsResponse = await member1Agent.get('/workflows').expect(200);
|
|
expect(member1WorkflowsResponse.body.data).toHaveLength(1);
|
|
expect(member1WorkflowsResponse.body.data[0].name).toBe('Project A Workflow');
|
|
|
|
// Member1 should not see credentials from any project (no credential permissions)
|
|
const member1CredentialsResponse = await member1Agent.get('/credentials').expect(200);
|
|
expect(member1CredentialsResponse.body.data).toHaveLength(0);
|
|
|
|
// Member2 should only see credentials from teamProjectB
|
|
const member2CredentialsResponse = await member2Agent.get('/credentials').expect(200);
|
|
expect(member2CredentialsResponse.body.data).toHaveLength(1);
|
|
expect(member2CredentialsResponse.body.data[0].name).toBe(credentialBPayload.name);
|
|
|
|
// Member2 should not see workflows from any project (no workflow permissions)
|
|
const member2WorkflowsResponse = await member2Agent.get('/workflows').expect(200);
|
|
expect(member2WorkflowsResponse.body.data).toHaveLength(0);
|
|
|
|
// Cross-project access should be forbidden
|
|
await member1Agent.get(`/workflows/${workflowB.id}`).expect(403); // Member1 can't access Project B workflow
|
|
await member2Agent.get(`/credentials/${credentialAResponse.body.data.id}`).expect(403); // Member2 can't access Project A credential
|
|
|
|
// Verify member1 can still access workflowA in their authorized project
|
|
await member1Agent.get(`/workflows/${workflowA.id}`).expect(200);
|
|
// Verify member2 can still access credentials in their authorized project
|
|
await member2Agent.get(`/credentials/${credentialBResponse.body.data.id}`).expect(200);
|
|
});
|
|
|
|
test('should handle multi-project role assignments correctly', async () => {
|
|
// Give member1 different roles in different projects
|
|
await linkUserToProject(member1, teamProjectA, customWorkflowWriter.slug); // Write access to workflows in Project A
|
|
await linkUserToProject(member1, teamProjectB, customCredentialReader.slug); // Read access to credentials in Project B
|
|
|
|
// Create resources in both projects
|
|
const workflowA = await createWorkflow(
|
|
{ name: 'Multi-Project Workflow A Test2' },
|
|
teamProjectA,
|
|
);
|
|
const workflowB = await createWorkflow(
|
|
{ name: 'Multi-Project Workflow B Test2' },
|
|
teamProjectB,
|
|
);
|
|
|
|
const credentialAPayload = randomCredentialPayload();
|
|
const credentialAResponse = await ownerAgent
|
|
.post('/credentials')
|
|
.send({ ...credentialAPayload, projectId: teamProjectA.id })
|
|
.expect(200);
|
|
|
|
const credentialBPayload = randomCredentialPayload();
|
|
const credentialBResponse = await ownerAgent
|
|
.post('/credentials')
|
|
.send({ ...credentialBPayload, projectId: teamProjectB.id })
|
|
.expect(200);
|
|
|
|
// Member1 should see workflows from Project A (writer role) - but might see workflows from previous tests
|
|
const workflowsResponse = await member1Agent.get('/workflows').expect(200);
|
|
const member1Workflows = workflowsResponse.body.data.filter(
|
|
(wf: any) => wf.name === 'Multi-Project Workflow A Test2',
|
|
);
|
|
expect(member1Workflows).toHaveLength(1);
|
|
expect(member1Workflows[0].name).toBe('Multi-Project Workflow A Test2');
|
|
|
|
// Member1 should see credentials from Project B only (reader role)
|
|
const credentialsResponse = await member1Agent.get('/credentials').expect(200);
|
|
const member1Credentials = credentialsResponse.body.data.filter(
|
|
(cred: any) => cred.name === credentialBPayload.name,
|
|
);
|
|
expect(member1Credentials).toHaveLength(1);
|
|
expect(member1Credentials[0].name).toBe(credentialBPayload.name);
|
|
|
|
// Test workflow permissions: Can read/write in Project A
|
|
await member1Agent.get(`/workflows/${workflowA.id}`).expect(200); // Can read
|
|
await member1Agent
|
|
.patch(`/workflows/${workflowA.id}`)
|
|
.send({ name: 'Updated Multi-Project Workflow A Test2', versionId: workflowA.versionId })
|
|
.expect(200); // Can write
|
|
|
|
// Test workflow permissions: Cannot access Project B workflows
|
|
await member1Agent.get(`/workflows/${workflowB.id}`).expect(403);
|
|
|
|
// Test credential permissions: Can read in Project B
|
|
await member1Agent.get(`/credentials/${credentialBResponse.body.data.id}`).expect(200); // Can read
|
|
|
|
// Test credential permissions: Cannot write in Project B (reader role only)
|
|
await member1Agent
|
|
.patch(`/credentials/${credentialBResponse.body.data.id}`)
|
|
.send({ ...credentialBPayload, name: 'Forbidden Update' })
|
|
.expect(403); // Cannot write
|
|
|
|
// Test credential permissions: Cannot access Project A credentials (no credential role in Project A)
|
|
await member1Agent.get(`/credentials/${credentialAResponse.body.data.id}`).expect(403);
|
|
});
|
|
|
|
test('should validate permission boundaries with complex project setups', async () => {
|
|
// Create a complex permission matrix:
|
|
// Member1: Workflow Writer in Project A, Credential Reader in Project B
|
|
// Member2: Workflow Reader in Project A, Credential Writer in Project B
|
|
// Member3: Mixed Reader in both projects
|
|
|
|
await linkUserToProject(member1, teamProjectA, customWorkflowWriter.slug);
|
|
await linkUserToProject(member1, teamProjectB, customCredentialReader.slug);
|
|
|
|
await linkUserToProject(member2, teamProjectA, customWorkflowReader.slug);
|
|
await linkUserToProject(member2, teamProjectB, customCredentialWriter.slug);
|
|
|
|
await linkUserToProject(member3, teamProjectA, customMixedReader.slug);
|
|
await linkUserToProject(member3, teamProjectB, customMixedReader.slug);
|
|
|
|
// Create resources for testing
|
|
const workflowA = await createWorkflow(
|
|
{ name: 'Boundary Test Workflow A Test3' },
|
|
teamProjectA,
|
|
);
|
|
const workflowB = await createWorkflow(
|
|
{ name: 'Boundary Test Workflow B Test3' },
|
|
teamProjectB,
|
|
);
|
|
|
|
const credentialAPayload = randomCredentialPayload();
|
|
const credentialAResponse = await ownerAgent
|
|
.post('/credentials')
|
|
.send({ ...credentialAPayload, projectId: teamProjectA.id })
|
|
.expect(200);
|
|
|
|
const credentialBPayload = randomCredentialPayload();
|
|
const credentialBResponse = await ownerAgent
|
|
.post('/credentials')
|
|
.send({ ...credentialBPayload, projectId: teamProjectB.id })
|
|
.expect(200);
|
|
|
|
// Test Member1 permissions - Should see: workflows from Project A, credentials from Project B
|
|
const member1WorkflowsResponse = await member1Agent.get('/workflows').expect(200);
|
|
const member1SpecificWorkflows = member1WorkflowsResponse.body.data.filter(
|
|
(wf: any) => wf.name === 'Boundary Test Workflow A Test3',
|
|
);
|
|
expect(member1SpecificWorkflows).toHaveLength(1);
|
|
expect(member1SpecificWorkflows[0].name).toBe('Boundary Test Workflow A Test3');
|
|
|
|
const member1CredentialsResponse = await member1Agent.get('/credentials').expect(200);
|
|
const member1SpecificCredentials = member1CredentialsResponse.body.data.filter(
|
|
(cred: any) => cred.name === credentialBPayload.name,
|
|
);
|
|
expect(member1SpecificCredentials).toHaveLength(1);
|
|
expect(member1SpecificCredentials[0].name).toBe(credentialBPayload.name);
|
|
|
|
// Test Member2 permissions - Should see: workflows from Project A (read-only), credentials from Project B
|
|
const member2WorkflowsResponse = await member2Agent.get('/workflows').expect(200);
|
|
const member2SpecificWorkflows = member2WorkflowsResponse.body.data.filter(
|
|
(wf: any) => wf.name === 'Boundary Test Workflow A Test3',
|
|
);
|
|
expect(member2SpecificWorkflows).toHaveLength(1);
|
|
expect(member2SpecificWorkflows[0].name).toBe('Boundary Test Workflow A Test3');
|
|
|
|
const member2CredentialsResponse = await member2Agent.get('/credentials').expect(200);
|
|
const member2SpecificCredentials = member2CredentialsResponse.body.data.filter(
|
|
(cred: any) => cred.name === credentialBPayload.name,
|
|
);
|
|
expect(member2SpecificCredentials).toHaveLength(1);
|
|
expect(member2SpecificCredentials[0].name).toBe(credentialBPayload.name);
|
|
|
|
// Test Member3 permissions - Should see: workflows from both projects, credentials from both projects (read-only)
|
|
const member3WorkflowsResponse = await member3Agent.get('/workflows').expect(200);
|
|
const member3SpecificWorkflowsA = member3WorkflowsResponse.body.data.filter(
|
|
(wf: any) => wf.name === 'Boundary Test Workflow A Test3',
|
|
);
|
|
const member3SpecificWorkflowsB = member3WorkflowsResponse.body.data.filter(
|
|
(wf: any) => wf.name === 'Boundary Test Workflow B Test3',
|
|
);
|
|
expect(member3SpecificWorkflowsA).toHaveLength(1);
|
|
expect(member3SpecificWorkflowsB).toHaveLength(1);
|
|
|
|
const member3CredentialsResponse = await member3Agent.get('/credentials').expect(200);
|
|
const member3SpecificCredentialsA = member3CredentialsResponse.body.data.filter(
|
|
(cred: any) => cred.name === credentialAPayload.name,
|
|
);
|
|
const member3SpecificCredentialsB = member3CredentialsResponse.body.data.filter(
|
|
(cred: any) => cred.name === credentialBPayload.name,
|
|
);
|
|
expect(member3SpecificCredentialsA).toHaveLength(1);
|
|
expect(member3SpecificCredentialsB).toHaveLength(1);
|
|
|
|
// Test write permission boundaries
|
|
// Member1 can write workflows in Project A, but not credentials
|
|
await member1Agent
|
|
.patch(`/workflows/${workflowA.id}`)
|
|
.send({ name: 'Updated by Member1 Test3', versionId: workflowA.versionId })
|
|
.expect(200);
|
|
|
|
await member1Agent
|
|
.patch(`/credentials/${credentialAResponse.body.data.id}`)
|
|
.send({ ...credentialAPayload, name: 'Forbidden Update' })
|
|
.expect(403);
|
|
|
|
// Member2 cannot write workflows in Project A, but can write credentials in Project B
|
|
await member2Agent
|
|
.patch(`/workflows/${workflowA.id}`)
|
|
.send({ name: 'Forbidden Update', versionId: workflowA.versionId })
|
|
.expect(403);
|
|
|
|
await member2Agent
|
|
.patch(`/credentials/${credentialBResponse.body.data.id}`)
|
|
.send({ ...credentialBPayload, name: 'Updated by Member2 Test3' })
|
|
.expect(200);
|
|
|
|
// Member3 cannot write anything (read-only role)
|
|
await member3Agent
|
|
.patch(`/workflows/${workflowA.id}`)
|
|
.send({ name: 'Forbidden Update', versionId: workflowA.versionId })
|
|
.expect(403);
|
|
|
|
await member3Agent
|
|
.patch(`/credentials/${credentialBResponse.body.data.id}`)
|
|
.send({ ...credentialBPayload, name: 'Forbidden Update' })
|
|
.expect(403);
|
|
|
|
// Verify member3 can read workflowB directly
|
|
await member3Agent.get(`/workflows/${workflowB.id}`).expect(200);
|
|
});
|
|
|
|
test('should prevent unauthorized cross-project resource manipulation', async () => {
|
|
// Setup: Member1 has permissions only in Project A
|
|
await linkUserToProject(member1, teamProjectA, customWorkflowWriter.slug);
|
|
|
|
// Create resources in both projects via owner
|
|
const workflowA = await createWorkflow({ name: 'Protected Workflow A Test4' }, teamProjectA);
|
|
const workflowB = await createWorkflow({ name: 'Protected Workflow B Test4' }, teamProjectB);
|
|
|
|
// Member1 should be able to manipulate resources in Project A
|
|
await member1Agent.get(`/workflows/${workflowA.id}`).expect(200);
|
|
await member1Agent
|
|
.patch(`/workflows/${workflowA.id}`)
|
|
.send({ name: 'Modified in Project A Test4', versionId: workflowA.versionId })
|
|
.expect(200);
|
|
|
|
// Member1 should NOT be able to manipulate resources in Project B
|
|
await member1Agent.get(`/workflows/${workflowB.id}`).expect(403);
|
|
await member1Agent
|
|
.patch(`/workflows/${workflowB.id}`)
|
|
.send({ name: 'Unauthorized Modification', versionId: workflowB.versionId })
|
|
.expect(403);
|
|
await member1Agent.delete(`/workflows/${workflowB.id}`).expect(403);
|
|
|
|
// Member1 should not be able to create workflows with Project B ID
|
|
const unauthorizedWorkflowPayload = {
|
|
name: 'Unauthorized Workflow Test4',
|
|
nodes: [
|
|
{
|
|
id: 'uuid-1234',
|
|
parameters: {},
|
|
name: 'Start',
|
|
type: 'n8n-nodes-base.manualTrigger',
|
|
typeVersion: 1,
|
|
position: [240, 300],
|
|
},
|
|
],
|
|
connections: {},
|
|
projectId: teamProjectB.id, // Trying to create in Project B
|
|
};
|
|
|
|
await member1Agent.post('/workflows').send(unauthorizedWorkflowPayload).expect(400); // Should be rejected due to lack of permissions in Project B
|
|
});
|
|
});
|