refactor(core): Revamp subworkflow policy checker errors (#10752)

This commit is contained in:
Iván Ovejero 2024-09-12 09:11:37 +02:00 committed by GitHub
parent db61bde9da
commit 8c6cd014a0
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
12 changed files with 335 additions and 132 deletions

View file

@ -7,9 +7,9 @@ import type { ICollaboratorsChanged } from '@/interfaces';
import type { OnPushMessage } from '@/push/types'; import type { OnPushMessage } from '@/push/types';
import { UserRepository } from '@/databases/repositories/user.repository'; import { UserRepository } from '@/databases/repositories/user.repository';
import type { User } from '@/databases/entities/user'; import type { User } from '@/databases/entities/user';
import { SharedWorkflowRepository } from '@/databases/repositories/shared-workflow.repository'; import { CollaborationState } from '@/collaboration/collaboration.state';
import { AccessService } from '@/services/access.service';
import { CollaborationState } from './collaboration.state';
import type { WorkflowClosedMessage, WorkflowOpenedMessage } from './collaboration.message'; import type { WorkflowClosedMessage, WorkflowOpenedMessage } from './collaboration.message';
import { parseWorkflowMessage } from './collaboration.message'; import { parseWorkflowMessage } from './collaboration.message';
@ -23,7 +23,7 @@ export class CollaborationService {
private readonly push: Push, private readonly push: Push,
private readonly state: CollaborationState, private readonly state: CollaborationState,
private readonly userRepository: UserRepository, private readonly userRepository: UserRepository,
private readonly sharedWorkflowRepository: SharedWorkflowRepository, private readonly accessService: AccessService,
) {} ) {}
init() { init() {
@ -57,7 +57,7 @@ export class CollaborationService {
private async handleWorkflowOpened(userId: User['id'], msg: WorkflowOpenedMessage) { private async handleWorkflowOpened(userId: User['id'], msg: WorkflowOpenedMessage) {
const { workflowId } = msg; const { workflowId } = msg;
if (!(await this.hasUserAccessToWorkflow(userId, workflowId))) { if (!(await this.accessService.hasReadAccess(userId, workflowId))) {
return; return;
} }
@ -69,7 +69,7 @@ export class CollaborationService {
private async handleWorkflowClosed(userId: User['id'], msg: WorkflowClosedMessage) { private async handleWorkflowClosed(userId: User['id'], msg: WorkflowClosedMessage) {
const { workflowId } = msg; const { workflowId } = msg;
if (!(await this.hasUserAccessToWorkflow(userId, workflowId))) { if (!(await this.accessService.hasReadAccess(userId, workflowId))) {
return; return;
} }
@ -99,19 +99,4 @@ export class CollaborationService {
this.push.sendToUsers('collaboratorsChanged', msgData, userIds); this.push.sendToUsers('collaboratorsChanged', msgData, userIds);
} }
private async hasUserAccessToWorkflow(userId: User['id'], workflowId: Workflow['id']) {
const user = await this.userRepository.findOneBy({
id: userId,
});
if (!user) {
return false;
}
const workflow = await this.sharedWorkflowRepository.findWorkflowForUser(workflowId, user, [
'workflow:read',
]);
return !!workflow;
}
} }

View file

@ -2,24 +2,66 @@ import { WorkflowOperationError } from 'n8n-workflow';
import type { Project } from '@/databases/entities/project'; import type { Project } from '@/databases/entities/project';
import type { INode } from 'n8n-workflow'; import type { INode } from 'n8n-workflow';
type SubworkflowPolicyDenialErrorParams = { type Options = {
/** ID of the subworkflow whose execution was denied. */
subworkflowId: string; subworkflowId: string;
/** Project that owns the subworkflow whose execution was denied. */
subworkflowProject: Project; subworkflowProject: Project;
areOwnedBySameProject?: boolean;
/** Whether the user has read access to the subworkflow based on their project and scope. */
hasReadAccess: boolean;
/** URL of the n8n instance. */
instanceUrl: string;
/** Full name of the user who owns the personal project that owns the subworkflow. Absent if team project. */
ownerName?: string;
/** Node that triggered the execution of the subworkflow whose execution was denied. */
node?: INode; node?: INode;
}; };
export const SUBWORKFLOW_DENIAL_BASE_DESCRIPTION =
'The sub-workflow youre trying to execute limits which workflows it can be called by.';
export class SubworkflowPolicyDenialError extends WorkflowOperationError { export class SubworkflowPolicyDenialError extends WorkflowOperationError {
constructor({ constructor({
subworkflowId, subworkflowId,
subworkflowProject, subworkflowProject,
areOwnedBySameProject, instanceUrl,
hasReadAccess,
ownerName,
node, node,
}: SubworkflowPolicyDenialErrorParams) { }: Options) {
const description = areOwnedBySameProject const descriptions = {
? 'Change the settings of the sub-workflow so it can be called by this one.' default: SUBWORKFLOW_DENIAL_BASE_DESCRIPTION,
: `An admin for the ${subworkflowProject.name} project can make this change. You may need to tell them the ID of the sub-workflow, which is ${subworkflowId}`; accessible: [
SUBWORKFLOW_DENIAL_BASE_DESCRIPTION,
`<a href="${instanceUrl}/workflow/${subworkflowId}" target="_blank">Update sub-workflow settings</a> to allow other workflows to call it.`,
].join(' '),
inaccessiblePersonalProject: [
SUBWORKFLOW_DENIAL_BASE_DESCRIPTION,
`You will need ${ownerName} to update the sub-workflow (${subworkflowId}) settings to allow this workflow to call it.`,
].join(' '),
inaccesibleTeamProject: [
SUBWORKFLOW_DENIAL_BASE_DESCRIPTION,
`You will need an admin from the ${subworkflowProject.name} project to update the sub-workflow (${subworkflowId}) settings to allow this workflow to call it.`,
].join(' '),
};
super(`Target workflow ID ${subworkflowId} may not be called`, node, description); const description = () => {
if (hasReadAccess) return descriptions.accessible;
if (subworkflowProject.type === 'personal') return descriptions.inaccessiblePersonalProject;
if (subworkflowProject.type === 'team') return descriptions.inaccesibleTeamProject;
return descriptions.default;
};
super(
`The sub-workflow (${subworkflowId}) cannot be called by this workflow`,
node,
description(),
);
} }
} }

View file

@ -64,7 +64,7 @@ describe('OwnershipService', () => {
projectRelationRepository.getPersonalProjectOwners.mockResolvedValueOnce([projectRelation]); projectRelationRepository.getPersonalProjectOwners.mockResolvedValueOnce([projectRelation]);
const returnedOwner = await ownershipService.getProjectOwnerCached('some-project-id'); const returnedOwner = await ownershipService.getPersonalProjectOwnerCached('some-project-id');
expect(returnedOwner).toBe(mockOwner); expect(returnedOwner).toBe(mockOwner);
}); });
@ -72,7 +72,7 @@ describe('OwnershipService', () => {
test('should not throw if no project owner found, should return null instead', async () => { test('should not throw if no project owner found, should return null instead', async () => {
projectRelationRepository.getPersonalProjectOwners.mockResolvedValueOnce([]); projectRelationRepository.getPersonalProjectOwners.mockResolvedValueOnce([]);
const owner = await ownershipService.getProjectOwnerCached('some-project-id'); const owner = await ownershipService.getPersonalProjectOwnerCached('some-project-id');
expect(owner).toBeNull(); expect(owner).toBeNull();
}); });
@ -91,7 +91,7 @@ describe('OwnershipService', () => {
projectRelationRepository.getPersonalProjectOwners.mockResolvedValueOnce([projectRelation]); projectRelationRepository.getPersonalProjectOwners.mockResolvedValueOnce([projectRelation]);
const returnedOwner = await ownershipService.getProjectOwnerCached('some-project-id'); const returnedOwner = await ownershipService.getPersonalProjectOwnerCached('some-project-id');
expect(returnedOwner).toBe(mockOwner); expect(returnedOwner).toBe(mockOwner);
}); });
@ -99,7 +99,7 @@ describe('OwnershipService', () => {
test('should not throw if no project owner found, should return null instead', async () => { test('should not throw if no project owner found, should return null instead', async () => {
projectRelationRepository.getPersonalProjectOwners.mockResolvedValueOnce([]); projectRelationRepository.getPersonalProjectOwners.mockResolvedValueOnce([]);
const owner = await ownershipService.getProjectOwnerCached('some-project-id'); const owner = await ownershipService.getPersonalProjectOwnerCached('some-project-id');
expect(owner).toBeNull(); expect(owner).toBeNull();
}); });

View file

@ -42,7 +42,7 @@ describe('WorkflowStatisticsService', () => {
config.set('diagnostics.enabled', true); config.set('diagnostics.enabled', true);
config.set('deployment.type', 'n8n-testing'); config.set('deployment.type', 'n8n-testing');
mocked(ownershipService.getWorkflowProjectCached).mockResolvedValue(fakeProject); mocked(ownershipService.getWorkflowProjectCached).mockResolvedValue(fakeProject);
mocked(ownershipService.getProjectOwnerCached).mockResolvedValue(fakeUser); mocked(ownershipService.getPersonalProjectOwnerCached).mockResolvedValue(fakeUser);
const updateSettingsMock = jest.spyOn(userService, 'updateSettings').mockImplementation(); const updateSettingsMock = jest.spyOn(userService, 'updateSettings').mockImplementation();
const eventService = mock<EventService>(); const eventService = mock<EventService>();

View file

@ -0,0 +1,29 @@
import { Service } from 'typedi';
import { SharedWorkflowRepository } from '@/databases/repositories/shared-workflow.repository';
import { UserRepository } from '@/databases/repositories/user.repository';
import type { User } from '@/databases/entities/user';
import type { Workflow } from 'n8n-workflow';
/**
* Responsible for checking whether a user has access to a resource.
*/
@Service()
export class AccessService {
constructor(
private readonly userRepository: UserRepository,
private readonly sharedWorkflowRepository: SharedWorkflowRepository,
) {}
/** Whether a user has read access to a workflow based on their project and scope. */
async hasReadAccess(userId: User['id'], workflowId: Workflow['id']) {
const user = await this.userRepository.findOneBy({ id: userId });
if (!user) return false;
const workflow = await this.sharedWorkflowRepository.findWorkflowForUser(workflowId, user, [
'workflow:read',
]);
return workflow !== null;
}
}

View file

@ -40,9 +40,10 @@ export class OwnershipService {
} }
/** /**
* Retrieve the user that owns the project, or null if it's not an ownable project. Note that project ownership is **immutable**. * Retrieve the user who owns the personal project, or `null` if non-personal project.
* Personal project ownership is **immutable**.
*/ */
async getProjectOwnerCached(projectId: string): Promise<User | null> { async getPersonalProjectOwnerCached(projectId: string): Promise<User | null> {
const cachedValue = await this.cacheService.getHashValue<User | null>( const cachedValue = await this.cacheService.getHashValue<User | null>(
'project-owner', 'project-owner',
projectId, projectId,

View file

@ -72,7 +72,7 @@ export class WorkflowStatisticsService extends TypedEmitter<WorkflowStatisticsEv
if (name === StatisticsNames.productionSuccess && upsertResult === 'insert') { if (name === StatisticsNames.productionSuccess && upsertResult === 'insert') {
const project = await this.ownershipService.getWorkflowProjectCached(workflowId); const project = await this.ownershipService.getWorkflowProjectCached(workflowId);
if (project.type === 'personal') { if (project.type === 'personal') {
const owner = await this.ownershipService.getProjectOwnerCached(project.id); const owner = await this.ownershipService.getPersonalProjectOwnerCached(project.id);
if (owner && !owner.settings?.userActivated) { if (owner && !owner.settings?.userActivated) {
await this.userService.updateSettings(owner.id, { await this.userService.updateSettings(owner.id, {
@ -105,7 +105,7 @@ export class WorkflowStatisticsService extends TypedEmitter<WorkflowStatisticsEv
// Compile the metrics since this was a new data loaded event // Compile the metrics since this was a new data loaded event
const project = await this.ownershipService.getWorkflowProjectCached(workflowId); const project = await this.ownershipService.getWorkflowProjectCached(workflowId);
const owner = await this.ownershipService.getProjectOwnerCached(project.id); const owner = await this.ownershipService.getPersonalProjectOwnerCached(project.id);
let metrics = { let metrics = {
userId: owner!.id, userId: owner!.id,

View file

@ -1,29 +1,39 @@
import { v4 as uuid } from 'uuid'; import { v4 as uuid } from 'uuid';
import type { Workflow } from 'n8n-workflow'; import type { INode, Workflow } from 'n8n-workflow';
import { SubworkflowOperationError } from 'n8n-workflow'; import type { Project } from '@/databases/entities/project';
import { Project } from '@/databases/entities/project';
import { OwnershipService } from '@/services/ownership.service'; import { OwnershipService } from '@/services/ownership.service';
import { mockInstance } from '@test/mocking'; import { mockInstance } from '@test/mocking';
import config from '@/config';
import { mock } from 'jest-mock-extended'; import { mock } from 'jest-mock-extended';
import { SubworkflowPolicyChecker } from '../subworkflow-policy-checker.service'; import { SubworkflowPolicyChecker } from '../subworkflow-policy-checker.service';
import type { WorkflowEntity } from '@/databases/entities/workflow-entity'; import type { WorkflowEntity } from '@/databases/entities/workflow-entity';
import type { License } from '@/license'; import type { License } from '@/license';
import type { GlobalConfig } from '@n8n/config'; import type { GlobalConfig } from '@n8n/config';
import type { AccessService } from '@/services/access.service';
const toTargetCallErrorMsg = (subworkflowId: string) => import type { User } from '@/databases/entities/user';
`Target workflow ID ${subworkflowId} may not be called`; import {
SUBWORKFLOW_DENIAL_BASE_DESCRIPTION,
const ownershipService = mockInstance(OwnershipService); SubworkflowPolicyDenialError,
const memberPersonalProject = mock<Project>(); } from '@/errors/subworkflow-policy-denial.error';
import type { UrlService } from '@/services/url.service';
describe('SubworkflowPolicyChecker', () => { describe('SubworkflowPolicyChecker', () => {
const ownershipService = mockInstance(OwnershipService);
const license = mock<License>(); const license = mock<License>();
const globalConfig = mock<GlobalConfig>({ const globalConfig = mock<GlobalConfig>({
workflows: { callerPolicyDefaultOption: 'workflowsFromSameOwner' }, workflows: { callerPolicyDefaultOption: 'workflowsFromSameOwner' },
}); });
const checker = new SubworkflowPolicyChecker(mock(), license, ownershipService, globalConfig); const accessService = mock<AccessService>();
const urlService = mock<UrlService>();
const checker = new SubworkflowPolicyChecker(
mock(),
license,
ownershipService,
globalConfig,
accessService,
urlService,
);
beforeEach(() => { beforeEach(() => {
license.isSharingEnabled.mockReturnValue(true); license.isSharingEnabled.mockReturnValue(true);
@ -34,59 +44,28 @@ describe('SubworkflowPolicyChecker', () => {
}); });
describe('no caller policy', () => { describe('no caller policy', () => {
test('should fall back to N8N_WORKFLOW_CALLER_POLICY_DEFAULT_OPTION', async () => { it('should fall back to `N8N_WORKFLOW_CALLER_POLICY_DEFAULT_OPTION`', async () => {
const checker = new SubworkflowPolicyChecker( globalConfig.workflows.callerPolicyDefaultOption = 'none';
mock(),
license,
ownershipService,
mock<GlobalConfig>({ workflows: { callerPolicyDefaultOption: 'none' } }),
);
const parentWorkflow = mock<WorkflowEntity>(); const parentWorkflow = mock<WorkflowEntity>();
const subworkflow = mock<Workflow>(); // no caller policy const subworkflowId = 'subworkflow-id';
const subworkflow = mock<Workflow>({ id: subworkflowId }); // no caller policy
ownershipService.getWorkflowProjectCached.mockResolvedValue(memberPersonalProject); const parentWorkflowProject = mock<Project>();
const subworkflowProject = mock<Project>({ type: 'team' });
ownershipService.getWorkflowProjectCached.mockResolvedValueOnce(parentWorkflowProject);
ownershipService.getWorkflowProjectCached.mockResolvedValueOnce(subworkflowProject);
const check = checker.check(subworkflow, parentWorkflow.id); const check = checker.check(subworkflow, parentWorkflow.id);
await expect(check).rejects.toThrow(toTargetCallErrorMsg(subworkflow.id)); await expect(check).rejects.toThrowError(SubworkflowPolicyDenialError);
config.load(config.default); globalConfig.workflows.callerPolicyDefaultOption = 'workflowsFromSameOwner';
}); });
}); });
describe('overridden caller policy', () => { describe('`workflows-from-list` caller policy', () => {
test('if no sharing, should override policy to workflows-from-same-owner', async () => { it('should allow if caller list contains parent workflow ID', 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 parentWorkflow = mock<WorkflowEntity>({ id: uuid() });
const subworkflow = mock<Workflow>({ const subworkflow = mock<Workflow>({
@ -97,39 +76,62 @@ describe('SubworkflowPolicyChecker', () => {
}); });
const parentWorkflowProject = mock<Project>({ id: uuid() }); const parentWorkflowProject = mock<Project>({ id: uuid() });
const subworkflowOwner = mock<Project>({ id: uuid() }); const subworkflowProject = mock<Project>({ id: uuid() });
ownershipService.getWorkflowProjectCached.mockResolvedValueOnce(parentWorkflowProject); // parent workflow ownershipService.getWorkflowProjectCached.mockResolvedValueOnce(parentWorkflowProject);
ownershipService.getWorkflowProjectCached.mockResolvedValueOnce(subworkflowOwner); // subworkflow ownershipService.getWorkflowProjectCached.mockResolvedValueOnce(subworkflowProject);
const check = checker.check(subworkflow, parentWorkflow.id); const check = checker.check(subworkflow, parentWorkflow.id);
await expect(check).resolves.not.toThrow(); await expect(check).resolves.not.toThrow();
}); });
test('should deny if caller list does not contain parent workflow ID', async () => { it('should deny if caller list does not contain parent workflow ID', async () => {
const parentWorkflow = mock<WorkflowEntity>(); const parentWorkflow = mock<WorkflowEntity>({ id: 'parent-workflow-id' });
const parentWorkflowProject = mock<Project>({ id: uuid() });
const subworkflowProject = mock<Project>({ id: uuid(), type: 'team' });
ownershipService.getWorkflowProjectCached.mockResolvedValueOnce(parentWorkflowProject);
ownershipService.getWorkflowProjectCached.mockResolvedValueOnce(subworkflowProject);
const subworkflowId = 'subworkflow-id';
const subworkflow = mock<Workflow>({ const subworkflow = mock<Workflow>({
id: subworkflowId,
settings: { callerPolicy: 'workflowsFromAList', callerIds: 'xyz' }, settings: { callerPolicy: 'workflowsFromAList', callerIds: 'xyz' },
}); });
const check = checker.check(subworkflow, parentWorkflow.id); const check = checker.check(subworkflow, parentWorkflow.id);
await expect(check).rejects.toThrow(); await expect(check).rejects.toThrowError(SubworkflowPolicyDenialError);
}); });
}); });
describe('any caller policy', () => { describe('`any` caller policy', () => {
test('should not throw', async () => { it('if no sharing, should be overriden to `workflows-from-same-owner`', async () => {
license.isSharingEnabled.mockReturnValueOnce(false);
const parentWorkflow = mock<WorkflowEntity>();
const subworkflowId = 'subworkflow-id';
const subworkflow = mock<Workflow>({ id: subworkflowId, settings: { callerPolicy: 'any' } }); // should be overridden
const parentWorkflowProject = mock<Project>({ id: uuid() });
const subworkflowProject = mock<Project>({ id: uuid(), type: 'team' });
ownershipService.getWorkflowProjectCached.mockResolvedValueOnce(parentWorkflowProject);
ownershipService.getWorkflowProjectCached.mockResolvedValueOnce(subworkflowProject);
const check = checker.check(subworkflow, parentWorkflow.id);
await expect(check).rejects.toThrowError(SubworkflowPolicyDenialError);
});
it('should not throw on a regular subworkflow call', async () => {
const parentWorkflow = mock<WorkflowEntity>({ id: uuid() }); const parentWorkflow = mock<WorkflowEntity>({ id: uuid() });
const subworkflow = mock<Workflow>({ settings: { callerPolicy: 'any' } }); const subworkflow = mock<Workflow>({ settings: { callerPolicy: 'any' } });
const parentWorkflowProject = mock<Project>({ id: uuid() }); const parentWorkflowProject = mock<Project>({ id: uuid() });
const subworkflowOwner = mock<Project>({ id: uuid() }); const subworkflowProject = mock<Project>({ id: uuid() });
ownershipService.getWorkflowProjectCached.mockResolvedValueOnce(parentWorkflowProject); // parent workflow ownershipService.getWorkflowProjectCached.mockResolvedValueOnce(parentWorkflowProject);
ownershipService.getWorkflowProjectCached.mockResolvedValueOnce(subworkflowOwner); // subworkflow ownershipService.getWorkflowProjectCached.mockResolvedValueOnce(subworkflowProject);
ownershipService.getWorkflowProjectCached.mockResolvedValueOnce(new Project());
const check = checker.check(subworkflow, parentWorkflow.id); const check = checker.check(subworkflow, parentWorkflow.id);
@ -137,28 +139,32 @@ describe('SubworkflowPolicyChecker', () => {
}); });
}); });
describe('workflows-from-same-owner caller policy', () => { describe('`workflows-from-same-owner` caller policy', () => {
test('should deny if the two workflows are owned by different users', async () => { it('should deny if the two workflows are owned by different projects', async () => {
const parentWorkflowProject = mock<Project>({ id: uuid() }); const parentWorkflowProject = mock<Project>({ id: uuid() });
const subworkflowOwner = mock<Project>({ id: uuid() }); const subworkflowProject = mock<Project>({ id: uuid(), type: 'team' });
ownershipService.getWorkflowProjectCached.mockResolvedValueOnce(parentWorkflowProject); // parent workflow ownershipService.getWorkflowProjectCached.mockResolvedValueOnce(parentWorkflowProject);
ownershipService.getWorkflowProjectCached.mockResolvedValueOnce(subworkflowOwner); // subworkflow ownershipService.getWorkflowProjectCached.mockResolvedValueOnce(subworkflowProject);
const subworkflow = mock<Workflow>({ settings: { callerPolicy: 'workflowsFromSameOwner' } }); const subworkflowId = 'subworkflow-id';
const subworkflow = mock<Workflow>({
id: subworkflowId,
settings: { callerPolicy: 'workflowsFromSameOwner' },
});
const check = checker.check(subworkflow, uuid()); const check = checker.check(subworkflow, uuid());
await expect(check).rejects.toThrow(toTargetCallErrorMsg(subworkflow.id)); await expect(check).rejects.toThrowError(SubworkflowPolicyDenialError);
}); });
test('should allow if both workflows are owned by the same user', async () => { it('should allow if both workflows are owned by the same project', async () => {
const parentWorkflow = mock<WorkflowEntity>(); const parentWorkflow = mock<WorkflowEntity>();
const bothWorkflowsProject = mock<Project>({ id: uuid() }); const bothWorkflowsProject = mock<Project>({ id: uuid() });
ownershipService.getWorkflowProjectCached.mockResolvedValueOnce(bothWorkflowsProject); // parent workflow ownershipService.getWorkflowProjectCached.mockResolvedValueOnce(bothWorkflowsProject); // parent workflow project
ownershipService.getWorkflowProjectCached.mockResolvedValueOnce(bothWorkflowsProject); // subworkflow ownershipService.getWorkflowProjectCached.mockResolvedValueOnce(bothWorkflowsProject); // subworkflow project
const subworkflow = mock<Workflow>({ settings: { callerPolicy: 'workflowsFromSameOwner' } }); const subworkflow = mock<Workflow>({ settings: { callerPolicy: 'workflowsFromSameOwner' } });
@ -167,4 +173,117 @@ describe('SubworkflowPolicyChecker', () => {
await expect(check).resolves.not.toThrow(); await expect(check).resolves.not.toThrow();
}); });
}); });
describe('error details', () => {
it('should contain description for accessible case', async () => {
const parentWorkflow = mock<WorkflowEntity>({ id: uuid() });
const subworkflowId = 'subworkflow-id';
const subworkflow = mock<Workflow>({ id: subworkflowId, settings: { callerPolicy: 'none' } });
const parentWorkflowProject = mock<Project>({ id: uuid() });
const subworkflowProject = mock<Project>({ id: uuid() });
ownershipService.getWorkflowProjectCached.mockResolvedValueOnce(parentWorkflowProject);
ownershipService.getWorkflowProjectCached.mockResolvedValueOnce(subworkflowProject);
const subworkflowProjectOwner = mock<User>({ id: uuid() });
ownershipService.getPersonalProjectOwnerCached.mockResolvedValueOnce(subworkflowProjectOwner);
accessService.hasReadAccess.mockResolvedValueOnce(true);
const instanceUrl = 'https://n8n.test.com';
urlService.getInstanceBaseUrl.mockReturnValueOnce(instanceUrl);
const check = checker.check(
subworkflow,
parentWorkflow.id,
mock<INode>(),
subworkflowProjectOwner.id,
);
await expect(check).rejects.toMatchObject({
message: `The sub-workflow (${subworkflowId}) cannot be called by this workflow`,
description: `${SUBWORKFLOW_DENIAL_BASE_DESCRIPTION} <a href=\"${instanceUrl}/workflow/subworkflow-id\" target=\"_blank\">Update sub-workflow settings</a> to allow other workflows to call it.`,
});
});
it('should contain description for inaccessible personal project case', async () => {
const parentWorkflow = mock<WorkflowEntity>({ id: uuid() });
const subworkflowId = 'subworkflow-id';
const subworkflow = mock<Workflow>({ id: subworkflowId, settings: { callerPolicy: 'none' } });
const parentWorkflowProject = mock<Project>({ id: uuid() });
const subworkflowProject = mock<Project>({ id: uuid(), type: 'personal' });
ownershipService.getWorkflowProjectCached.mockResolvedValueOnce(parentWorkflowProject);
ownershipService.getWorkflowProjectCached.mockResolvedValueOnce(subworkflowProject);
const subworkflowProjectOwner = mock<User>({
id: uuid(),
firstName: 'John',
lastName: 'Doe',
});
ownershipService.getPersonalProjectOwnerCached.mockResolvedValueOnce(subworkflowProjectOwner);
accessService.hasReadAccess.mockResolvedValueOnce(false);
const node = mock<INode>();
const check = checker.check(subworkflow, parentWorkflow.id, node, subworkflowProjectOwner.id);
await expect(check).rejects.toMatchObject({
message: `The sub-workflow (${subworkflowId}) cannot be called by this workflow`,
description: `${SUBWORKFLOW_DENIAL_BASE_DESCRIPTION} You will need John Doe to update the sub-workflow (${subworkflowId}) settings to allow this workflow to call it.`,
});
});
it('should contain description for inaccessible team project case', async () => {
const parentWorkflow = mock<WorkflowEntity>({ id: uuid() });
const subworkflowId = 'subworkflow-id';
const subworkflow = mock<Workflow>({ id: subworkflowId, settings: { callerPolicy: 'none' } });
const parentWorkflowProject = mock<Project>({ id: uuid() });
const subworkflowProject = mock<Project>({ id: uuid(), type: 'team' });
ownershipService.getWorkflowProjectCached.mockResolvedValueOnce(parentWorkflowProject);
ownershipService.getWorkflowProjectCached.mockResolvedValueOnce(subworkflowProject);
const subworkflowProjectOwner = mock<User>({
id: uuid(),
firstName: 'John',
lastName: 'Doe',
});
ownershipService.getPersonalProjectOwnerCached.mockResolvedValueOnce(subworkflowProjectOwner);
accessService.hasReadAccess.mockResolvedValueOnce(false);
const check = checker.check(
subworkflow,
parentWorkflow.id,
mock<INode>(),
subworkflowProjectOwner.id,
);
await expect(check).rejects.toMatchObject({
message: `The sub-workflow (${subworkflowId}) cannot be called by this workflow`,
description: `${SUBWORKFLOW_DENIAL_BASE_DESCRIPTION} You will need an admin from the ${subworkflowProject.name} project to update the sub-workflow (${subworkflowId}) settings to allow this workflow to call it.`,
});
});
it('should contain description for default (e.g. error workflow) case', async () => {
const parentWorkflow = mock<WorkflowEntity>({ id: uuid() });
const subworkflowId = 'subworkflow-id';
const subworkflow = mock<Workflow>({ id: subworkflowId, settings: { callerPolicy: 'none' } });
const parentWorkflowProject = mock<Project>({ id: uuid() });
const subworkflowProject = mock<Project>({ id: uuid() });
ownershipService.getWorkflowProjectCached.mockResolvedValueOnce(parentWorkflowProject);
ownershipService.getWorkflowProjectCached.mockResolvedValueOnce(subworkflowProject);
const subworkflowProjectOwner = mock<User>({ id: uuid() });
ownershipService.getPersonalProjectOwnerCached.mockResolvedValueOnce(subworkflowProjectOwner);
accessService.hasReadAccess.mockResolvedValueOnce(true);
const check = checker.check(subworkflow, parentWorkflow.id, mock<INode>());
await expect(check).rejects.toMatchObject({
message: `The sub-workflow (${subworkflowId}) cannot be called by this workflow`,
description: SUBWORKFLOW_DENIAL_BASE_DESCRIPTION,
});
});
});
}); });

View file

@ -3,8 +3,12 @@ import { GlobalConfig } from '@n8n/config';
import { Logger } from '@/logger'; import { Logger } from '@/logger';
import { License } from '@/license'; import { License } from '@/license';
import { OwnershipService } from '@/services/ownership.service'; import { OwnershipService } from '@/services/ownership.service';
import type { Workflow, INode, WorkflowSettings } from 'n8n-workflow'; import { type Workflow, type INode, type WorkflowSettings } from 'n8n-workflow';
import { SubworkflowPolicyDenialError } from '@/errors/subworkflow-policy-denial.error'; import { SubworkflowPolicyDenialError } from '@/errors/subworkflow-policy-denial.error';
import { AccessService } from '@/services/access.service';
import type { Project } from '@/databases/entities/project';
import { strict as assert } from 'node:assert';
import { UrlService } from '@/services/url.service';
type Policy = WorkflowSettings.CallerPolicy; type Policy = WorkflowSettings.CallerPolicy;
type DenialPolicy = Exclude<Policy, 'any'>; type DenialPolicy = Exclude<Policy, 'any'>;
@ -16,12 +20,14 @@ export class SubworkflowPolicyChecker {
private readonly license: License, private readonly license: License,
private readonly ownershipService: OwnershipService, private readonly ownershipService: OwnershipService,
private readonly globalConfig: GlobalConfig, private readonly globalConfig: GlobalConfig,
private readonly accessService: AccessService,
private readonly urlService: UrlService,
) {} ) {}
/** /**
* Check whether the parent workflow is allowed to call the subworkflow. * Check whether the parent workflow is allowed to call the subworkflow.
*/ */
async check(subworkflow: Workflow, parentWorkflowId: string, node?: INode) { async check(subworkflow: Workflow, parentWorkflowId: string, node?: INode, userId?: string) {
const { id: subworkflowId } = subworkflow; const { id: subworkflowId } = subworkflow;
if (!subworkflowId) return; // e.g. when running a subworkflow loaded from a file if (!subworkflowId) return; // e.g. when running a subworkflow loaded from a file
@ -30,6 +36,8 @@ export class SubworkflowPolicyChecker {
if (policy === 'any') return; if (policy === 'any') return;
if (policy === 'workflowsFromAList' && this.isListed(subworkflow, parentWorkflowId)) return;
const { parentWorkflowProject, subworkflowProject } = await this.findProjects({ const { parentWorkflowProject, subworkflowProject } = await this.findProjects({
parentWorkflowId, parentWorkflowId,
subworkflowId, subworkflowId,
@ -37,20 +45,36 @@ export class SubworkflowPolicyChecker {
const areOwnedBySameProject = parentWorkflowProject.id === subworkflowProject.id; const areOwnedBySameProject = parentWorkflowProject.id === subworkflowProject.id;
if ( if (policy === 'workflowsFromSameOwner' && areOwnedBySameProject) return;
policy === 'none' ||
(policy === 'workflowsFromAList' && !this.hasParentListed(subworkflow, parentWorkflowId)) ||
(policy === 'workflowsFromSameOwner' && !areOwnedBySameProject)
) {
this.logDenial({ parentWorkflowId, subworkflowId, policy });
throw new SubworkflowPolicyDenialError({ this.logDenial({ parentWorkflowId, subworkflowId, policy });
subworkflowId,
subworkflowProject, const errorDetails = await this.errorDetails(subworkflowProject, subworkflow, userId);
areOwnedBySameProject,
node, throw new SubworkflowPolicyDenialError({
}); subworkflowId,
} subworkflowProject,
node,
instanceUrl: this.urlService.getInstanceBaseUrl(),
...errorDetails,
});
}
private async errorDetails(subworkflowProject: Project, subworkflow: Workflow, userId?: string) {
const hasReadAccess = userId
? await this.accessService.hasReadAccess(userId, subworkflow.id)
: false; /* no user ID in policy check for error workflow, so `false` to keep error message generic */
if (subworkflowProject.type === 'team') return { hasReadAccess };
const owner = await this.ownershipService.getPersonalProjectOwnerCached(subworkflowProject.id);
assert(owner !== null); // only `null` if not personal
return {
hasReadAccess,
ownerName: owner.firstName + ' ' + owner.lastName,
};
} }
/** /**
@ -85,7 +109,7 @@ export class SubworkflowPolicyChecker {
/** /**
* Whether the subworkflow has the parent workflow listed as a caller. * Whether the subworkflow has the parent workflow listed as a caller.
*/ */
private hasParentListed(subworkflow: Workflow, parentWorkflowId: string) { private isListed(subworkflow: Workflow, parentWorkflowId: string) {
const callerIds = const callerIds =
subworkflow.settings.callerIds subworkflow.settings.callerIds
?.split(',') ?.split(',')

View file

@ -19,7 +19,9 @@ export class PermissionChecker {
*/ */
async check(workflowId: string, nodes: INode[]) { async check(workflowId: string, nodes: INode[]) {
const homeProject = await this.ownershipService.getWorkflowProjectCached(workflowId); const homeProject = await this.ownershipService.getWorkflowProjectCached(workflowId);
const homeProjectOwner = await this.ownershipService.getProjectOwnerCached(homeProject.id); const homeProjectOwner = await this.ownershipService.getPersonalProjectOwnerCached(
homeProject.id,
);
if (homeProject.type === 'personal' && homeProjectOwner?.hasGlobalScope('credential:list')) { if (homeProject.type === 'personal' && homeProjectOwner?.hasGlobalScope('credential:list')) {
// Workflow belongs to a project by a user with privileges // Workflow belongs to a project by a user with privileges
// so all credentials are usable. Skip credential checks. // so all credentials are usable. Skip credential checks.

View file

@ -807,6 +807,7 @@ async function executeWorkflow(
workflow, workflow,
options.parentWorkflowId, options.parentWorkflowId,
options.node, options.node,
additionalData.userId,
); );
// Create new additionalData to have different workflow loaded and to call // Create new additionalData to have different workflow loaded and to call

View file

@ -265,7 +265,7 @@ describe('check()', () => {
const workflowEntity = await createWorkflow(nodes, owner); const workflowEntity = await createWorkflow(nodes, owner);
ownershipService.getWorkflowProjectCached.mockResolvedValueOnce(ownerPersonalProject); ownershipService.getWorkflowProjectCached.mockResolvedValueOnce(ownerPersonalProject);
ownershipService.getProjectOwnerCached.mockResolvedValueOnce(owner); ownershipService.getPersonalProjectOwnerCached.mockResolvedValueOnce(owner);
await expect( await expect(
permissionChecker.check(workflowEntity.id, workflowEntity.nodes), permissionChecker.check(workflowEntity.id, workflowEntity.nodes),