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 }); });