feat: RBAC (#8922)
Signed-off-by: Oleg Ivaniv <me@olegivaniv.com> Co-authored-by: Val <68596159+valya@users.noreply.github.com> Co-authored-by: कारतोफ्फेलस्क्रिप्ट™ <aditya@netroy.in> Co-authored-by: Valya Bullions <valya@n8n.io> Co-authored-by: Danny Martini <danny@n8n.io> Co-authored-by: Danny Martini <despair.blue@gmail.com> Co-authored-by: Iván Ovejero <ivov.src@gmail.com> Co-authored-by: Omar Ajoue <krynble@gmail.com> Co-authored-by: oleg <me@olegivaniv.com> Co-authored-by: Michael Kret <michael.k@radency.com> Co-authored-by: Michael Kret <88898367+michael-radency@users.noreply.github.com> Co-authored-by: Elias Meire <elias@meire.dev> Co-authored-by: Giulio Andreini <andreini@netseven.it> Co-authored-by: Giulio Andreini <g.andreini@gmail.com> Co-authored-by: Ayato Hayashi <go12limchangyong@gmail.com>
This commit is contained in:
@@ -4,10 +4,9 @@ import type { INode, WorkflowSettings } from 'n8n-workflow';
|
||||
import { SubworkflowOperationError, Workflow } from 'n8n-workflow';
|
||||
|
||||
import config from '@/config';
|
||||
import { User } from '@db/entities/User';
|
||||
import type { User } from '@db/entities/User';
|
||||
import { WorkflowRepository } from '@db/repositories/workflow.repository';
|
||||
import { SharedWorkflowRepository } from '@db/repositories/sharedWorkflow.repository';
|
||||
import { UserRepository } from '@/databases/repositories/user.repository';
|
||||
import { generateNanoId } from '@/databases/utils/generators';
|
||||
import { License } from '@/License';
|
||||
import { LoadNodesAndCredentials } from '@/LoadNodesAndCredentials';
|
||||
@@ -28,7 +27,10 @@ import { mockNodeTypesData } from '../unit/Helpers';
|
||||
import { affixRoleToSaveCredential } from '../integration/shared/db/credentials';
|
||||
import { createOwner, createUser } from '../integration/shared/db/users';
|
||||
import { SharedCredentialsRepository } from '@/databases/repositories/sharedCredentials.repository';
|
||||
import { getPersonalProject } from './shared/db/projects';
|
||||
import type { WorkflowEntity } from '@/databases/entities/WorkflowEntity';
|
||||
import { Project } from '@/databases/entities/Project';
|
||||
import { ProjectRepository } from '@/databases/repositories/project.repository';
|
||||
|
||||
export const toTargetCallErrorMsg = (subworkflowId: string) =>
|
||||
`Target workflow ID ${subworkflowId} may not be called`;
|
||||
@@ -71,9 +73,11 @@ export function createSubworkflow({
|
||||
});
|
||||
}
|
||||
|
||||
const ownershipService = mockInstance(OwnershipService);
|
||||
|
||||
const createWorkflow = async (nodes: INode[], workflowOwner?: User): Promise<WorkflowEntity> => {
|
||||
const workflowDetails = {
|
||||
id: uuid(),
|
||||
id: randomPositiveDigit().toString(),
|
||||
name: 'test',
|
||||
active: false,
|
||||
connections: {},
|
||||
@@ -82,11 +86,13 @@ const createWorkflow = async (nodes: INode[], workflowOwner?: User): Promise<Wor
|
||||
};
|
||||
|
||||
const workflowEntity = await Container.get(WorkflowRepository).save(workflowDetails);
|
||||
|
||||
if (workflowOwner) {
|
||||
const project = await getPersonalProject(workflowOwner);
|
||||
|
||||
await Container.get(SharedWorkflowRepository).save({
|
||||
workflow: workflowEntity,
|
||||
user: workflowOwner,
|
||||
project,
|
||||
role: 'workflow:owner',
|
||||
});
|
||||
}
|
||||
@@ -98,6 +104,8 @@ let saveCredential: SaveCredentialFunction;
|
||||
|
||||
let owner: User;
|
||||
let member: User;
|
||||
let ownerPersonalProject: Project;
|
||||
let memberPersonalProject: Project;
|
||||
|
||||
const mockNodeTypes = mockInstance(NodeTypes);
|
||||
mockInstance(LoadNodesAndCredentials, {
|
||||
@@ -106,8 +114,6 @@ mockInstance(LoadNodesAndCredentials, {
|
||||
|
||||
let permissionChecker: PermissionChecker;
|
||||
|
||||
let license: LicenseMocker;
|
||||
|
||||
beforeAll(async () => {
|
||||
await testDb.init();
|
||||
|
||||
@@ -116,16 +122,12 @@ beforeAll(async () => {
|
||||
permissionChecker = Container.get(PermissionChecker);
|
||||
|
||||
[owner, member] = await Promise.all([createOwner(), createUser()]);
|
||||
|
||||
license = new LicenseMocker();
|
||||
license.mock(Container.get(License));
|
||||
license.setDefaults({
|
||||
features: ['feat:sharing'],
|
||||
});
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
license.reset();
|
||||
ownerPersonalProject = await Container.get(ProjectRepository).getPersonalProjectForUserOrFail(
|
||||
owner.id,
|
||||
);
|
||||
memberPersonalProject = await Container.get(ProjectRepository).getPersonalProjectForUserOrFail(
|
||||
member.id,
|
||||
);
|
||||
});
|
||||
|
||||
describe('check()', () => {
|
||||
@@ -150,46 +152,19 @@ describe('check()', () => {
|
||||
];
|
||||
|
||||
const workflow = await createWorkflow(nodes, member);
|
||||
ownershipService.getWorkflowProjectCached.mockResolvedValueOnce(memberPersonalProject);
|
||||
|
||||
await expect(
|
||||
permissionChecker.check(workflow.id, member.id, workflow.nodes),
|
||||
).resolves.not.toThrow();
|
||||
await expect(permissionChecker.check(workflow.id, nodes)).resolves.not.toThrow();
|
||||
});
|
||||
|
||||
test('should allow if requesting user is instance owner', async () => {
|
||||
const owner = await createOwner();
|
||||
const nodes: INode[] = [
|
||||
{
|
||||
id: uuid(),
|
||||
name: 'Action Network',
|
||||
type: 'n8n-nodes-base.actionNetwork',
|
||||
parameters: {},
|
||||
typeVersion: 1,
|
||||
position: [0, 0],
|
||||
credentials: {
|
||||
actionNetworkApi: {
|
||||
id: randomPositiveDigit().toString(),
|
||||
name: 'Action Network Account',
|
||||
},
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
const workflow = await createWorkflow(nodes);
|
||||
|
||||
await expect(
|
||||
permissionChecker.check(workflow.id, owner.id, workflow.nodes),
|
||||
).resolves.not.toThrow();
|
||||
});
|
||||
|
||||
test('should allow if workflow creds are valid subset (shared credential)', async () => {
|
||||
test('should allow if workflow creds are valid subset', async () => {
|
||||
const ownerCred = await saveCredential(randomCred(), { user: owner });
|
||||
const memberCred = await saveCredential(randomCred(), { user: member });
|
||||
|
||||
await Container.get(SharedCredentialsRepository).save(
|
||||
Container.get(SharedCredentialsRepository).create({
|
||||
projectId: (await getPersonalProject(member)).id,
|
||||
credentialsId: ownerCred.id,
|
||||
userId: member.id,
|
||||
role: 'credential:user',
|
||||
}),
|
||||
);
|
||||
@@ -225,119 +200,18 @@ describe('check()', () => {
|
||||
},
|
||||
];
|
||||
|
||||
const workflow = await createWorkflow(nodes, member);
|
||||
const workflowEntity = await createWorkflow(nodes, member);
|
||||
|
||||
await expect(
|
||||
permissionChecker.check(workflow.id, member.id, workflow.nodes),
|
||||
).resolves.not.toThrow();
|
||||
});
|
||||
ownershipService.getWorkflowProjectCached.mockResolvedValueOnce(memberPersonalProject);
|
||||
|
||||
test('should allow if workflow creds are valid subset (shared workflow)', async () => {
|
||||
const ownerCred = await saveCredential(randomCred(), { user: owner });
|
||||
const memberCred = await saveCredential(randomCred(), { user: member });
|
||||
|
||||
const nodes: INode[] = [
|
||||
{
|
||||
id: uuid(),
|
||||
name: 'Action Network',
|
||||
type: 'n8n-nodes-base.actionNetwork',
|
||||
parameters: {},
|
||||
typeVersion: 1,
|
||||
position: [0, 0],
|
||||
credentials: {
|
||||
actionNetworkApi: {
|
||||
id: ownerCred.id,
|
||||
name: ownerCred.name,
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
id: uuid(),
|
||||
name: 'Action Network 2',
|
||||
type: 'n8n-nodes-base.actionNetwork',
|
||||
parameters: {},
|
||||
typeVersion: 1,
|
||||
position: [0, 0],
|
||||
credentials: {
|
||||
actionNetworkApi: {
|
||||
id: memberCred.id,
|
||||
name: memberCred.name,
|
||||
},
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
const workflow = await createWorkflow(nodes, member);
|
||||
await Container.get(SharedWorkflowRepository).save(
|
||||
Container.get(SharedWorkflowRepository).create({
|
||||
workflowId: workflow.id,
|
||||
userId: owner.id,
|
||||
role: 'workflow:editor',
|
||||
}),
|
||||
);
|
||||
|
||||
await expect(
|
||||
permissionChecker.check(workflow.id, member.id, workflow.nodes),
|
||||
).resolves.not.toThrow();
|
||||
});
|
||||
|
||||
test('should deny if workflow creds are valid subset but sharing is disabled', async () => {
|
||||
const [owner, member] = await Promise.all([createOwner(), createUser()]);
|
||||
|
||||
const ownerCred = await saveCredential(randomCred(), { user: owner });
|
||||
const memberCred = await saveCredential(randomCred(), { user: member });
|
||||
|
||||
await Container.get(SharedCredentialsRepository).save(
|
||||
Container.get(SharedCredentialsRepository).create({
|
||||
credentialsId: ownerCred.id,
|
||||
userId: member.id,
|
||||
role: 'credential:user',
|
||||
}),
|
||||
);
|
||||
|
||||
const nodes: INode[] = [
|
||||
{
|
||||
id: uuid(),
|
||||
name: 'Action Network',
|
||||
type: 'n8n-nodes-base.actionNetwork',
|
||||
parameters: {},
|
||||
typeVersion: 1,
|
||||
position: [0, 0],
|
||||
credentials: {
|
||||
actionNetworkApi: {
|
||||
id: ownerCred.id,
|
||||
name: ownerCred.name,
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
id: uuid(),
|
||||
name: 'Action Network 2',
|
||||
type: 'n8n-nodes-base.actionNetwork',
|
||||
parameters: {},
|
||||
typeVersion: 1,
|
||||
position: [0, 0],
|
||||
credentials: {
|
||||
actionNetworkApi: {
|
||||
id: memberCred.id,
|
||||
name: memberCred.name,
|
||||
},
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
const workflow = await createWorkflow(nodes, member);
|
||||
|
||||
license.disable('feat:sharing');
|
||||
await expect(permissionChecker.check(workflow.id, member.id, nodes)).rejects.toThrow();
|
||||
await expect(permissionChecker.check(workflowEntity.id, nodes)).resolves.not.toThrow();
|
||||
});
|
||||
|
||||
test('should deny if workflow creds are not valid subset', async () => {
|
||||
const member = await createUser();
|
||||
|
||||
const memberCred = await saveCredential(randomCred(), { user: member });
|
||||
const ownerCred = await saveCredential(randomCred(), { user: owner });
|
||||
|
||||
const nodes: INode[] = [
|
||||
const nodes = [
|
||||
{
|
||||
id: uuid(),
|
||||
name: 'Action Network',
|
||||
@@ -361,21 +235,73 @@ describe('check()', () => {
|
||||
position: [0, 0] as [number, number],
|
||||
credentials: {
|
||||
actionNetworkApi: {
|
||||
id: 'non-existing-credential-id',
|
||||
name: 'Non-existing credential name',
|
||||
id: ownerCred.id,
|
||||
name: ownerCred.name,
|
||||
},
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
const workflow = await createWorkflow(nodes, member);
|
||||
const workflowEntity = await createWorkflow(nodes, member);
|
||||
|
||||
await expect(permissionChecker.check(workflow.id, member.id, workflow.nodes)).rejects.toThrow();
|
||||
await expect(
|
||||
permissionChecker.check(workflowEntity.id, workflowEntity.nodes),
|
||||
).rejects.toThrow();
|
||||
});
|
||||
|
||||
test('should allow all credentials if current user is instance owner', async () => {
|
||||
const memberCred = await saveCredential(randomCred(), { user: member });
|
||||
const ownerCred = await saveCredential(randomCred(), { user: owner });
|
||||
|
||||
const nodes = [
|
||||
{
|
||||
id: uuid(),
|
||||
name: 'Action Network',
|
||||
type: 'n8n-nodes-base.actionNetwork',
|
||||
parameters: {},
|
||||
typeVersion: 1,
|
||||
position: [0, 0] as [number, number],
|
||||
credentials: {
|
||||
actionNetworkApi: {
|
||||
id: memberCred.id,
|
||||
name: memberCred.name,
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
id: uuid(),
|
||||
name: 'Action Network 2',
|
||||
type: 'n8n-nodes-base.actionNetwork',
|
||||
parameters: {},
|
||||
typeVersion: 1,
|
||||
position: [0, 0] as [number, number],
|
||||
credentials: {
|
||||
actionNetworkApi: {
|
||||
id: ownerCred.id,
|
||||
name: ownerCred.name,
|
||||
},
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
const workflowEntity = await createWorkflow(nodes, owner);
|
||||
ownershipService.getWorkflowProjectCached.mockResolvedValueOnce(ownerPersonalProject);
|
||||
ownershipService.getProjectOwnerCached.mockResolvedValueOnce(owner);
|
||||
|
||||
await expect(
|
||||
permissionChecker.check(workflowEntity.id, workflowEntity.nodes),
|
||||
).resolves.not.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe('checkSubworkflowExecutePolicy()', () => {
|
||||
const ownershipService = mockInstance(OwnershipService);
|
||||
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 () => {
|
||||
@@ -384,7 +310,7 @@ describe('checkSubworkflowExecutePolicy()', () => {
|
||||
const parentWorkflow = createParentWorkflow();
|
||||
const subworkflow = createSubworkflow(); // no caller policy
|
||||
|
||||
ownershipService.getWorkflowOwnerCached.mockResolvedValue(new User());
|
||||
ownershipService.getWorkflowProjectCached.mockResolvedValue(memberPersonalProject);
|
||||
|
||||
const check = permissionChecker.checkSubworkflowExecutePolicy(subworkflow, parentWorkflow.id);
|
||||
|
||||
@@ -401,11 +327,11 @@ describe('checkSubworkflowExecutePolicy()', () => {
|
||||
const parentWorkflow = createParentWorkflow();
|
||||
const subworkflow = createSubworkflow({ policy: 'any' }); // should be overridden
|
||||
|
||||
const firstUser = Container.get(UserRepository).create({ id: uuid() });
|
||||
const secondUser = Container.get(UserRepository).create({ id: uuid() });
|
||||
const firstProject = Container.get(ProjectRepository).create({ id: uuid() });
|
||||
const secondProject = Container.get(ProjectRepository).create({ id: uuid() });
|
||||
|
||||
ownershipService.getWorkflowOwnerCached.mockResolvedValueOnce(firstUser); // parent workflow
|
||||
ownershipService.getWorkflowOwnerCached.mockResolvedValueOnce(secondUser); // subworkflow
|
||||
ownershipService.getWorkflowProjectCached.mockResolvedValueOnce(firstProject); // parent workflow
|
||||
ownershipService.getWorkflowProjectCached.mockResolvedValueOnce(secondProject); // subworkflow
|
||||
|
||||
const check = permissionChecker.checkSubworkflowExecutePolicy(subworkflow, parentWorkflow.id);
|
||||
|
||||
@@ -416,7 +342,7 @@ describe('checkSubworkflowExecutePolicy()', () => {
|
||||
} catch (error) {
|
||||
if (error instanceof SubworkflowOperationError) {
|
||||
expect(error.description).toBe(
|
||||
`${firstUser.firstName} (${firstUser.email}) can make this change. You may need to tell them the ID of this workflow, which is ${subworkflow.id}`,
|
||||
`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}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -457,7 +383,7 @@ describe('checkSubworkflowExecutePolicy()', () => {
|
||||
test('should not throw', async () => {
|
||||
const parentWorkflow = createParentWorkflow();
|
||||
const subworkflow = createSubworkflow({ policy: 'any' });
|
||||
ownershipService.getWorkflowOwnerCached.mockResolvedValue(new User());
|
||||
ownershipService.getWorkflowProjectCached.mockResolvedValueOnce(new Project());
|
||||
|
||||
const check = permissionChecker.checkSubworkflowExecutePolicy(subworkflow, parentWorkflow.id);
|
||||
|
||||
@@ -467,11 +393,11 @@ describe('checkSubworkflowExecutePolicy()', () => {
|
||||
|
||||
describe('workflows-from-same-owner caller policy', () => {
|
||||
test('should deny if the two workflows are owned by different users', async () => {
|
||||
const parentWorkflowOwner = Container.get(UserRepository).create({ id: uuid() });
|
||||
const subworkflowOwner = Container.get(UserRepository).create({ id: uuid() });
|
||||
const parentWorkflowProject = Container.get(ProjectRepository).create({ id: uuid() });
|
||||
const subworkflowOwner = Container.get(ProjectRepository).create({ id: uuid() });
|
||||
|
||||
ownershipService.getWorkflowOwnerCached.mockResolvedValueOnce(parentWorkflowOwner); // parent workflow
|
||||
ownershipService.getWorkflowOwnerCached.mockResolvedValueOnce(subworkflowOwner); // subworkflow
|
||||
ownershipService.getWorkflowProjectCached.mockResolvedValueOnce(parentWorkflowProject); // parent workflow
|
||||
ownershipService.getWorkflowProjectCached.mockResolvedValueOnce(subworkflowOwner); // subworkflow
|
||||
|
||||
const subworkflow = createSubworkflow({ policy: 'workflowsFromSameOwner' });
|
||||
|
||||
@@ -483,10 +409,10 @@ describe('checkSubworkflowExecutePolicy()', () => {
|
||||
test('should allow if both workflows are owned by the same user', async () => {
|
||||
const parentWorkflow = createParentWorkflow();
|
||||
|
||||
const bothWorkflowsOwner = Container.get(UserRepository).create({ id: uuid() });
|
||||
const bothWorkflowsProject = Container.get(ProjectRepository).create({ id: uuid() });
|
||||
|
||||
ownershipService.getWorkflowOwnerCached.mockResolvedValueOnce(bothWorkflowsOwner); // parent workflow
|
||||
ownershipService.getWorkflowOwnerCached.mockResolvedValueOnce(bothWorkflowsOwner); // subworkflow
|
||||
ownershipService.getWorkflowProjectCached.mockResolvedValueOnce(bothWorkflowsProject); // parent workflow
|
||||
ownershipService.getWorkflowProjectCached.mockResolvedValueOnce(bothWorkflowsProject); // subworkflow
|
||||
|
||||
const subworkflow = createSubworkflow({ policy: 'workflowsFromSameOwner' });
|
||||
|
||||
|
||||
Reference in New Issue
Block a user