feat(core): Allow transferring workflows from any project to any team project (#9534)

This commit is contained in:
Danny Martini
2024-06-03 16:57:04 +02:00
committed by GitHub
parent 68420ca6be
commit d6db8cbf23
14 changed files with 572 additions and 29 deletions

View File

@@ -1,6 +1,16 @@
import { ResponseError } from './abstract/response.error';
export class NotFoundError extends ResponseError {
static isDefinedAndNotNull<T>(
value: T | undefined | null,
message: string,
hint?: string,
): asserts value is T {
if (value === undefined || value === null) {
throw new NotFoundError(message, hint);
}
}
constructor(message: string, hint: string | undefined = undefined) {
super(message, 404, 404, hint);
}

View File

@@ -0,0 +1,7 @@
import { ResponseError } from './abstract/response.error';
export class TransferWorkflowError extends ResponseError {
constructor(message: string) {
super(message, 400, 400);
}
}

View File

@@ -9,6 +9,7 @@ export const GLOBAL_OWNER_SCOPES: Scope[] = [
'credential:delete',
'credential:list',
'credential:share',
'credential:move',
'communityPackage:install',
'communityPackage:uninstall',
'communityPackage:update',
@@ -68,6 +69,7 @@ export const GLOBAL_OWNER_SCOPES: Scope[] = [
'workflow:list',
'workflow:share',
'workflow:execute',
'workflow:move',
'workersView:manage',
'project:list',
'project:create',

View File

@@ -13,11 +13,13 @@ export const REGULAR_PROJECT_ADMIN_SCOPES: Scope[] = [
'workflow:delete',
'workflow:list',
'workflow:execute',
'workflow:move',
'credential:create',
'credential:read',
'credential:update',
'credential:delete',
'credential:list',
'credential:move',
'project:list',
'project:read',
'project:update',
@@ -32,12 +34,14 @@ export const PERSONAL_PROJECT_OWNER_SCOPES: Scope[] = [
'workflow:list',
'workflow:execute',
'workflow:share',
'workflow:move',
'credential:create',
'credential:read',
'credential:update',
'credential:delete',
'credential:list',
'credential:share',
'credential:move',
'project:list',
'project:read',
];

View File

@@ -5,6 +5,7 @@ export const CREDENTIALS_SHARING_OWNER_SCOPES: Scope[] = [
'credential:update',
'credential:delete',
'credential:share',
'credential:move',
];
export const CREDENTIALS_SHARING_USER_SCOPES: Scope[] = ['credential:read'];
@@ -15,6 +16,7 @@ export const WORKFLOW_SHARING_OWNER_SCOPES: Scope[] = [
'workflow:delete',
'workflow:execute',
'workflow:share',
'workflow:move',
];
export const WORKFLOW_SHARING_EDITOR_SCOPES: Scope[] = [

View File

@@ -54,5 +54,11 @@ export declare namespace WorkflowRequest {
type Share = AuthenticatedRequest<{ workflowId: string }, {}, { shareWithIds: string[] }>;
type Transfer = AuthenticatedRequest<
{ workflowId: string },
{},
{ destinationProjectId: string }
>;
type FromUrl = AuthenticatedRequest<{}, {}, {}, { url?: string }>;
}

View File

@@ -1,6 +1,6 @@
import { Service } from 'typedi';
import omit from 'lodash/omit';
import { ApplicationError, NodeOperationError } from 'n8n-workflow';
import { ApplicationError, NodeOperationError, WorkflowActivationError } from 'n8n-workflow';
import type { CredentialsEntity } from '@db/entities/CredentialsEntity';
import type { User } from '@db/entities/User';
@@ -20,6 +20,10 @@ import type {
import { OwnershipService } from '@/services/ownership.service';
import { In, type EntityManager } from '@n8n/typeorm';
import { Project } from '@/databases/entities/Project';
import { ProjectService } from '@/services/project.service';
import { ActiveWorkflowManager } from '@/ActiveWorkflowManager';
import { TransferWorkflowError } from '@/errors/response-errors/transfer-workflow.error';
import { SharedWorkflow } from '@/databases/entities/SharedWorkflow';
@Service()
export class EnterpriseWorkflowService {
@@ -30,6 +34,8 @@ export class EnterpriseWorkflowService {
private readonly credentialsRepository: CredentialsRepository,
private readonly credentialsService: CredentialsService,
private readonly ownershipService: OwnershipService,
private readonly projectService: ProjectService,
private readonly activeWorkflowManager: ActiveWorkflowManager,
) {}
async shareWithProjects(
@@ -235,4 +241,100 @@ export class EnterpriseWorkflowService {
);
});
}
async transferOne(user: User, workflowId: string, destinationProjectId: string) {
// 1. get workflow
const workflow = await this.sharedWorkflowRepository.findWorkflowForUser(workflowId, user, [
'workflow:move',
]);
NotFoundError.isDefinedAndNotNull(
workflow,
`Could not find workflow with the id "${workflowId}". Make sure you have the permission to delete it.`,
);
// 2. get owner-sharing
const ownerSharing = workflow.shared.find((s) => s.role === 'workflow:owner')!;
NotFoundError.isDefinedAndNotNull(
ownerSharing,
`Could not find owner for workflow ${workflow.id}`,
);
// 3. get source project
const sourceProject = ownerSharing.project;
// 4. get destination project
const destinationProject = await this.projectService.getProjectWithScope(
user,
destinationProjectId,
['workflow:create'],
);
NotFoundError.isDefinedAndNotNull(
destinationProject,
`Could not find project with the id "${destinationProjectId}". Make sure you have the permission to create workflows in it.`,
);
// 5. checks
if (sourceProject.id === destinationProject.id) {
throw new TransferWorkflowError(
"You can't transfer a workflow into the project that's already owning it.",
);
}
if (sourceProject.type !== 'team' && sourceProject.type !== 'personal') {
throw new TransferWorkflowError(
'You can only transfer workflows out of personal or team projects.',
);
}
if (destinationProject.type !== 'team') {
throw new TransferWorkflowError('You can only transfer workflows into team projects.');
}
// 6. deactivate workflow if necessary
const wasActive = workflow.active;
if (wasActive) {
await this.activeWorkflowManager.remove(workflowId);
}
// 7. transfer the workflow
await this.workflowRepository.manager.transaction(async (trx) => {
// remove all sharings
await trx.remove(workflow.shared);
// create new owner-sharing
await trx.save(
trx.create(SharedWorkflow, {
workflowId: workflow.id,
projectId: destinationProject.id,
role: 'workflow:owner',
}),
);
});
// 8. try to activate it again if it was active
if (wasActive) {
try {
await this.activeWorkflowManager.add(workflowId, 'update');
return;
} catch (error) {
await this.workflowRepository.updateActiveState(workflowId, false);
// Since the transfer worked we return a 200 but also return the
// activation error as data.
if (error instanceof WorkflowActivationError) {
return {
error: error.toJSON
? error.toJSON()
: {
name: error.name,
message: error.message,
},
};
}
throw error;
}
}
return;
}
}

View File

@@ -40,6 +40,7 @@ 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';
import { z } from 'zod';
@RestController('/workflows')
export class WorkflowsController {
@@ -460,4 +461,16 @@ export class WorkflowsController {
workflow,
});
}
@Put('/:workflowId/transfer')
@ProjectScope('workflow:move')
async transfer(req: WorkflowRequest.Transfer) {
const body = z.object({ destinationProjectId: z.string() }).parse(req.body);
return await this.enterpriseWorkflowService.transferOne(
req.user,
req.params.workflowId,
body.destinationProjectId,
);
}
}