mirror of
https://github.com/n8n-io/n8n.git
synced 2024-12-25 20:54:07 -08:00
refactor(core): Split out subworkflow policy check from permission checker (no-changelog) (#10168)
This commit is contained in:
parent
7848c19f54
commit
f1a3791abc
|
@ -1,22 +1,17 @@
|
||||||
import { Service } from 'typedi';
|
import { Service } from 'typedi';
|
||||||
import type { INode, Workflow } from 'n8n-workflow';
|
import type { INode } from 'n8n-workflow';
|
||||||
import { CredentialAccessError, NodeOperationError, WorkflowOperationError } from 'n8n-workflow';
|
import { CredentialAccessError, NodeOperationError } from 'n8n-workflow';
|
||||||
|
|
||||||
import config from '@/config';
|
|
||||||
import { License } from '@/License';
|
|
||||||
import { OwnershipService } from '@/services/ownership.service';
|
import { OwnershipService } from '@/services/ownership.service';
|
||||||
import { SharedCredentialsRepository } from '@db/repositories/sharedCredentials.repository';
|
import { SharedCredentialsRepository } from '@db/repositories/sharedCredentials.repository';
|
||||||
import { ProjectService } from '@/services/project.service';
|
import { ProjectService } from '@/services/project.service';
|
||||||
import { Logger } from '@/Logger';
|
|
||||||
|
|
||||||
@Service()
|
@Service()
|
||||||
export class PermissionChecker {
|
export class PermissionChecker {
|
||||||
constructor(
|
constructor(
|
||||||
private readonly sharedCredentialsRepository: SharedCredentialsRepository,
|
private readonly sharedCredentialsRepository: SharedCredentialsRepository,
|
||||||
private readonly ownershipService: OwnershipService,
|
private readonly ownershipService: OwnershipService,
|
||||||
private readonly license: License,
|
|
||||||
private readonly projectService: ProjectService,
|
private readonly projectService: ProjectService,
|
||||||
private readonly logger: Logger,
|
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
@ -50,104 +45,6 @@ export class PermissionChecker {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async checkSubworkflowExecutePolicy(
|
|
||||||
subworkflow: Workflow,
|
|
||||||
parentWorkflowId: string,
|
|
||||||
node?: INode,
|
|
||||||
) {
|
|
||||||
/**
|
|
||||||
* Important considerations: both the current workflow and the parent can have empty IDs.
|
|
||||||
* This happens when a user is executing an unsaved workflow manually running a workflow
|
|
||||||
* loaded from a file or code, for instance.
|
|
||||||
* This is an important topic to keep in mind for all security checks
|
|
||||||
*/
|
|
||||||
if (!subworkflow.id) {
|
|
||||||
// It's a workflow from code and not loaded from DB
|
|
||||||
// No checks are necessary since it doesn't have any sort of settings
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
let policy =
|
|
||||||
subworkflow.settings?.callerPolicy ?? config.getEnv('workflows.callerPolicyDefaultOption');
|
|
||||||
|
|
||||||
const isSharingEnabled = this.license.isSharingEnabled();
|
|
||||||
|
|
||||||
if (!isSharingEnabled) {
|
|
||||||
// Community version allows only same owner workflows
|
|
||||||
policy = 'workflowsFromSameOwner';
|
|
||||||
}
|
|
||||||
|
|
||||||
const parentWorkflowOwner =
|
|
||||||
await this.ownershipService.getWorkflowProjectCached(parentWorkflowId);
|
|
||||||
|
|
||||||
const subworkflowOwner = await this.ownershipService.getWorkflowProjectCached(subworkflow.id);
|
|
||||||
|
|
||||||
const description =
|
|
||||||
subworkflowOwner.id === parentWorkflowOwner.id
|
|
||||||
? 'Change the settings of the sub-workflow so it can be called by this one.'
|
|
||||||
: `An admin for the ${subworkflowOwner.name} project can make this change. You may need to tell them the ID of the sub-workflow, which is ${subworkflow.id}`;
|
|
||||||
|
|
||||||
const errorToThrow = new WorkflowOperationError(
|
|
||||||
`Target workflow ID ${subworkflow.id} may not be called`,
|
|
||||||
node,
|
|
||||||
description,
|
|
||||||
);
|
|
||||||
|
|
||||||
if (policy === 'none') {
|
|
||||||
this.logger.warn('[PermissionChecker] Subworkflow execution denied', {
|
|
||||||
callerWorkflowId: parentWorkflowId,
|
|
||||||
subworkflowId: subworkflow.id,
|
|
||||||
reason: 'Subworkflow may not be called',
|
|
||||||
policy,
|
|
||||||
isSharingEnabled,
|
|
||||||
});
|
|
||||||
throw errorToThrow;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (policy === 'workflowsFromAList') {
|
|
||||||
if (parentWorkflowId === undefined) {
|
|
||||||
this.logger.warn('[PermissionChecker] Subworkflow execution denied', {
|
|
||||||
reason: 'Subworkflow may be called only by workflows from an allowlist',
|
|
||||||
callerWorkflowId: parentWorkflowId,
|
|
||||||
subworkflowId: subworkflow.id,
|
|
||||||
policy,
|
|
||||||
isSharingEnabled,
|
|
||||||
});
|
|
||||||
throw errorToThrow;
|
|
||||||
}
|
|
||||||
|
|
||||||
const allowedCallerIds = subworkflow.settings.callerIds
|
|
||||||
?.split(',')
|
|
||||||
.map((id) => id.trim())
|
|
||||||
.filter((id) => id !== '');
|
|
||||||
|
|
||||||
if (!allowedCallerIds?.includes(parentWorkflowId)) {
|
|
||||||
this.logger.warn('[PermissionChecker] Subworkflow execution denied', {
|
|
||||||
reason: 'Subworkflow may be called only by workflows from an allowlist',
|
|
||||||
callerWorkflowId: parentWorkflowId,
|
|
||||||
subworkflowId: subworkflow.id,
|
|
||||||
allowlist: allowedCallerIds,
|
|
||||||
policy,
|
|
||||||
isSharingEnabled,
|
|
||||||
});
|
|
||||||
throw errorToThrow;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (policy === 'workflowsFromSameOwner' && subworkflowOwner?.id !== parentWorkflowOwner.id) {
|
|
||||||
this.logger.warn('[PermissionChecker] Subworkflow execution denied', {
|
|
||||||
reason: 'Subworkflow may be called only by workflows owned by the same project',
|
|
||||||
callerWorkflowId: parentWorkflowId,
|
|
||||||
subworkflowId: subworkflow.id,
|
|
||||||
callerProjectId: parentWorkflowOwner.id,
|
|
||||||
subworkflowProjectId: subworkflowOwner.id,
|
|
||||||
policy,
|
|
||||||
isSharingEnabled,
|
|
||||||
});
|
|
||||||
throw errorToThrow;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private mapCredIdsToNodes(nodes: INode[]) {
|
private mapCredIdsToNodes(nodes: INode[]) {
|
||||||
return nodes.reduce<{ [credentialId: string]: INode[] }>((map, node) => {
|
return nodes.reduce<{ [credentialId: string]: INode[] }>((map, node) => {
|
||||||
if (node.disabled || !node.credentials) return map;
|
if (node.disabled || !node.credentials) return map;
|
||||||
|
|
|
@ -73,6 +73,7 @@ import { WorkflowExecutionService } from './workflows/workflowExecution.service'
|
||||||
import { MessageEventBus } from '@/eventbus/MessageEventBus/MessageEventBus';
|
import { MessageEventBus } from '@/eventbus/MessageEventBus/MessageEventBus';
|
||||||
import { EventService } from './eventbus/event.service';
|
import { EventService } from './eventbus/event.service';
|
||||||
import { GlobalConfig } from '@n8n/config';
|
import { GlobalConfig } from '@n8n/config';
|
||||||
|
import { SubworkflowPolicyChecker } from './subworkflows/subworkflow-policy-checker.service';
|
||||||
|
|
||||||
export function objectToError(errorObject: unknown, workflow: Workflow): Error {
|
export function objectToError(errorObject: unknown, workflow: Workflow): Error {
|
||||||
// TODO: Expand with other error types
|
// TODO: Expand with other error types
|
||||||
|
@ -826,7 +827,7 @@ async function executeWorkflow(
|
||||||
let data;
|
let data;
|
||||||
try {
|
try {
|
||||||
await Container.get(PermissionChecker).check(workflowData.id, workflowData.nodes);
|
await Container.get(PermissionChecker).check(workflowData.id, workflowData.nodes);
|
||||||
await Container.get(PermissionChecker).checkSubworkflowExecutePolicy(
|
await Container.get(SubworkflowPolicyChecker).check(
|
||||||
workflow,
|
workflow,
|
||||||
options.parentWorkflowId,
|
options.parentWorkflowId,
|
||||||
options.node,
|
options.node,
|
||||||
|
|
|
@ -0,0 +1,161 @@
|
||||||
|
import { v4 as uuid } from 'uuid';
|
||||||
|
import type { Workflow } from 'n8n-workflow';
|
||||||
|
import { SubworkflowOperationError } from 'n8n-workflow';
|
||||||
|
import { Project } from '@/databases/entities/Project';
|
||||||
|
import { OwnershipService } from '@/services/ownership.service';
|
||||||
|
import { mockInstance } from '@test/mocking';
|
||||||
|
import config from '@/config';
|
||||||
|
import { mock } from 'jest-mock-extended';
|
||||||
|
import { SubworkflowPolicyChecker } from '../subworkflow-policy-checker.service';
|
||||||
|
|
||||||
|
import type { WorkflowEntity } from '@/databases/entities/WorkflowEntity';
|
||||||
|
import type { License } from '@/License';
|
||||||
|
|
||||||
|
const toTargetCallErrorMsg = (subworkflowId: string) =>
|
||||||
|
`Target workflow ID ${subworkflowId} may not be called`;
|
||||||
|
|
||||||
|
const ownershipService = mockInstance(OwnershipService);
|
||||||
|
const memberPersonalProject = mock<Project>();
|
||||||
|
|
||||||
|
describe('SubworkflowPolicyChecker', () => {
|
||||||
|
const license = mock<License>();
|
||||||
|
const checker = new SubworkflowPolicyChecker(mock(), license, ownershipService);
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
license.isSharingEnabled.mockReturnValue(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
jest.restoreAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('no caller policy', () => {
|
||||||
|
test('should fall back to N8N_WORKFLOW_CALLER_POLICY_DEFAULT_OPTION', async () => {
|
||||||
|
config.set('workflows.callerPolicyDefaultOption', 'none');
|
||||||
|
|
||||||
|
const parentWorkflow = mock<WorkflowEntity>();
|
||||||
|
const subworkflow = mock<Workflow>(); // no caller policy
|
||||||
|
|
||||||
|
ownershipService.getWorkflowProjectCached.mockResolvedValue(memberPersonalProject);
|
||||||
|
|
||||||
|
const check = checker.check(subworkflow, parentWorkflow.id);
|
||||||
|
|
||||||
|
await expect(check).rejects.toThrow(toTargetCallErrorMsg(subworkflow.id));
|
||||||
|
|
||||||
|
config.load(config.default);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('overridden caller policy', () => {
|
||||||
|
test('if no sharing, should override policy to workflows-from-same-owner', async () => {
|
||||||
|
license.isSharingEnabled.mockReturnValue(false);
|
||||||
|
|
||||||
|
const parentWorkflow = mock<WorkflowEntity>();
|
||||||
|
const subworkflow = mock<Workflow>({ settings: { callerPolicy: 'any' } }); // should be overridden
|
||||||
|
|
||||||
|
const firstProject = mock<Project>({ id: uuid() });
|
||||||
|
const secondProject = mock<Project>({ id: uuid() });
|
||||||
|
|
||||||
|
ownershipService.getWorkflowProjectCached.mockResolvedValueOnce(firstProject); // parent workflow
|
||||||
|
ownershipService.getWorkflowProjectCached.mockResolvedValueOnce(secondProject); // subworkflow
|
||||||
|
|
||||||
|
const check = checker.check(subworkflow, parentWorkflow.id);
|
||||||
|
|
||||||
|
await expect(check).rejects.toThrow(toTargetCallErrorMsg(subworkflow.id));
|
||||||
|
|
||||||
|
try {
|
||||||
|
await checker.check(subworkflow, uuid());
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof SubworkflowOperationError) {
|
||||||
|
expect(error.description).toBe(
|
||||||
|
`An admin for the ${firstProject.name} project can make this change. You may need to tell them the ID of the sub-workflow, which is ${subworkflow.id}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('workflows-from-list caller policy', () => {
|
||||||
|
// xyz
|
||||||
|
test('should allow if caller list contains parent workflow ID', async () => {
|
||||||
|
const parentWorkflow = mock<WorkflowEntity>({ id: uuid() });
|
||||||
|
|
||||||
|
const subworkflow = mock<Workflow>({
|
||||||
|
settings: {
|
||||||
|
callerPolicy: 'workflowsFromAList',
|
||||||
|
callerIds: `123,456,bcdef, ${parentWorkflow.id}`,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const parentWorkflowProject = mock<Project>({ id: uuid() });
|
||||||
|
const subworkflowOwner = mock<Project>({ id: uuid() });
|
||||||
|
ownershipService.getWorkflowProjectCached.mockResolvedValueOnce(parentWorkflowProject); // parent workflow
|
||||||
|
ownershipService.getWorkflowProjectCached.mockResolvedValueOnce(subworkflowOwner); // subworkflow
|
||||||
|
|
||||||
|
const check = checker.check(subworkflow, parentWorkflow.id);
|
||||||
|
|
||||||
|
await expect(check).resolves.not.toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should deny if caller list does not contain parent workflow ID', async () => {
|
||||||
|
const parentWorkflow = mock<WorkflowEntity>();
|
||||||
|
|
||||||
|
const subworkflow = mock<Workflow>({
|
||||||
|
settings: { callerPolicy: 'workflowsFromAList', callerIds: 'xyz' },
|
||||||
|
});
|
||||||
|
|
||||||
|
const check = checker.check(subworkflow, parentWorkflow.id);
|
||||||
|
|
||||||
|
await expect(check).rejects.toThrow();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('any caller policy', () => {
|
||||||
|
test('should not throw', async () => {
|
||||||
|
const parentWorkflow = mock<WorkflowEntity>({ id: uuid() });
|
||||||
|
const subworkflow = mock<Workflow>({ settings: { callerPolicy: 'any' } });
|
||||||
|
|
||||||
|
const parentWorkflowProject = mock<Project>({ id: uuid() });
|
||||||
|
const subworkflowOwner = mock<Project>({ id: uuid() });
|
||||||
|
ownershipService.getWorkflowProjectCached.mockResolvedValueOnce(parentWorkflowProject); // parent workflow
|
||||||
|
ownershipService.getWorkflowProjectCached.mockResolvedValueOnce(subworkflowOwner); // subworkflow
|
||||||
|
|
||||||
|
ownershipService.getWorkflowProjectCached.mockResolvedValueOnce(new Project());
|
||||||
|
|
||||||
|
const check = checker.check(subworkflow, parentWorkflow.id);
|
||||||
|
|
||||||
|
await expect(check).resolves.not.toThrow();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('workflows-from-same-owner caller policy', () => {
|
||||||
|
test('should deny if the two workflows are owned by different users', async () => {
|
||||||
|
const parentWorkflowProject = mock<Project>({ id: uuid() });
|
||||||
|
const subworkflowOwner = mock<Project>({ id: uuid() });
|
||||||
|
|
||||||
|
ownershipService.getWorkflowProjectCached.mockResolvedValueOnce(parentWorkflowProject); // parent workflow
|
||||||
|
ownershipService.getWorkflowProjectCached.mockResolvedValueOnce(subworkflowOwner); // subworkflow
|
||||||
|
|
||||||
|
const subworkflow = mock<Workflow>({ settings: { callerPolicy: 'workflowsFromSameOwner' } });
|
||||||
|
|
||||||
|
const check = checker.check(subworkflow, uuid());
|
||||||
|
|
||||||
|
await expect(check).rejects.toThrow(toTargetCallErrorMsg(subworkflow.id));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should allow if both workflows are owned by the same user', async () => {
|
||||||
|
const parentWorkflow = mock<WorkflowEntity>();
|
||||||
|
|
||||||
|
const bothWorkflowsProject = mock<Project>({ id: uuid() });
|
||||||
|
|
||||||
|
ownershipService.getWorkflowProjectCached.mockResolvedValueOnce(bothWorkflowsProject); // parent workflow
|
||||||
|
ownershipService.getWorkflowProjectCached.mockResolvedValueOnce(bothWorkflowsProject); // subworkflow
|
||||||
|
|
||||||
|
const subworkflow = mock<Workflow>({ settings: { callerPolicy: 'workflowsFromSameOwner' } });
|
||||||
|
|
||||||
|
const check = checker.check(subworkflow, parentWorkflow.id);
|
||||||
|
|
||||||
|
await expect(check).resolves.not.toThrow();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
|
@ -0,0 +1,110 @@
|
||||||
|
import { Service } from 'typedi';
|
||||||
|
import { WorkflowOperationError } from 'n8n-workflow';
|
||||||
|
import config from '@/config';
|
||||||
|
import { Logger } from '@/Logger';
|
||||||
|
import { License } from '@/License';
|
||||||
|
import { OwnershipService } from '@/services/ownership.service';
|
||||||
|
import type { Workflow, INode } from 'n8n-workflow';
|
||||||
|
|
||||||
|
@Service()
|
||||||
|
export class SubworkflowPolicyChecker {
|
||||||
|
constructor(
|
||||||
|
private readonly logger: Logger,
|
||||||
|
private readonly license: License,
|
||||||
|
private readonly ownershipService: OwnershipService,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
async check(subworkflow: Workflow, parentWorkflowId: string, node?: INode) {
|
||||||
|
/**
|
||||||
|
* Important considerations: both the current workflow and the parent can have empty IDs.
|
||||||
|
* This happens when a user is executing an unsaved workflow manually running a workflow
|
||||||
|
* loaded from a file or code, for instance.
|
||||||
|
* This is an important topic to keep in mind for all security checks
|
||||||
|
*/
|
||||||
|
if (!subworkflow.id) {
|
||||||
|
// It's a workflow from code and not loaded from DB
|
||||||
|
// No checks are necessary since it doesn't have any sort of settings
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let policy =
|
||||||
|
subworkflow.settings?.callerPolicy ?? config.getEnv('workflows.callerPolicyDefaultOption');
|
||||||
|
|
||||||
|
const isSharingEnabled = this.license.isSharingEnabled();
|
||||||
|
|
||||||
|
if (!isSharingEnabled) {
|
||||||
|
// Community version allows only same owner workflows
|
||||||
|
policy = 'workflowsFromSameOwner';
|
||||||
|
}
|
||||||
|
|
||||||
|
const parentWorkflowOwner =
|
||||||
|
await this.ownershipService.getWorkflowProjectCached(parentWorkflowId);
|
||||||
|
|
||||||
|
const subworkflowOwner = await this.ownershipService.getWorkflowProjectCached(subworkflow.id);
|
||||||
|
|
||||||
|
const description =
|
||||||
|
subworkflowOwner.id === parentWorkflowOwner.id
|
||||||
|
? 'Change the settings of the sub-workflow so it can be called by this one.'
|
||||||
|
: `An admin for the ${subworkflowOwner.name} project can make this change. You may need to tell them the ID of the sub-workflow, which is ${subworkflow.id}`;
|
||||||
|
|
||||||
|
const errorToThrow = new WorkflowOperationError(
|
||||||
|
`Target workflow ID ${subworkflow.id} may not be called`,
|
||||||
|
node,
|
||||||
|
description,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (policy === 'none') {
|
||||||
|
this.logger.warn('[PermissionChecker] Subworkflow execution denied', {
|
||||||
|
callerWorkflowId: parentWorkflowId,
|
||||||
|
subworkflowId: subworkflow.id,
|
||||||
|
reason: 'Subworkflow may not be called',
|
||||||
|
policy,
|
||||||
|
isSharingEnabled,
|
||||||
|
});
|
||||||
|
throw errorToThrow;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (policy === 'workflowsFromAList') {
|
||||||
|
if (parentWorkflowId === undefined) {
|
||||||
|
this.logger.warn('[PermissionChecker] Subworkflow execution denied', {
|
||||||
|
reason: 'Subworkflow may be called only by workflows from an allowlist',
|
||||||
|
callerWorkflowId: parentWorkflowId,
|
||||||
|
subworkflowId: subworkflow.id,
|
||||||
|
policy,
|
||||||
|
isSharingEnabled,
|
||||||
|
});
|
||||||
|
throw errorToThrow;
|
||||||
|
}
|
||||||
|
|
||||||
|
const allowedCallerIds = subworkflow.settings.callerIds
|
||||||
|
?.split(',')
|
||||||
|
.map((id) => id.trim())
|
||||||
|
.filter((id) => id !== '');
|
||||||
|
|
||||||
|
if (!allowedCallerIds?.includes(parentWorkflowId)) {
|
||||||
|
this.logger.warn('[PermissionChecker] Subworkflow execution denied', {
|
||||||
|
reason: 'Subworkflow may be called only by workflows from an allowlist',
|
||||||
|
callerWorkflowId: parentWorkflowId,
|
||||||
|
subworkflowId: subworkflow.id,
|
||||||
|
allowlist: allowedCallerIds,
|
||||||
|
policy,
|
||||||
|
isSharingEnabled,
|
||||||
|
});
|
||||||
|
throw errorToThrow;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (policy === 'workflowsFromSameOwner' && subworkflowOwner?.id !== parentWorkflowOwner.id) {
|
||||||
|
this.logger.warn('[PermissionChecker] Subworkflow execution denied', {
|
||||||
|
reason: 'Subworkflow may be called only by workflows owned by the same project',
|
||||||
|
callerWorkflowId: parentWorkflowId,
|
||||||
|
subworkflowId: subworkflow.id,
|
||||||
|
callerProjectId: parentWorkflowOwner.id,
|
||||||
|
subworkflowProjectId: subworkflowOwner.id,
|
||||||
|
policy,
|
||||||
|
isSharingEnabled,
|
||||||
|
});
|
||||||
|
throw errorToThrow;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -32,9 +32,9 @@ import { WorkflowRunner } from '@/WorkflowRunner';
|
||||||
import * as WorkflowExecuteAdditionalData from '@/WorkflowExecuteAdditionalData';
|
import * as WorkflowExecuteAdditionalData from '@/WorkflowExecuteAdditionalData';
|
||||||
import { TestWebhooks } from '@/TestWebhooks';
|
import { TestWebhooks } from '@/TestWebhooks';
|
||||||
import { Logger } from '@/Logger';
|
import { Logger } from '@/Logger';
|
||||||
import { PermissionChecker } from '@/UserManagement/PermissionChecker';
|
|
||||||
import type { Project } from '@/databases/entities/Project';
|
import type { Project } from '@/databases/entities/Project';
|
||||||
import { GlobalConfig } from '@n8n/config';
|
import { GlobalConfig } from '@n8n/config';
|
||||||
|
import { SubworkflowPolicyChecker } from '@/subworkflows/subworkflow-policy-checker.service';
|
||||||
|
|
||||||
@Service()
|
@Service()
|
||||||
export class WorkflowExecutionService {
|
export class WorkflowExecutionService {
|
||||||
|
@ -44,9 +44,9 @@ export class WorkflowExecutionService {
|
||||||
private readonly workflowRepository: WorkflowRepository,
|
private readonly workflowRepository: WorkflowRepository,
|
||||||
private readonly nodeTypes: NodeTypes,
|
private readonly nodeTypes: NodeTypes,
|
||||||
private readonly testWebhooks: TestWebhooks,
|
private readonly testWebhooks: TestWebhooks,
|
||||||
private readonly permissionChecker: PermissionChecker,
|
|
||||||
private readonly workflowRunner: WorkflowRunner,
|
private readonly workflowRunner: WorkflowRunner,
|
||||||
private readonly globalConfig: GlobalConfig,
|
private readonly globalConfig: GlobalConfig,
|
||||||
|
private readonly subworkflowPolicyChecker: SubworkflowPolicyChecker,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
async runWorkflow(
|
async runWorkflow(
|
||||||
|
@ -188,7 +188,7 @@ export class WorkflowExecutionService {
|
||||||
const failedNode = workflowErrorData.execution?.lastNodeExecuted
|
const failedNode = workflowErrorData.execution?.lastNodeExecuted
|
||||||
? workflowInstance.getNode(workflowErrorData.execution?.lastNodeExecuted)
|
? workflowInstance.getNode(workflowErrorData.execution?.lastNodeExecuted)
|
||||||
: undefined;
|
: undefined;
|
||||||
await this.permissionChecker.checkSubworkflowExecutePolicy(
|
await this.subworkflowPolicyChecker.check(
|
||||||
workflowInstance,
|
workflowInstance,
|
||||||
workflowErrorData.workflow.id!,
|
workflowErrorData.workflow.id!,
|
||||||
failedNode ?? undefined,
|
failedNode ?? undefined,
|
||||||
|
|
|
@ -1,22 +1,17 @@
|
||||||
import { v4 as uuid } from 'uuid';
|
import { v4 as uuid } from 'uuid';
|
||||||
import { Container } from 'typedi';
|
import { Container } from 'typedi';
|
||||||
import type { INode, WorkflowSettings } from 'n8n-workflow';
|
import type { INode } from 'n8n-workflow';
|
||||||
import { SubworkflowOperationError, Workflow, randomInt } from 'n8n-workflow';
|
import { randomInt } from 'n8n-workflow';
|
||||||
|
|
||||||
import config from '@/config';
|
|
||||||
import type { User } from '@db/entities/User';
|
import type { User } from '@db/entities/User';
|
||||||
import { WorkflowRepository } from '@db/repositories/workflow.repository';
|
import { WorkflowRepository } from '@db/repositories/workflow.repository';
|
||||||
import { SharedWorkflowRepository } from '@db/repositories/sharedWorkflow.repository';
|
import { SharedWorkflowRepository } from '@db/repositories/sharedWorkflow.repository';
|
||||||
import { generateNanoId } from '@/databases/utils/generators';
|
|
||||||
import { License } from '@/License';
|
|
||||||
import { LoadNodesAndCredentials } from '@/LoadNodesAndCredentials';
|
import { LoadNodesAndCredentials } from '@/LoadNodesAndCredentials';
|
||||||
import { NodeTypes } from '@/NodeTypes';
|
import { NodeTypes } from '@/NodeTypes';
|
||||||
import { OwnershipService } from '@/services/ownership.service';
|
import { OwnershipService } from '@/services/ownership.service';
|
||||||
import { PermissionChecker } from '@/UserManagement/PermissionChecker';
|
import { PermissionChecker } from '@/UserManagement/PermissionChecker';
|
||||||
|
|
||||||
import { mockInstance } from '../shared/mocking';
|
import { mockInstance } from '../shared/mocking';
|
||||||
import { randomCredentialPayload as randomCred, randomName } from '../integration/shared/random';
|
import { randomCredentialPayload as randomCred } from '../integration/shared/random';
|
||||||
import { LicenseMocker } from '../integration/shared/license';
|
|
||||||
import * as testDb from '../integration/shared/testDb';
|
import * as testDb from '../integration/shared/testDb';
|
||||||
import type { SaveCredentialFunction } from '../integration/shared/types';
|
import type { SaveCredentialFunction } from '../integration/shared/types';
|
||||||
import { mockNodeTypesData } from '../unit/Helpers';
|
import { mockNodeTypesData } from '../unit/Helpers';
|
||||||
|
@ -25,50 +20,9 @@ import { createOwner, createUser } from '../integration/shared/db/users';
|
||||||
import { SharedCredentialsRepository } from '@/databases/repositories/sharedCredentials.repository';
|
import { SharedCredentialsRepository } from '@/databases/repositories/sharedCredentials.repository';
|
||||||
import { getPersonalProject } from './shared/db/projects';
|
import { getPersonalProject } from './shared/db/projects';
|
||||||
import type { WorkflowEntity } from '@/databases/entities/WorkflowEntity';
|
import type { WorkflowEntity } from '@/databases/entities/WorkflowEntity';
|
||||||
import { Project } from '@/databases/entities/Project';
|
import type { Project } from '@/databases/entities/Project';
|
||||||
import { ProjectRepository } from '@/databases/repositories/project.repository';
|
import { ProjectRepository } from '@/databases/repositories/project.repository';
|
||||||
|
|
||||||
export const toTargetCallErrorMsg = (subworkflowId: string) =>
|
|
||||||
`Target workflow ID ${subworkflowId} may not be called`;
|
|
||||||
|
|
||||||
export function createParentWorkflow() {
|
|
||||||
return Container.get(WorkflowRepository).create({
|
|
||||||
id: generateNanoId(),
|
|
||||||
name: randomName(),
|
|
||||||
active: false,
|
|
||||||
connections: {},
|
|
||||||
nodes: [
|
|
||||||
{
|
|
||||||
name: '',
|
|
||||||
typeVersion: 1,
|
|
||||||
type: 'n8n-nodes-base.executeWorkflow',
|
|
||||||
position: [0, 0],
|
|
||||||
parameters: {},
|
|
||||||
},
|
|
||||||
],
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export function createSubworkflow({
|
|
||||||
policy,
|
|
||||||
callerIds,
|
|
||||||
}: {
|
|
||||||
policy?: WorkflowSettings.CallerPolicy;
|
|
||||||
callerIds?: string;
|
|
||||||
} = {}) {
|
|
||||||
return new Workflow({
|
|
||||||
id: uuid(),
|
|
||||||
nodes: [],
|
|
||||||
connections: {},
|
|
||||||
active: false,
|
|
||||||
nodeTypes: mockNodeTypes,
|
|
||||||
settings: {
|
|
||||||
...(policy ? { callerPolicy: policy } : {}),
|
|
||||||
...(callerIds ? { callerIds } : {}),
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
const ownershipService = mockInstance(OwnershipService);
|
const ownershipService = mockInstance(OwnershipService);
|
||||||
|
|
||||||
const createWorkflow = async (nodes: INode[], workflowOwner?: User): Promise<WorkflowEntity> => {
|
const createWorkflow = async (nodes: INode[], workflowOwner?: User): Promise<WorkflowEntity> => {
|
||||||
|
@ -289,132 +243,3 @@ describe('check()', () => {
|
||||||
).resolves.not.toThrow();
|
).resolves.not.toThrow();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('checkSubworkflowExecutePolicy()', () => {
|
|
||||||
let license: LicenseMocker;
|
|
||||||
|
|
||||||
beforeAll(() => {
|
|
||||||
license = new LicenseMocker();
|
|
||||||
license.mock(Container.get(License));
|
|
||||||
license.enable('feat:sharing');
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('no caller policy', () => {
|
|
||||||
test('should fall back to N8N_WORKFLOW_CALLER_POLICY_DEFAULT_OPTION', async () => {
|
|
||||||
config.set('workflows.callerPolicyDefaultOption', 'none');
|
|
||||||
|
|
||||||
const parentWorkflow = createParentWorkflow();
|
|
||||||
const subworkflow = createSubworkflow(); // no caller policy
|
|
||||||
|
|
||||||
ownershipService.getWorkflowProjectCached.mockResolvedValue(memberPersonalProject);
|
|
||||||
|
|
||||||
const check = permissionChecker.checkSubworkflowExecutePolicy(subworkflow, parentWorkflow.id);
|
|
||||||
|
|
||||||
await expect(check).rejects.toThrow(toTargetCallErrorMsg(subworkflow.id));
|
|
||||||
|
|
||||||
config.load(config.default);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('overridden caller policy', () => {
|
|
||||||
test('if no sharing, should override policy to workflows-from-same-owner', async () => {
|
|
||||||
license.disable('feat:sharing');
|
|
||||||
|
|
||||||
const parentWorkflow = createParentWorkflow();
|
|
||||||
const subworkflow = createSubworkflow({ policy: 'any' }); // should be overridden
|
|
||||||
|
|
||||||
const firstProject = Container.get(ProjectRepository).create({ id: uuid() });
|
|
||||||
const secondProject = Container.get(ProjectRepository).create({ id: uuid() });
|
|
||||||
|
|
||||||
ownershipService.getWorkflowProjectCached.mockResolvedValueOnce(firstProject); // parent workflow
|
|
||||||
ownershipService.getWorkflowProjectCached.mockResolvedValueOnce(secondProject); // subworkflow
|
|
||||||
|
|
||||||
const check = permissionChecker.checkSubworkflowExecutePolicy(subworkflow, parentWorkflow.id);
|
|
||||||
|
|
||||||
await expect(check).rejects.toThrow(toTargetCallErrorMsg(subworkflow.id));
|
|
||||||
|
|
||||||
try {
|
|
||||||
await permissionChecker.checkSubworkflowExecutePolicy(subworkflow, uuid());
|
|
||||||
} catch (error) {
|
|
||||||
if (error instanceof SubworkflowOperationError) {
|
|
||||||
expect(error.description).toBe(
|
|
||||||
`An admin for the ${firstProject.name} project can make this change. You may need to tell them the ID of the sub-workflow, which is ${subworkflow.id}`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
license.enable('feat:sharing');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('workflows-from-list caller policy', () => {
|
|
||||||
test('should allow if caller list contains parent workflow ID', async () => {
|
|
||||||
const parentWorkflow = createParentWorkflow();
|
|
||||||
|
|
||||||
const subworkflow = createSubworkflow({
|
|
||||||
policy: 'workflowsFromAList',
|
|
||||||
callerIds: `123,456,bcdef, ${parentWorkflow.id}`,
|
|
||||||
});
|
|
||||||
|
|
||||||
const check = permissionChecker.checkSubworkflowExecutePolicy(subworkflow, parentWorkflow.id);
|
|
||||||
|
|
||||||
await expect(check).resolves.not.toThrow();
|
|
||||||
});
|
|
||||||
|
|
||||||
test('should deny if caller list does not contain parent workflow ID', async () => {
|
|
||||||
const parentWorkflow = createParentWorkflow();
|
|
||||||
|
|
||||||
const subworkflow = createSubworkflow({
|
|
||||||
policy: 'workflowsFromAList',
|
|
||||||
callerIds: 'xyz',
|
|
||||||
});
|
|
||||||
|
|
||||||
const check = permissionChecker.checkSubworkflowExecutePolicy(subworkflow, parentWorkflow.id);
|
|
||||||
|
|
||||||
await expect(check).rejects.toThrow();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('any caller policy', () => {
|
|
||||||
test('should not throw', async () => {
|
|
||||||
const parentWorkflow = createParentWorkflow();
|
|
||||||
const subworkflow = createSubworkflow({ policy: 'any' });
|
|
||||||
ownershipService.getWorkflowProjectCached.mockResolvedValueOnce(new Project());
|
|
||||||
|
|
||||||
const check = permissionChecker.checkSubworkflowExecutePolicy(subworkflow, parentWorkflow.id);
|
|
||||||
|
|
||||||
await expect(check).resolves.not.toThrow();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('workflows-from-same-owner caller policy', () => {
|
|
||||||
test('should deny if the two workflows are owned by different users', async () => {
|
|
||||||
const parentWorkflowProject = Container.get(ProjectRepository).create({ id: uuid() });
|
|
||||||
const subworkflowOwner = Container.get(ProjectRepository).create({ id: uuid() });
|
|
||||||
|
|
||||||
ownershipService.getWorkflowProjectCached.mockResolvedValueOnce(parentWorkflowProject); // parent workflow
|
|
||||||
ownershipService.getWorkflowProjectCached.mockResolvedValueOnce(subworkflowOwner); // subworkflow
|
|
||||||
|
|
||||||
const subworkflow = createSubworkflow({ policy: 'workflowsFromSameOwner' });
|
|
||||||
|
|
||||||
const check = permissionChecker.checkSubworkflowExecutePolicy(subworkflow, uuid());
|
|
||||||
|
|
||||||
await expect(check).rejects.toThrow(toTargetCallErrorMsg(subworkflow.id));
|
|
||||||
});
|
|
||||||
|
|
||||||
test('should allow if both workflows are owned by the same user', async () => {
|
|
||||||
const parentWorkflow = createParentWorkflow();
|
|
||||||
|
|
||||||
const bothWorkflowsProject = Container.get(ProjectRepository).create({ id: uuid() });
|
|
||||||
|
|
||||||
ownershipService.getWorkflowProjectCached.mockResolvedValueOnce(bothWorkflowsProject); // parent workflow
|
|
||||||
ownershipService.getWorkflowProjectCached.mockResolvedValueOnce(bothWorkflowsProject); // subworkflow
|
|
||||||
|
|
||||||
const subworkflow = createSubworkflow({ policy: 'workflowsFromSameOwner' });
|
|
||||||
|
|
||||||
const check = permissionChecker.checkSubworkflowExecutePolicy(subworkflow, parentWorkflow.id);
|
|
||||||
|
|
||||||
await expect(check).resolves.not.toThrow();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
|
@ -57,8 +57,9 @@ describe('WorkflowExecutionService', () => {
|
||||||
mock(),
|
mock(),
|
||||||
mock(),
|
mock(),
|
||||||
mock(),
|
mock(),
|
||||||
mock(),
|
|
||||||
workflowRunner,
|
workflowRunner,
|
||||||
|
mock(),
|
||||||
|
mock(),
|
||||||
);
|
);
|
||||||
|
|
||||||
describe('runWorkflow()', () => {
|
describe('runWorkflow()', () => {
|
||||||
|
|
Loading…
Reference in a new issue