n8n/packages/cli/test/unit/PermissionChecker.test.ts

450 lines
13 KiB
TypeScript

import { v4 as uuid } from 'uuid';
import { Container } from 'typedi';
import type { INodeTypes, WorkflowSettings } from 'n8n-workflow';
import { SubworkflowOperationError, Workflow } from 'n8n-workflow';
import config from '@/config';
import { Role } from '@db/entities/Role';
import { User } from '@db/entities/User';
import { SharedWorkflow } from '@db/entities/SharedWorkflow';
import { LoadNodesAndCredentials } from '@/LoadNodesAndCredentials';
import { NodeTypes } from '@/NodeTypes';
import { PermissionChecker } from '@/UserManagement/PermissionChecker';
import * as UserManagementHelper from '@/UserManagement/UserManagementHelper';
import { OwnershipService } from '@/services/ownership.service';
import { mockInstance } from '../shared/mocking';
import {
randomCredentialPayload as randomCred,
randomName,
randomPositiveDigit,
} from '../integration/shared/random';
import * as testDb from '../integration/shared/testDb';
import type { SaveCredentialFunction } from '../integration/shared/types';
import { mockNodeTypesData } from './Helpers';
import { affixRoleToSaveCredential } from '../integration/shared/db/credentials';
import { getCredentialOwnerRole, getWorkflowOwnerRole } from '../integration/shared/db/roles';
import { createOwner, createUser } from '../integration/shared/db/users';
import { WorkflowRepository } from '@db/repositories/workflow.repository';
import { SharedWorkflowRepository } from '@db/repositories/sharedWorkflow.repository';
import { UserRepository } from '@/databases/repositories/user.repository';
import { LicenseMocker } from '../integration/shared/license';
import { License } from '@/License';
import { generateNanoId } from '@/databases/utils/generators';
import type { WorkflowEntity } from '@/databases/entities/WorkflowEntity';
function createSubworkflow({ policy }: { policy: WorkflowSettings.CallerPolicy }) {
return new Workflow({
id: uuid(),
nodes: [],
connections: {},
active: false,
nodeTypes: mockNodeTypes,
settings: {
callerPolicy: policy,
},
});
}
let mockNodeTypes: INodeTypes;
let credentialOwnerRole: Role;
let workflowOwnerRole: Role;
let saveCredential: SaveCredentialFunction;
mockInstance(LoadNodesAndCredentials, {
loadedNodes: mockNodeTypesData(['start', 'actionNetwork']),
});
beforeAll(async () => {
await testDb.init();
mockNodeTypes = Container.get(NodeTypes);
credentialOwnerRole = await getCredentialOwnerRole();
workflowOwnerRole = await getWorkflowOwnerRole();
saveCredential = affixRoleToSaveCredential(credentialOwnerRole);
});
beforeEach(async () => {
await testDb.truncate(['SharedWorkflow', 'SharedCredentials', 'Workflow', 'Credentials', 'User']);
});
afterAll(async () => {
await testDb.terminate();
});
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(async () => PermissionChecker.check(workflow, userId)).not.toThrow();
});
test('should allow if requesting user is instance owner', async () => {
const owner = await 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 () => PermissionChecker.check(workflow, owner.id)).not.toThrow();
});
test('should allow if workflow creds are valid subset', async () => {
const [owner, member] = await Promise.all([createOwner(), 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,
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,
name: memberCred.name,
},
},
},
],
});
expect(async () => PermissionChecker.check(workflow, owner.id)).not.toThrow();
});
test('should deny if workflow creds are not valid subset', async () => {
const member = await createUser();
const memberCred = await saveCredential(randomCred(), { user: member });
const workflowDetails = {
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] as [number, number],
credentials: {
actionNetworkApi: {
id: memberCred.id,
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 Container.get(WorkflowRepository).save(workflowDetails);
await Container.get(SharedWorkflowRepository).save({
workflow: workflowEntity,
user: member,
role: workflowOwnerRole,
});
const workflow = new Workflow(workflowDetails);
await expect(PermissionChecker.check(workflow, member.id)).rejects.toThrow();
});
});
describe('PermissionChecker.checkSubworkflowExecutePolicy', () => {
const userId = uuid();
const fakeUser = new User();
fakeUser.id = userId;
const ownershipService = mockInstance(OwnershipService);
const ownerMockRole = new Role();
ownerMockRole.name = 'owner';
const sharedWorkflowOwner = new SharedWorkflow();
sharedWorkflowOwner.role = ownerMockRole;
const nonOwnerMockRole = new Role();
nonOwnerMockRole.name = 'editor';
const nonOwnerUser = new User();
nonOwnerUser.id = uuid();
let parentWorkflow: WorkflowEntity;
beforeAll(() => {
parentWorkflow = Container.get(WorkflowRepository).create({
id: generateNanoId(),
name: randomName(),
active: false,
connections: {},
nodes: [
{
name: '',
typeVersion: 1,
type: 'n8n-nodes-base.executeWorkflow',
position: [0, 0],
parameters: {},
},
],
});
});
test('sets default policy from environment when subworkflow has none', async () => {
await Container.get(WorkflowRepository).save(parentWorkflow);
config.set('workflows.callerPolicyDefaultOption', 'none');
jest.spyOn(ownershipService, 'getWorkflowOwnerCached').mockResolvedValue(fakeUser);
jest.spyOn(UserManagementHelper, 'isSharingEnabled').mockReturnValue(true);
const subworkflow = new Workflow({
nodes: [],
connections: {},
active: false,
nodeTypes: mockNodeTypes,
id: '2',
});
await expect(
PermissionChecker.checkSubworkflowExecutePolicy(subworkflow, parentWorkflow.id),
).rejects.toThrow(`Target workflow ID ${subworkflow.id} may not be called`);
});
test('if sharing is disabled, ensures that workflows are owned by same user and reject running workflows belonging to another user even if setting allows execution', async () => {
await Container.get(WorkflowRepository).save(parentWorkflow);
jest.spyOn(ownershipService, 'getWorkflowOwnerCached').mockResolvedValueOnce(fakeUser);
jest.spyOn(ownershipService, 'getWorkflowOwnerCached').mockResolvedValueOnce(nonOwnerUser);
jest.spyOn(UserManagementHelper, 'isSharingEnabled').mockReturnValue(false);
const subworkflow = new Workflow({
nodes: [],
connections: {},
active: false,
nodeTypes: mockNodeTypes,
id: '2',
settings: {
callerPolicy: 'any',
},
});
await expect(
PermissionChecker.checkSubworkflowExecutePolicy(subworkflow, parentWorkflow.id),
).rejects.toThrow(`Target workflow ID ${subworkflow.id} may not be called`);
// Check description
try {
await PermissionChecker.checkSubworkflowExecutePolicy(subworkflow, '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('should throw if allowed list does not contain parent workflow id', async () => {
await Container.get(WorkflowRepository).save(parentWorkflow);
jest
.spyOn(ownershipService, 'getWorkflowOwnerCached')
.mockImplementation(async (workflowId) => fakeUser);
jest.spyOn(UserManagementHelper, 'isSharingEnabled').mockReturnValue(true);
const subworkflow = new Workflow({
nodes: [],
connections: {},
active: false,
nodeTypes: mockNodeTypes,
id: '2',
settings: {
callerPolicy: 'workflowsFromAList',
callerIds: '123,456,bcdef ',
},
});
await expect(
PermissionChecker.checkSubworkflowExecutePolicy(subworkflow, parentWorkflow.id),
).rejects.toThrow(`Target workflow ID ${subworkflow.id} may not be called`);
});
test('sameOwner passes when both workflows are owned by the same user', async () => {
await Container.get(WorkflowRepository).save(parentWorkflow);
jest.spyOn(ownershipService, 'getWorkflowOwnerCached').mockImplementation(async () => fakeUser);
jest.spyOn(UserManagementHelper, 'isSharingEnabled').mockReturnValue(false);
const subworkflow = new Workflow({
nodes: [],
connections: {},
active: false,
nodeTypes: mockNodeTypes,
id: '2',
});
await expect(
PermissionChecker.checkSubworkflowExecutePolicy(subworkflow, parentWorkflow.id),
).resolves.not.toThrow();
});
test('workflowsFromAList works when the list contains the parent id', async () => {
await Container.get(WorkflowRepository).save(parentWorkflow);
jest
.spyOn(ownershipService, 'getWorkflowOwnerCached')
.mockImplementation(async (workflowId) => fakeUser);
jest.spyOn(UserManagementHelper, 'isSharingEnabled').mockReturnValue(true);
const subworkflow = new Workflow({
nodes: [],
connections: {},
active: false,
nodeTypes: mockNodeTypes,
id: '2',
settings: {
callerPolicy: 'workflowsFromAList',
callerIds: `123,456,bcdef, ${parentWorkflow.id}`,
},
});
await expect(
PermissionChecker.checkSubworkflowExecutePolicy(subworkflow, parentWorkflow.id),
).resolves.not.toThrow();
});
test('should not throw when workflow policy is set to any', async () => {
await Container.get(WorkflowRepository).save(parentWorkflow);
jest
.spyOn(ownershipService, 'getWorkflowOwnerCached')
.mockImplementation(async (workflowId) => fakeUser);
jest.spyOn(UserManagementHelper, 'isSharingEnabled').mockReturnValue(true);
const subworkflow = new Workflow({
nodes: [],
connections: {},
active: false,
nodeTypes: mockNodeTypes,
id: '2',
settings: {
callerPolicy: 'any',
},
});
await expect(
PermissionChecker.checkSubworkflowExecutePolicy(subworkflow, parentWorkflow.id),
).resolves.not.toThrow();
});
describe('with workflows-from-same-owner caller policy', () => {
beforeAll(() => {
const license = new LicenseMocker();
license.mock(Container.get(License));
license.enable('feat:sharing');
});
test('should deny if the two workflows are owned by different users', async () => {
const parentWorkflowOwner = Container.get(UserRepository).create({ id: uuid() });
const subworkflowOwner = Container.get(UserRepository).create({ id: uuid() });
ownershipService.getWorkflowOwnerCached.mockResolvedValueOnce(parentWorkflowOwner);
ownershipService.getWorkflowOwnerCached.mockResolvedValueOnce(subworkflowOwner);
const subworkflow = createSubworkflow({ policy: 'workflowsFromSameOwner' });
const check = PermissionChecker.checkSubworkflowExecutePolicy(subworkflow, uuid());
await expect(check).rejects.toThrow();
});
test('should allow if both workflows are owned by the same user', async () => {
await Container.get(WorkflowRepository).save(parentWorkflow);
const bothWorkflowsOwner = Container.get(UserRepository).create({ id: uuid() });
ownershipService.getWorkflowOwnerCached.mockResolvedValueOnce(bothWorkflowsOwner); // parent workflow
ownershipService.getWorkflowOwnerCached.mockResolvedValueOnce(bothWorkflowsOwner); // subworkflow
const subworkflow = createSubworkflow({ policy: 'workflowsFromSameOwner' });
const check = PermissionChecker.checkSubworkflowExecutePolicy(subworkflow, parentWorkflow.id);
await expect(check).resolves.not.toThrow();
});
});
});