n8n/packages/cli/test/integration/access-control/cross-project-access.test.ts

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