import { v4 as uuid } from 'uuid'; import { INodeTypeData, INodeTypes, SubworkflowOperationError, Workflow } from 'n8n-workflow'; import config from '@/config'; import * as Db from '@/Db'; import * as testDb from '../integration/shared/testDb'; import { NodeTypes as MockNodeTypes } from './Helpers'; import { UserService } from '@/user/user.service'; import { PermissionChecker } from '@/UserManagement/PermissionChecker'; import * as UserManagementHelper from '@/UserManagement/UserManagementHelper'; import { WorkflowsService } from '@/workflows/workflows.services'; import { randomCredentialPayload as randomCred, randomPositiveDigit, } from '../integration/shared/random'; import type { Role } from '@/databases/entities/Role'; import type { SaveCredentialFunction } from '../integration/shared/types'; let testDbName = ''; let mockNodeTypes: INodeTypes; let credentialOwnerRole: Role; let workflowOwnerRole: Role; let saveCredential: SaveCredentialFunction; beforeAll(async () => { const initResult = await testDb.init(); testDbName = initResult.testDbName; mockNodeTypes = MockNodeTypes({ loaded: { nodes: MOCK_NODE_TYPES_DATA, credentials: {}, }, known: { nodes: {}, credentials: {} }, }); credentialOwnerRole = await testDb.getCredentialOwnerRole(); workflowOwnerRole = await testDb.getWorkflowOwnerRole(); saveCredential = testDb.affixRoleToSaveCredential(credentialOwnerRole); }); beforeEach(async () => { await testDb.truncate(['SharedWorkflow', 'SharedCredentials'], testDbName); await testDb.truncate(['User', 'Workflow', 'Credentials'], testDbName); }); afterAll(async () => { await testDb.terminate(testDbName); }); describe('PermissionChecker.check()', () => { test('should allow if workflow has no creds', async () => { const userId = uuid(); const workflow = new Workflow({ id: randomPositiveDigit().toString(), name: 'test', active: false, connections: {}, nodeTypes: mockNodeTypes, nodes: [ { id: uuid(), name: 'Start', type: 'n8n-nodes-base.start', typeVersion: 1, parameters: {}, position: [0, 0], }, ], }); expect(() => PermissionChecker.check(workflow, userId)).not.toThrow(); }); test('should allow if requesting user is instance owner', async () => { const owner = await testDb.createOwner(); const workflow = new Workflow({ id: randomPositiveDigit().toString(), name: 'test', active: false, connections: {}, nodeTypes: mockNodeTypes, nodes: [ { id: uuid(), name: 'Action Network', type: 'n8n-nodes-base.actionNetwork', parameters: {}, typeVersion: 1, position: [0, 0], credentials: { actionNetworkApi: { id: randomPositiveDigit().toString(), name: 'Action Network Account', }, }, }, ], }); expect(async () => await PermissionChecker.check(workflow, owner.id)).not.toThrow(); }); test('should allow if workflow creds are valid subset', async () => { const [owner, member] = await Promise.all([testDb.createOwner(), testDb.createUser()]); const ownerCred = await saveCredential(randomCred(), { user: owner }); const memberCred = await saveCredential(randomCred(), { user: member }); const workflow = new Workflow({ id: randomPositiveDigit().toString(), name: 'test', active: false, connections: {}, nodeTypes: mockNodeTypes, nodes: [ { id: uuid(), name: 'Action Network', type: 'n8n-nodes-base.actionNetwork', parameters: {}, typeVersion: 1, position: [0, 0], credentials: { actionNetworkApi: { id: ownerCred.id.toString(), name: ownerCred.name, }, }, }, { id: uuid(), name: 'Action Network 2', type: 'n8n-nodes-base.actionNetwork', parameters: {}, typeVersion: 1, position: [0, 0], credentials: { actionNetworkApi: { id: memberCred.id.toString(), name: memberCred.name, }, }, }, ], }); expect(async () => await PermissionChecker.check(workflow, owner.id)).not.toThrow(); }); test('should deny if workflow creds are not valid subset', async () => { const member = await testDb.createUser(); const memberCred = await saveCredential(randomCred(), { user: member }); const workflowDetails = { id: randomPositiveDigit(), name: 'test', active: false, connections: {}, nodeTypes: mockNodeTypes, nodes: [ { id: uuid(), name: 'Action Network', type: 'n8n-nodes-base.actionNetwork', parameters: {}, typeVersion: 1, position: [0, 0] as [number, number], credentials: { actionNetworkApi: { id: memberCred.id.toString(), name: memberCred.name, }, }, }, { id: uuid(), name: 'Action Network 2', type: 'n8n-nodes-base.actionNetwork', parameters: {}, typeVersion: 1, position: [0, 0] as [number, number], credentials: { actionNetworkApi: { id: 'non-existing-credential-id', name: 'Non-existing credential name', }, }, }, ], }; const workflowEntity = await Db.collections.Workflow.save(workflowDetails); await Db.collections.SharedWorkflow.save({ workflow: workflowEntity, user: member, role: workflowOwnerRole, }); const workflow = new Workflow({ ...workflowDetails, id: workflowDetails.id.toString() }); expect(PermissionChecker.check(workflow, member.id)).rejects.toThrow(); }); }); describe('PermissionChecker.checkSubworkflowExecutePolicy', () => { test('sets default policy from environment when subworkflow has none', async () => { const userId = 'abcde'; config.set('workflows.callerPolicyDefaultOption', 'none'); jest.spyOn(UserManagementHelper, 'getWorkflowOwner').mockImplementation(async (workflowId) => { return { id: userId }; }); jest.spyOn(UserManagementHelper, 'isSharingEnabled').mockReturnValue(true); const subworkflow = new Workflow({ nodes: [], connections: {}, active: false, nodeTypes: MockNodeTypes(), id: '2', }); await expect( PermissionChecker.checkSubworkflowExecutePolicy(subworkflow, userId, userId), ).rejects.toThrow(`Target workflow ID ${subworkflow.id} may not be called`); }); test('if sharing is disabled, ensures that workflows are owner by same user', async () => { const userId = 'abcde'; const fakeUser = { id: userId, firstName: 'Test', email: 'email@email.com' }; jest .spyOn(UserManagementHelper, 'getWorkflowOwner') .mockImplementation(async (workflowId) => fakeUser); jest.spyOn(UserManagementHelper, 'isSharingEnabled').mockReturnValue(false); jest.spyOn(UserService, 'get').mockImplementation(async () => fakeUser); jest.spyOn(WorkflowsService, 'getSharing').mockImplementation(async () => { return { role: { name: 'not owner', }, }; }); const subworkflow = new Workflow({ nodes: [], connections: {}, active: false, nodeTypes: MockNodeTypes(), id: '2', settings: { userId: 'bcdef' }, }); await expect( PermissionChecker.checkSubworkflowExecutePolicy(subworkflow, userId, userId), ).rejects.toThrow(`Target workflow ID ${subworkflow.id} may not be called`); // Check description try { await PermissionChecker.checkSubworkflowExecutePolicy( subworkflow, subworkflow.settings.userId as string, 'abcde', ); } catch (error) { if (error instanceof SubworkflowOperationError) { expect(error.description).toBe( `${fakeUser.firstName} (${fakeUser.email}) can make this change. You may need to tell them the ID of this workflow, which is ${subworkflow.id}`, ); } } }); test('list of ids must include the parent workflow id', async () => { const userId = 'abcde'; const fakeUser = { id: userId }; jest .spyOn(UserManagementHelper, 'getWorkflowOwner') .mockImplementation(async (workflowId) => fakeUser); jest.spyOn(UserManagementHelper, 'isSharingEnabled').mockReturnValue(true); jest.spyOn(UserService, 'get').mockImplementation(async () => fakeUser); jest.spyOn(WorkflowsService, 'getSharing').mockImplementation(async () => { return { role: { name: 'not owner', }, }; }); const subworkflow = new Workflow({ nodes: [], connections: {}, active: false, nodeTypes: MockNodeTypes(), id: '2', settings: { userId: 'bcdef', callerPolicy: 'workflowsFromAList', callerIds: '123,456,bcdef ', }, }); await expect( PermissionChecker.checkSubworkflowExecutePolicy(subworkflow, userId, userId), ).rejects.toThrow(`Target workflow ID ${subworkflow.id} may not be called`); }); test('sameOwner passes when both workflows are owned by the same user', async () => { const userId = 'abcde'; const fakeUser = { id: userId }; jest .spyOn(UserManagementHelper, 'getWorkflowOwner') .mockImplementation(async (workflowId) => fakeUser); jest.spyOn(UserManagementHelper, 'isSharingEnabled').mockReturnValue(false); jest.spyOn(UserService, 'get').mockImplementation(async () => fakeUser); jest.spyOn(WorkflowsService, 'getSharing').mockImplementation(async () => { return { role: { name: 'owner', }, }; }); const subworkflow = new Workflow({ nodes: [], connections: {}, active: false, nodeTypes: MockNodeTypes(), id: '2', settings: { userId: userId }, }); expect(PermissionChecker.checkSubworkflowExecutePolicy(subworkflow, userId, userId)).resolves; }); test('workflowsFromAList works when the list contains the parent id', async () => { const userId = 'abcde'; const fakeUser = { id: userId }; jest .spyOn(UserManagementHelper, 'getWorkflowOwner') .mockImplementation(async (workflowId) => fakeUser); jest.spyOn(UserManagementHelper, 'isSharingEnabled').mockReturnValue(true); jest.spyOn(UserService, 'get').mockImplementation(async () => fakeUser); jest.spyOn(WorkflowsService, 'getSharing').mockImplementation(async () => { return { role: { name: 'not owner', }, }; }); const subworkflow = new Workflow({ nodes: [], connections: {}, active: false, nodeTypes: MockNodeTypes(), id: '2', settings: { userId: 'bcdef', callerPolicy: 'workflowsFromAList', callerIds: `123,456,bcdef, ${userId}`, }, }); expect(PermissionChecker.checkSubworkflowExecutePolicy(subworkflow, userId, userId)).resolves; }); }); const MOCK_NODE_TYPES_DATA = ['start', 'actionNetwork'].reduce((acc, nodeName) => { return ( (acc[`n8n-nodes-base.${nodeName}`] = { sourcePath: '', type: { description: { displayName: nodeName, name: nodeName, group: [], description: '', version: 1, defaults: {}, inputs: [], outputs: [], properties: [], }, }, }), acc ); }, {});