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:
Csaba Tuncsik
2024-05-17 10:53:15 +02:00
committed by GitHub
parent b1f977ebd0
commit 596c472ecc
292 changed files with 14129 additions and 3989 deletions

View File

@@ -1,5 +1,5 @@
import type { IWorkflowDb } from '@/Interfaces';
import type { AuthenticatedRequest } from '@/requests';
import type { AuthenticatedRequest, ListQuery } from '@/requests';
import type {
INode,
IConnections,
@@ -11,7 +11,7 @@ import type {
export declare namespace WorkflowRequest {
type CreateUpdatePayload = Partial<{
id: string; // delete if sent
id: string; // deleted if sent
name: string;
nodes: INode[];
connections: IConnections;
@@ -20,6 +20,7 @@ export declare namespace WorkflowRequest {
tags: string[];
hash: string;
meta: Record<string, unknown>;
projectId: string;
}>;
type ManualRunPayload = {
@@ -32,12 +33,16 @@ export declare namespace WorkflowRequest {
type Create = AuthenticatedRequest<{}, {}, CreateUpdatePayload>;
type Get = AuthenticatedRequest<{ id: string }>;
type Get = AuthenticatedRequest<{ workflowId: string }>;
type GetMany = AuthenticatedRequest<{}, {}, {}, ListQuery.Params & { includeScopes?: string }> & {
listQueryOptions: ListQuery.Options;
};
type Delete = Get;
type Update = AuthenticatedRequest<
{ id: string },
{ workflowId: string },
{},
CreateUpdatePayload,
{ forceSave?: string }
@@ -45,7 +50,7 @@ export declare namespace WorkflowRequest {
type NewName = AuthenticatedRequest<{}, {}, {}, { name?: string }>;
type ManualRun = AuthenticatedRequest<{}, {}, ManualRunPayload>;
type ManualRun = AuthenticatedRequest<{ workflowId: string }, {}, ManualRunPayload>;
type Share = AuthenticatedRequest<{ workflowId: string }, {}, { shareWithIds: string[] }>;

View File

@@ -15,7 +15,11 @@ import { Logger } from '@/Logger';
import type {
CredentialUsedByWorkflow,
WorkflowWithSharingsAndCredentials,
WorkflowWithSharingsMetaDataAndCredentials,
} from './workflows.types';
import { OwnershipService } from '@/services/ownership.service';
import { In, type EntityManager } from '@n8n/typeorm';
import { Project } from '@/databases/entities/Project';
@Service()
export class EnterpriseWorkflowService {
@@ -25,49 +29,48 @@ export class EnterpriseWorkflowService {
private readonly workflowRepository: WorkflowRepository,
private readonly credentialsRepository: CredentialsRepository,
private readonly credentialsService: CredentialsService,
private readonly ownershipService: OwnershipService,
) {}
async isOwned(
user: User,
workflowId: string,
): Promise<{ ownsWorkflow: boolean; workflow?: WorkflowEntity }> {
const sharing = await this.sharedWorkflowRepository.getSharing(
user,
workflowId,
{ allowGlobalScope: false },
['workflow'],
);
async shareWithProjects(
workflow: WorkflowEntity,
shareWithIds: string[],
entityManager: EntityManager,
) {
const em = entityManager ?? this.sharedWorkflowRepository.manager;
if (!sharing || sharing.role !== 'workflow:owner') return { ownsWorkflow: false };
const { workflow } = sharing;
return { ownsWorkflow: true, workflow };
}
addOwnerAndSharings(workflow: WorkflowWithSharingsAndCredentials): void {
workflow.ownedBy = null;
workflow.sharedWith = [];
if (!workflow.usedCredentials) {
workflow.usedCredentials = [];
}
workflow.shared?.forEach(({ user, role }) => {
const { id, email, firstName, lastName } = user;
if (role === 'workflow:owner') {
workflow.ownedBy = { id, email, firstName, lastName };
return;
}
workflow.sharedWith?.push({ id, email, firstName, lastName });
const projects = await em.find(Project, {
where: { id: In(shareWithIds), type: 'personal' },
});
delete workflow.shared;
const newSharedWorkflows = projects
// We filter by role === 'project:personalOwner' above and there should
// always only be one owner.
.map((project) =>
this.sharedWorkflowRepository.create({
workflowId: workflow.id,
role: 'workflow:editor',
projectId: project.id,
}),
);
return await em.save(newSharedWorkflows);
}
addOwnerAndSharings(
workflow: WorkflowWithSharingsAndCredentials,
): WorkflowWithSharingsMetaDataAndCredentials {
const workflowWithMetaData = this.ownershipService.addOwnedByAndSharedWith(workflow);
return {
...workflow,
...workflowWithMetaData,
usedCredentials: workflow.usedCredentials ?? [],
};
}
async addCredentialsToWorkflow(
workflow: WorkflowWithSharingsAndCredentials,
workflow: WorkflowWithSharingsMetaDataAndCredentials,
currentUser: User,
): Promise<void> {
workflow.usedCredentials = [];
@@ -100,14 +103,7 @@ export class EnterpriseWorkflowService {
sharedWith: [],
ownedBy: null,
};
credential.shared?.forEach(({ user, role }) => {
const { id, email, firstName, lastName } = user;
if (role === 'credential:owner') {
workflowCredential.ownedBy = { id, email, firstName, lastName };
} else {
workflowCredential.sharedWith?.push({ id, email, firstName, lastName });
}
});
credential = this.ownershipService.addOwnedByAndSharedWith(credential);
workflow.usedCredentials?.push(workflowCredential);
});
}

View File

@@ -8,8 +8,6 @@ import { BinaryDataService } from 'n8n-core';
import config from '@/config';
import type { User } from '@db/entities/User';
import type { WorkflowEntity } from '@db/entities/WorkflowEntity';
import type { WorkflowSharingRole } from '@db/entities/SharedWorkflow';
import { ExecutionRepository } from '@db/repositories/execution.repository';
import { SharedWorkflowRepository } from '@db/repositories/sharedWorkflow.repository';
import { WorkflowTagMappingRepository } from '@db/repositories/workflowTagMapping.repository';
import { WorkflowRepository } from '@db/repositories/workflow.repository';
@@ -26,12 +24,19 @@ import { Logger } from '@/Logger';
import { OrchestrationService } from '@/services/orchestration.service';
import { BadRequestError } from '@/errors/response-errors/bad-request.error';
import { NotFoundError } from '@/errors/response-errors/not-found.error';
import { RoleService } from '@/services/role.service';
import { WorkflowSharingService } from './workflowSharing.service';
import { ProjectService } from '@/services/project.service';
import { ExecutionRepository } from '@/databases/repositories/execution.repository';
import type { Scope } from '@n8n/permissions';
import type { EntityManager } from '@n8n/typeorm';
import { In } from '@n8n/typeorm';
import { SharedWorkflow } from '@/databases/entities/SharedWorkflow';
@Service()
export class WorkflowService {
constructor(
private readonly logger: Logger,
private readonly executionRepository: ExecutionRepository,
private readonly sharedWorkflowRepository: SharedWorkflowRepository,
private readonly workflowRepository: WorkflowRepository,
private readonly workflowTagMappingRepository: WorkflowTagMappingRepository,
@@ -42,36 +47,52 @@ export class WorkflowService {
private readonly orchestrationService: OrchestrationService,
private readonly externalHooks: ExternalHooks,
private readonly activeWorkflowManager: ActiveWorkflowManager,
private readonly roleService: RoleService,
private readonly workflowSharingService: WorkflowSharingService,
private readonly projectService: ProjectService,
private readonly executionRepository: ExecutionRepository,
) {}
async getMany(sharedWorkflowIds: string[], options?: ListQuery.Options) {
const { workflows, count } = await this.workflowRepository.getMany(sharedWorkflowIds, options);
async getMany(user: User, options?: ListQuery.Options, includeScopes?: boolean) {
const sharedWorkflowIds = await this.workflowSharingService.getSharedWorkflowIds(user, {
scopes: ['workflow:read'],
});
return hasSharing(workflows)
? {
workflows: workflows.map((w) => this.ownershipService.addOwnedByAndSharedWith(w)),
count,
}
: { workflows, count };
// eslint-disable-next-line prefer-const
let { workflows, count } = await this.workflowRepository.getMany(sharedWorkflowIds, options);
if (hasSharing(workflows)) {
workflows = workflows.map((w) => this.ownershipService.addOwnedByAndSharedWith(w));
}
if (includeScopes) {
const projectRelations = await this.projectService.getProjectRelationsForUser(user);
workflows = workflows.map((w) => this.roleService.addScopes(w, user, projectRelations));
}
workflows.forEach((w) => {
// @ts-expect-error: This is to emulate the old behaviour of removing the shared
// field as part of `addOwnedByAndSharedWith`. We need this field in `addScopes`
// though. So to avoid leaking the information we just delete it.
delete w.shared;
});
return { workflows, count };
}
// eslint-disable-next-line complexity
async update(
user: User,
workflow: WorkflowEntity,
workflowUpdateData: WorkflowEntity,
workflowId: string,
tagIds?: string[],
forceSave?: boolean,
roles?: WorkflowSharingRole[],
): Promise<WorkflowEntity> {
const shared = await this.sharedWorkflowRepository.findSharing(
workflowId,
user,
const workflow = await this.sharedWorkflowRepository.findWorkflowForUser(workflowId, user, [
'workflow:update',
{ roles },
);
]);
if (!shared) {
if (!workflow) {
this.logger.verbose('User attempted to update a workflow without permissions', {
workflowId,
userId: user.id,
@@ -83,8 +104,8 @@ export class WorkflowService {
if (
!forceSave &&
workflow.versionId !== '' &&
workflow.versionId !== shared.workflow.versionId
workflowUpdateData.versionId !== '' &&
workflowUpdateData.versionId !== workflow.versionId
) {
throw new BadRequestError(
'Your most recent changes may be lost, because someone else just updated this workflow. Open this workflow in a new tab to see those new updates.',
@@ -92,25 +113,25 @@ export class WorkflowService {
);
}
if (Object.keys(omit(workflow, ['id', 'versionId', 'active'])).length > 0) {
if (Object.keys(omit(workflowUpdateData, ['id', 'versionId', 'active'])).length > 0) {
// Update the workflow's version when changing properties such as
// `name`, `pinData`, `nodes`, `connections`, `settings` or `tags`
workflow.versionId = uuid();
workflowUpdateData.versionId = uuid();
this.logger.verbose(
`Updating versionId for workflow ${workflowId} for user ${user.id} after saving`,
{
previousVersionId: shared.workflow.versionId,
newVersionId: workflow.versionId,
previousVersionId: workflow.versionId,
newVersionId: workflowUpdateData.versionId,
},
);
}
// check credentials for old format
await WorkflowHelpers.replaceInvalidCredentials(workflow);
await WorkflowHelpers.replaceInvalidCredentials(workflowUpdateData);
WorkflowHelpers.addNodeIds(workflow);
WorkflowHelpers.addNodeIds(workflowUpdateData);
await this.externalHooks.run('workflow.update', [workflow]);
await this.externalHooks.run('workflow.update', [workflowUpdateData]);
/**
* If the workflow being updated is stored as `active`, remove it from
@@ -119,11 +140,11 @@ export class WorkflowService {
* If a trigger or poller in the workflow was updated, the new value
* will take effect only on removing and re-adding.
*/
if (shared.workflow.active) {
if (workflow.active) {
await this.activeWorkflowManager.remove(workflowId);
}
const workflowSettings = workflow.settings ?? {};
const workflowSettings = workflowUpdateData.settings ?? {};
const keysAllowingDefault = [
'timezone',
@@ -144,14 +165,14 @@ export class WorkflowService {
delete workflowSettings.executionTimeout;
}
if (workflow.name) {
workflow.updatedAt = new Date(); // required due to atomic update
await validateEntity(workflow);
if (workflowUpdateData.name) {
workflowUpdateData.updatedAt = new Date(); // required due to atomic update
await validateEntity(workflowUpdateData);
}
await this.workflowRepository.update(
workflowId,
pick(workflow, [
pick(workflowUpdateData, [
'name',
'active',
'nodes',
@@ -168,8 +189,8 @@ export class WorkflowService {
await this.workflowTagMappingRepository.overwriteTaggings(workflowId, tagIds);
}
if (workflow.versionId !== shared.workflow.versionId) {
await this.workflowHistoryService.saveVersion(user, workflow, workflowId);
if (workflowUpdateData.versionId !== workflow.versionId) {
await this.workflowHistoryService.saveVersion(user, workflowUpdateData, workflowId);
}
const relations = config.getEnv('workflowTagsDisabled') ? [] : ['tags'];
@@ -200,16 +221,13 @@ export class WorkflowService {
// When the workflow is supposed to be active add it again
try {
await this.externalHooks.run('workflow.activate', [updatedWorkflow]);
await this.activeWorkflowManager.add(
workflowId,
shared.workflow.active ? 'update' : 'activate',
);
await this.activeWorkflowManager.add(workflowId, workflow.active ? 'update' : 'activate');
} catch (error) {
// If workflow could not be activated set it again to inactive
// and revert the versionId change so UI remains consistent
await this.workflowRepository.update(workflowId, {
active: false,
versionId: shared.workflow.versionId,
versionId: workflow.versionId,
});
// Also set it in the returned data
@@ -232,18 +250,15 @@ export class WorkflowService {
async delete(user: User, workflowId: string): Promise<WorkflowEntity | undefined> {
await this.externalHooks.run('workflow.delete', [workflowId]);
const sharedWorkflow = await this.sharedWorkflowRepository.findSharing(
workflowId,
user,
const workflow = await this.sharedWorkflowRepository.findWorkflowForUser(workflowId, user, [
'workflow:delete',
{ roles: ['workflow:owner'] },
);
]);
if (!sharedWorkflow) {
if (!workflow) {
return;
}
if (sharedWorkflow.workflow.active) {
if (workflow.active) {
// deactivate before deleting
await this.activeWorkflowManager.remove(workflowId);
}
@@ -261,6 +276,71 @@ export class WorkflowService {
void Container.get(InternalHooks).onWorkflowDeleted(user, workflowId, false);
await this.externalHooks.run('workflow.afterDelete', [workflowId]);
return sharedWorkflow.workflow;
return workflow;
}
async getWorkflowScopes(user: User, workflowId: string): Promise<Scope[]> {
const userProjectRelations = await this.projectService.getProjectRelationsForUser(user);
const shared = await this.sharedWorkflowRepository.find({
where: {
projectId: In([...new Set(userProjectRelations.map((pr) => pr.projectId))]),
workflowId,
},
});
return this.roleService.combineResourceScopes('workflow', user, shared, userProjectRelations);
}
/**
* Transfers all workflows owned by a project to another one.
* This has only been tested for personal projects. It may need to be amended
* for team projects.
**/
async transferAll(fromProjectId: string, toProjectId: string, trx?: EntityManager) {
trx = trx ?? this.workflowRepository.manager;
// Get all shared workflows for both projects.
const allSharedWorkflows = await trx.findBy(SharedWorkflow, {
projectId: In([fromProjectId, toProjectId]),
});
const sharedWorkflowsOfFromProject = allSharedWorkflows.filter(
(sw) => sw.projectId === fromProjectId,
);
// For all workflows that the from-project owns transfer the ownership to
// the to-project.
// This will override whatever relationship the to-project already has to
// the resources at the moment.
const ownedWorkflowIds = sharedWorkflowsOfFromProject
.filter((sw) => sw.role === 'workflow:owner')
.map((sw) => sw.workflowId);
await this.sharedWorkflowRepository.makeOwner(ownedWorkflowIds, toProjectId, trx);
// Delete the relationship to the from-project.
await this.sharedWorkflowRepository.deleteByIds(ownedWorkflowIds, fromProjectId, trx);
// Transfer relationships that are not `workflow:owner`.
// This will NOT override whatever relationship the from-project already
// has to the resource at the moment.
const sharedWorkflowIdsOfTransferee = allSharedWorkflows
.filter((sw) => sw.projectId === toProjectId)
.map((sw) => sw.workflowId);
// All resources that are shared with the from-project, but not with the
// to-project.
const sharedWorkflowsToTransfer = sharedWorkflowsOfFromProject.filter(
(sw) =>
sw.role !== 'workflow:owner' && !sharedWorkflowIdsOfTransferee.includes(sw.workflowId),
);
await trx.insert(
SharedWorkflow,
sharedWorkflowsToTransfer.map((sw) => ({
workflowId: sw.workflowId,
projectId: toProjectId,
role: sw.role,
})),
);
}
}

View File

@@ -34,6 +34,7 @@ import * as WorkflowExecuteAdditionalData from '@/WorkflowExecuteAdditionalData'
import { TestWebhooks } from '@/TestWebhooks';
import { Logger } from '@/Logger';
import { PermissionChecker } from '@/UserManagement/PermissionChecker';
import type { Project } from '@/databases/entities/Project';
@Service()
export class WorkflowExecutionService {
@@ -161,7 +162,7 @@ export class WorkflowExecutionService {
async executeErrorWorkflow(
workflowId: string,
workflowErrorData: IWorkflowErrorData,
runningUser: User,
runningProject: Project,
): Promise<void> {
// Wrap everything in try/catch to make sure that no errors bubble up and all get caught here
try {
@@ -284,7 +285,7 @@ export class WorkflowExecutionService {
executionMode,
executionData: runExecutionData,
workflowData,
userId: runningUser.id,
projectId: runningProject.id,
};
await this.workflowRunner.run(runData);

View File

@@ -1,4 +1,3 @@
import type { SharedWorkflow } from '@db/entities/SharedWorkflow';
import type { User } from '@db/entities/User';
import type { WorkflowEntity } from '@db/entities/WorkflowEntity';
import type { WorkflowHistory } from '@db/entities/WorkflowHistory';
@@ -18,28 +17,23 @@ export class WorkflowHistoryService {
private readonly sharedWorkflowRepository: SharedWorkflowRepository,
) {}
private async getSharedWorkflow(user: User, workflowId: string): Promise<SharedWorkflow | null> {
return await this.sharedWorkflowRepository.findOne({
where: {
...(!user.hasGlobalScope('workflow:read') && { userId: user.id }),
workflowId,
},
});
}
async getList(
user: User,
workflowId: string,
take: number,
skip: number,
): Promise<Array<Omit<WorkflowHistory, 'nodes' | 'connections'>>> {
const sharedWorkflow = await this.getSharedWorkflow(user, workflowId);
if (!sharedWorkflow) {
const workflow = await this.sharedWorkflowRepository.findWorkflowForUser(workflowId, user, [
'workflow:read',
]);
if (!workflow) {
throw new SharedWorkflowNotFoundError('');
}
return await this.workflowHistoryRepository.find({
where: {
workflowId: sharedWorkflow.workflowId,
workflowId: workflow.id,
},
take,
skip,
@@ -49,13 +43,17 @@ export class WorkflowHistoryService {
}
async getVersion(user: User, workflowId: string, versionId: string): Promise<WorkflowHistory> {
const sharedWorkflow = await this.getSharedWorkflow(user, workflowId);
if (!sharedWorkflow) {
const workflow = await this.sharedWorkflowRepository.findWorkflowForUser(workflowId, user, [
'workflow:read',
]);
if (!workflow) {
throw new SharedWorkflowNotFoundError('');
}
const hist = await this.workflowHistoryRepository.findOne({
where: {
workflowId: sharedWorkflow.workflowId,
workflowId: workflow.id,
versionId,
},
});

View File

@@ -1,30 +1,61 @@
import { Service } from 'typedi';
import { In, type FindOptionsWhere } from '@n8n/typeorm';
import { In } from '@n8n/typeorm';
import type { SharedWorkflow, WorkflowSharingRole } from '@db/entities/SharedWorkflow';
import type { User } from '@db/entities/User';
import { SharedWorkflowRepository } from '@db/repositories/sharedWorkflow.repository';
import { RoleService } from '@/services/role.service';
import type { Scope } from '@n8n/permissions';
import type { ProjectRole } from '@/databases/entities/ProjectRelation';
import type { WorkflowSharingRole } from '@/databases/entities/SharedWorkflow';
@Service()
export class WorkflowSharingService {
constructor(private readonly sharedWorkflowRepository: SharedWorkflowRepository) {}
constructor(
private readonly sharedWorkflowRepository: SharedWorkflowRepository,
private readonly roleService: RoleService,
) {}
/**
* Get the IDs of the workflows that have been shared with the user.
* Returns all IDs if user has the 'workflow:read' scope.
* Get the IDs of the workflows that have been shared with the user based on
* scope or roles.
* If `scopes` is passed the roles are inferred. Alternatively `projectRoles`
* and `workflowRoles` can be passed specifically.
*
* Returns all IDs if user has the 'workflow:read' global scope.
*/
async getSharedWorkflowIds(user: User, roles?: WorkflowSharingRole[]): Promise<string[]> {
const where: FindOptionsWhere<SharedWorkflow> = {};
if (!user.hasGlobalScope('workflow:read')) {
where.userId = user.id;
}
if (roles?.length) {
where.role = In(roles);
async getSharedWorkflowIds(
user: User,
options:
| { scopes: Scope[] }
| { projectRoles: ProjectRole[]; workflowRoles: WorkflowSharingRole[] },
): Promise<string[]> {
if (user.hasGlobalScope('workflow:read')) {
const sharedWorkflows = await this.sharedWorkflowRepository.find({ select: ['workflowId'] });
return sharedWorkflows.map(({ workflowId }) => workflowId);
}
const projectRoles =
'scopes' in options
? this.roleService.rolesWithScope('project', options.scopes)
: options.projectRoles;
const workflowRoles =
'scopes' in options
? this.roleService.rolesWithScope('workflow', options.scopes)
: options.workflowRoles;
const sharedWorkflows = await this.sharedWorkflowRepository.find({
where,
where: {
role: In(workflowRoles),
project: {
projectRelations: {
userId: user.id,
role: In(projectRoles),
},
},
},
select: ['workflowId'],
});
return sharedWorkflows.map(({ workflowId }) => workflowId);
}
}

View File

@@ -7,16 +7,14 @@ import * as ResponseHelper from '@/ResponseHelper';
import * as WorkflowHelpers from '@/WorkflowHelpers';
import type { IWorkflowResponse } from '@/Interfaces';
import config from '@/config';
import { Delete, Get, Patch, Post, Put, RestController } from '@/decorators';
import { SharedWorkflow, type WorkflowSharingRole } from '@db/entities/SharedWorkflow';
import { Delete, Get, Patch, Post, ProjectScope, Put, RestController } from '@/decorators';
import { SharedWorkflow } from '@db/entities/SharedWorkflow';
import { WorkflowEntity } from '@db/entities/WorkflowEntity';
import { SharedWorkflowRepository } from '@db/repositories/sharedWorkflow.repository';
import { TagRepository } from '@db/repositories/tag.repository';
import { WorkflowRepository } from '@db/repositories/workflow.repository';
import { UserRepository } from '@db/repositories/user.repository';
import { validateEntity } from '@/GenericHelpers';
import { ExternalHooks } from '@/ExternalHooks';
import { ListQuery } from '@/requests';
import { WorkflowService } from './workflow.service';
import { License } from '@/License';
import { InternalHooks } from '@/InternalHooks';
@@ -28,15 +26,20 @@ import { Logger } from '@/Logger';
import { BadRequestError } from '@/errors/response-errors/bad-request.error';
import { NotFoundError } from '@/errors/response-errors/not-found.error';
import { InternalServerError } from '@/errors/response-errors/internal-server.error';
import { UnauthorizedError } from '@/errors/response-errors/unauthorized.error';
import { ForbiddenError } from '@/errors/response-errors/forbidden.error';
import { NamingService } from '@/services/naming.service';
import { UserOnboardingService } from '@/services/userOnboarding.service';
import { CredentialsService } from '../credentials/credentials.service';
import { WorkflowRequest } from './workflow.request';
import { EnterpriseWorkflowService } from './workflow.service.ee';
import { WorkflowExecutionService } from './workflowExecution.service';
import { WorkflowSharingService } from './workflowSharing.service';
import { UserManagementMailer } from '@/UserManagement/email';
import { ProjectRepository } from '@/databases/repositories/project.repository';
import { ProjectService } from '@/services/project.service';
import { ApplicationError } from 'n8n-workflow';
import { In, type FindOptionsRelations } from '@n8n/typeorm';
import type { Project } from '@/databases/entities/Project';
import { ProjectRelationRepository } from '@/databases/repositories/projectRelation.repository';
@RestController('/workflows')
export class WorkflowsController {
@@ -53,17 +56,21 @@ export class WorkflowsController {
private readonly workflowRepository: WorkflowRepository,
private readonly workflowService: WorkflowService,
private readonly workflowExecutionService: WorkflowExecutionService,
private readonly workflowSharingService: WorkflowSharingService,
private readonly sharedWorkflowRepository: SharedWorkflowRepository,
private readonly userRepository: UserRepository,
private readonly license: License,
private readonly mailer: UserManagementMailer,
private readonly credentialsService: CredentialsService,
private readonly projectRepository: ProjectRepository,
private readonly projectService: ProjectService,
private readonly projectRelationRepository: ProjectRelationRepository,
) {}
@Post('/')
async create(req: WorkflowRequest.Create) {
delete req.body.id; // delete if sent
// @ts-expect-error: We shouldn't accept this because it can
// mess with relations of other workflows
delete req.body.shared;
const newWorkflow = new WorkflowEntity();
@@ -87,7 +94,7 @@ export class WorkflowsController {
if (this.license.isSharingEnabled()) {
// This is a new workflow, so we simply check if the user has access to
// all used workflows
// all used credentials
const allCredentials = await this.credentialsService.getMany(req.user);
@@ -103,20 +110,46 @@ export class WorkflowsController {
}
}
let savedWorkflow: undefined | WorkflowEntity;
let project: Project | null;
const savedWorkflow = await Db.transaction(async (transactionManager) => {
const workflow = await transactionManager.save<WorkflowEntity>(newWorkflow);
await Db.transaction(async (transactionManager) => {
savedWorkflow = await transactionManager.save<WorkflowEntity>(newWorkflow);
const { projectId } = req.body;
project =
projectId === undefined
? await this.projectRepository.getPersonalProjectForUser(req.user.id, transactionManager)
: await this.projectService.getProjectWithScope(
req.user,
projectId,
['workflow:create'],
transactionManager,
);
const newSharedWorkflow = new SharedWorkflow();
if (typeof projectId === 'string' && project === null) {
throw new BadRequestError(
"You don't have the permissions to save the workflow in this project.",
);
}
Object.assign(newSharedWorkflow, {
// Safe guard in case the personal project does not exist for whatever reason.
if (project === null) {
throw new ApplicationError('No personal project found');
}
const newSharedWorkflow = this.sharedWorkflowRepository.create({
role: 'workflow:owner',
user: req.user,
workflow: savedWorkflow,
projectId: project.id,
workflow,
});
await transactionManager.save<SharedWorkflow>(newSharedWorkflow);
return await this.sharedWorkflowRepository.findWorkflowForUser(
workflow.id,
req.user,
['workflow:read'],
{ em: transactionManager, includeTags: true },
);
});
if (!savedWorkflow) {
@@ -132,26 +165,28 @@ export class WorkflowsController {
});
}
await this.externalHooks.run('workflow.afterCreate', [savedWorkflow]);
void this.internalHooks.onWorkflowCreated(req.user, newWorkflow, false);
const savedWorkflowWithMetaData =
this.enterpriseWorkflowService.addOwnerAndSharings(savedWorkflow);
return savedWorkflow;
// @ts-expect-error: This is added as part of addOwnerAndSharings but
// shouldn't be returned to the frontend
delete savedWorkflowWithMetaData.shared;
await this.externalHooks.run('workflow.afterCreate', [savedWorkflow]);
void this.internalHooks.onWorkflowCreated(req.user, newWorkflow, project!, false);
const scopes = await this.workflowService.getWorkflowScopes(req.user, savedWorkflow.id);
return { ...savedWorkflowWithMetaData, scopes };
}
@Get('/', { middlewares: listQueryMiddleware })
async getAll(req: ListQuery.Request, res: express.Response) {
async getAll(req: WorkflowRequest.GetMany, res: express.Response) {
try {
const roles: WorkflowSharingRole[] = this.license.isSharingEnabled()
? []
: ['workflow:owner'];
const sharedWorkflowIds = await this.workflowSharingService.getSharedWorkflowIds(
req.user,
roles,
);
const { workflows: data, count } = await this.workflowService.getMany(
sharedWorkflowIds,
req.user,
req.listQueryOptions,
!!req.query.includeScopes,
);
res.json({ count, data });
@@ -210,48 +245,60 @@ export class WorkflowsController {
return workflowData;
}
@Get('/:id')
@Get('/:workflowId')
@ProjectScope('workflow:read')
async getWorkflow(req: WorkflowRequest.Get) {
const { id: workflowId } = req.params;
const { workflowId } = req.params;
if (this.license.isSharingEnabled()) {
const relations = ['shared', 'shared.user'];
const relations: FindOptionsRelations<WorkflowEntity> = {
shared: {
project: {
projectRelations: true,
},
},
};
if (!config.getEnv('workflowTagsDisabled')) {
relations.push('tags');
relations.tags = true;
}
const workflow = await this.workflowRepository.get({ id: workflowId }, { relations });
const workflow = await this.sharedWorkflowRepository.findWorkflowForUser(
workflowId,
req.user,
['workflow:read'],
{ includeTags: !config.getEnv('workflowTagsDisabled') },
);
if (!workflow) {
throw new NotFoundError(`Workflow with ID "${workflowId}" does not exist`);
}
const userSharing = workflow.shared?.find((shared) => shared.user.id === req.user.id);
if (!userSharing && !req.user.hasGlobalScope('workflow:read')) {
throw new UnauthorizedError(
'You do not have permission to access this workflow. Ask the owner to share it with you',
);
}
const enterpriseWorkflowService = this.enterpriseWorkflowService;
enterpriseWorkflowService.addOwnerAndSharings(workflow);
await enterpriseWorkflowService.addCredentialsToWorkflow(workflow, req.user);
return workflow;
const workflowWithMetaData = enterpriseWorkflowService.addOwnerAndSharings(workflow);
await enterpriseWorkflowService.addCredentialsToWorkflow(workflowWithMetaData, req.user);
// @ts-expect-error: This is added as part of addOwnerAndSharings but
// shouldn't be returned to the frontend
delete workflowWithMetaData.shared;
const scopes = await this.workflowService.getWorkflowScopes(req.user, workflowId);
return { ...workflowWithMetaData, scopes };
}
// sharing disabled
const extraRelations = config.getEnv('workflowTagsDisabled') ? [] : ['workflow.tags'];
const shared = await this.sharedWorkflowRepository.findSharing(
const workflow = await this.sharedWorkflowRepository.findWorkflowForUser(
workflowId,
req.user,
'workflow:read',
{ extraRelations },
['workflow:read'],
{ includeTags: !config.getEnv('workflowTagsDisabled') },
);
if (!shared) {
if (!workflow) {
this.logger.verbose('User attempted to access a workflow without permissions', {
workflowId,
userId: req.user.id,
@@ -261,12 +308,15 @@ export class WorkflowsController {
);
}
return shared.workflow;
const scopes = await this.workflowService.getWorkflowScopes(req.user, workflowId);
return { ...workflow, scopes };
}
@Patch('/:id')
@Patch('/:workflowId')
@ProjectScope('workflow:update')
async update(req: WorkflowRequest.Update) {
const { id: workflowId } = req.params;
const { workflowId } = req.params;
const forceSave = req.query.forceSave === 'true';
let updateData = new WorkflowEntity();
@@ -288,15 +338,17 @@ export class WorkflowsController {
workflowId,
tags,
isSharingEnabled ? forceSave : true,
isSharingEnabled ? undefined : ['workflow:owner'],
);
return updatedWorkflow;
const scopes = await this.workflowService.getWorkflowScopes(req.user, workflowId);
return { ...updatedWorkflow, scopes };
}
@Delete('/:id')
@Delete('/:workflowId')
@ProjectScope('workflow:delete')
async delete(req: WorkflowRequest.Delete) {
const { id: workflowId } = req.params;
const { workflowId } = req.params;
const workflow = await this.workflowService.delete(req.user, workflowId);
if (!workflow) {
@@ -312,19 +364,30 @@ export class WorkflowsController {
return true;
}
@Post('/run')
@Post('/:workflowId/run')
@ProjectScope('workflow:execute')
async runManually(req: WorkflowRequest.ManualRun) {
if (!req.body.workflowData.id) {
throw new ApplicationError('You cannot execute a workflow without an ID', {
level: 'warning',
});
}
if (req.params.workflowId !== req.body.workflowData.id) {
throw new ApplicationError('Workflow ID in body does not match workflow ID in URL', {
level: 'warning',
});
}
if (this.license.isSharingEnabled()) {
const workflow = this.workflowRepository.create(req.body.workflowData);
if (req.body.workflowData.id !== undefined) {
const safeWorkflow = await this.enterpriseWorkflowService.preventTampering(
workflow,
workflow.id,
req.user,
);
req.body.workflowData.nodes = safeWorkflow.nodes;
}
const safeWorkflow = await this.enterpriseWorkflowService.preventTampering(
workflow,
workflow.id,
req.user,
);
req.body.workflowData.nodes = safeWorkflow.nodes;
}
return await this.workflowExecutionService.executeManually(
@@ -335,6 +398,7 @@ export class WorkflowsController {
}
@Put('/:workflowId/share')
@ProjectScope('workflow:share')
async share(req: WorkflowRequest.Share) {
if (!this.license.isSharingEnabled()) throw new NotFoundError('Route not found');
@@ -348,59 +412,51 @@ export class WorkflowsController {
throw new BadRequestError('Bad request');
}
const isOwnedRes = await this.enterpriseWorkflowService.isOwned(req.user, workflowId);
const { ownsWorkflow } = isOwnedRes;
let { workflow } = isOwnedRes;
const workflow = await this.sharedWorkflowRepository.findWorkflowForUser(workflowId, req.user, [
'workflow:share',
]);
if (!ownsWorkflow || !workflow) {
workflow = undefined;
// Allow owners/admins to share
if (req.user.hasGlobalScope('workflow:share')) {
const sharedRes = await this.sharedWorkflowRepository.getSharing(req.user, workflowId, {
allowGlobalScope: true,
globalScope: 'workflow:share',
});
workflow = sharedRes?.workflow;
}
if (!workflow) {
throw new UnauthorizedError('Forbidden');
}
if (!workflow) {
throw new ForbiddenError();
}
const ownerIds = (
await this.workflowRepository.getSharings(
Db.getConnection().createEntityManager(),
workflowId,
['shared'],
)
)
.filter((e) => e.role === 'workflow:owner')
.map((e) => e.userId);
let newShareeIds: string[] = [];
await Db.transaction(async (trx) => {
// remove all sharings that are not supposed to exist anymore
await this.workflowRepository.pruneSharings(trx, workflowId, [...ownerIds, ...shareWithIds]);
const currentPersonalProjectIDs = workflow.shared
.filter((sw) => sw.role === 'workflow:editor')
.map((sw) => sw.projectId);
const newPersonalProjectIDs = shareWithIds;
const sharings = await this.workflowRepository.getSharings(trx, workflowId);
// extract the new sharings that need to be added
newShareeIds = utils.rightDiff(
[sharings, (sharing) => sharing.userId],
[shareWithIds, (shareeId) => shareeId],
const toShare = utils.rightDiff(
[currentPersonalProjectIDs, (id) => id],
[newPersonalProjectIDs, (id) => id],
);
if (newShareeIds.length) {
const users = await this.userRepository.getByIds(trx, newShareeIds);
await this.sharedWorkflowRepository.share(trx, workflow, users);
}
const toUnshare = utils.rightDiff(
[newPersonalProjectIDs, (id) => id],
[currentPersonalProjectIDs, (id) => id],
);
await trx.delete(SharedWorkflow, {
workflowId,
projectId: In(toUnshare),
});
await this.enterpriseWorkflowService.shareWithProjects(workflow, toShare, trx);
newShareeIds = toShare;
});
void this.internalHooks.onWorkflowSharingUpdate(workflowId, req.user.id, shareWithIds);
const projectsRelations = await this.projectRelationRepository.findBy({
projectId: In(newShareeIds),
role: 'project:personalOwner',
});
await this.mailer.notifyWorkflowShared({
sharer: req.user,
newShareeIds,
newShareeIds: projectsRelations.map((pr) => pr.userId),
workflow,
});
}

View File

@@ -1,14 +1,21 @@
import type { IUser } from 'n8n-workflow';
import type { SharedWorkflow } from '@db/entities/SharedWorkflow';
import type { WorkflowEntity } from '@db/entities/WorkflowEntity';
import type { SlimProject } from '@/requests';
export interface WorkflowWithSharingsAndCredentials extends Omit<WorkflowEntity, 'shared'> {
ownedBy?: IUser | null;
sharedWith?: IUser[];
homeProject?: SlimProject;
sharedWithProjects?: SlimProject[];
usedCredentials?: CredentialUsedByWorkflow[];
shared?: SharedWorkflow[];
}
export interface WorkflowWithSharingsMetaDataAndCredentials extends Omit<WorkflowEntity, 'shared'> {
homeProject?: SlimProject | null;
sharedWithProjects: SlimProject[];
usedCredentials?: CredentialUsedByWorkflow[];
}
export interface CredentialUsedByWorkflow {
id: string;
name: string;