mirror of
https://github.com/n8n-io/n8n.git
synced 2024-12-24 04:04:06 -08:00
refactor(core): Revamp subworkflow policy checker errors (#10752)
This commit is contained in:
parent
db61bde9da
commit
8c6cd014a0
|
@ -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;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
|
@ -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 you’re 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(),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -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();
|
||||||
});
|
});
|
||||||
|
|
|
@ -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>();
|
||||||
|
|
29
packages/cli/src/services/access.service.ts
Normal file
29
packages/cli/src/services/access.service.ts
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
|
@ -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,
|
||||||
|
|
|
@ -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,
|
||||||
|
|
|
@ -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,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
|
@ -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(',')
|
||||||
|
|
|
@ -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.
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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),
|
||||||
|
|
Loading…
Reference in a new issue