Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(core): Allow creating workflows in other projects than the personal one #8662

Merged
merged 8 commits into from
Feb 21, 2024
34 changes: 31 additions & 3 deletions packages/cli/src/services/project.service.ts
Original file line number Diff line number Diff line change
@@ -1,16 +1,22 @@
import type { Project } from '@/databases/entities/Project';
import { ProjectRelation, type ProjectRole } from '@/databases/entities/ProjectRelation';
import { ProjectRelation } from '@/databases/entities/ProjectRelation';
import type { ProjectRole } from '@/databases/entities/ProjectRelation';
import type { User } from '@/databases/entities/User';
import { ProjectRepository } from '@/databases/repositories/project.repository';
import { ProjectRelationRepository } from '@/databases/repositories/projectRelation.repository';
import { Not, type EntityManager } from '@n8n/typeorm';
import { Service } from 'typedi';
import { type Scope } from '@n8n/permissions';
import { In } from '@n8n/typeorm';
import { RoleService } from './role.service';

@Service()
export class ProjectService {
constructor(
private projectsRepository: ProjectRepository,
private projectRelationRepository: ProjectRelationRepository,
private readonly projectsRepository: ProjectRepository,
private readonly projectRepository: ProjectRepository,
despairblue marked this conversation as resolved.
Show resolved Hide resolved
private readonly projectRelationRepository: ProjectRelationRepository,
private readonly roleService: RoleService,
) {}

async getAccessibleProjects(user: User): Promise<Project[]> {
Expand Down Expand Up @@ -110,4 +116,26 @@ export class ProjectService {
),
);
}

async getProjectWithScope(user: User, projectId: string, scope: Scope) {
const projectRoles = this.roleService.rolesWithScope('project', [scope]);

return await this.projectRepository.findOne({
where: {
id: projectId,
projectRelations: {
role: In(projectRoles),
userId: user.id,
},
},
});
}

async addUser(projectId: string, userId: string, role: ProjectRole) {
return await this.projectRelationRepository.save({
projectId,
userId,
role,
});
}
}
3 changes: 2 additions & 1 deletion packages/cli/src/workflows/workflow.request.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import type { INode, IConnections, IWorkflowSettings, IRunData, IPinData } from

export declare namespace WorkflowRequest {
type CreateUpdatePayload = Partial<{
id: string; // delete if sent
id: string; // deleted if sent
name: string;
nodes: INode[];
connections: IConnections;
Expand All @@ -13,6 +13,7 @@ export declare namespace WorkflowRequest {
tags: string[];
hash: string;
meta: Record<string, unknown>;
projectId: string;
}>;

type ManualRunPayload = {
Expand Down
27 changes: 22 additions & 5 deletions packages/cli/src/workflows/workflows.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ import {
Put,
RestController,
} from '@/decorators';
import { SharedWorkflow, type WorkflowSharingRole } from '@db/entities/SharedWorkflow';
import type { SharedWorkflow, WorkflowSharingRole } from '@db/entities/SharedWorkflow';
import { WorkflowEntity } from '@db/entities/WorkflowEntity';
import { SharedWorkflowRepository } from '@db/repositories/sharedWorkflow.repository';
import { TagRepository } from '@db/repositories/tag.repository';
Expand Down Expand Up @@ -48,6 +48,8 @@ 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 '../../../workflow/src';

@Authorized()
@RestController('/workflows')
Expand All @@ -72,6 +74,7 @@ export class WorkflowsController {
private readonly mailer: UserManagementMailer,
private readonly credentialsService: CredentialsService,
private readonly projectRepository: ProjectRepository,
private readonly projectService: ProjectService,
) {}

@Post('/')
Expand Down Expand Up @@ -121,14 +124,28 @@ export class WorkflowsController {
await Db.transaction(async (transactionManager) => {
savedWorkflow = await transactionManager.save<WorkflowEntity>(newWorkflow);

const project = await this.projectRepository.getPersonalProjectForUserOrFail(req.user.id);
const { projectId } = req.body;
const project =
projectId === undefined
? await this.projectRepository.getPersonalProjectForUser(req.user.id)
: await this.projectService.getProjectWithScope(req.user, projectId, 'workflow:create');
despairblue marked this conversation as resolved.
Show resolved Hide resolved

const newSharedWorkflow = new SharedWorkflow();
if (typeof projectId === 'string' && project === null) {
despairblue marked this conversation as resolved.
Show resolved Hide resolved
throw new BadRequestError(
"You don't have the permissions to save the workflow in this project.",
);
}

// Safe guard in case the personal project does not exist for whatever reason.
if (project === null) {
throw new ApplicationError('No personal project found');
}

Object.assign(newSharedWorkflow, {
const newSharedWorkflow = this.sharedWorkflowRepository.create({
role: 'workflow:owner',
// TODO: remove when https://linear.app/n8n/issue/PAY-1353/make-sure-that-sharedworkflowuserid-is-not-used-anymore-to-check lands
user: req.user,
project,
projectId: project.id,
workflow: savedWorkflow,
});

Expand Down
204 changes: 204 additions & 0 deletions packages/cli/test/integration/services/project.service.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,204 @@
import { ProjectService } from '@/services/project.service';
import * as testDb from '../shared/testDb';
import Container from 'typedi';
import { createMember } from '../shared/db/users';
import { ProjectRepository } from '@/databases/repositories/project.repository';
import { ProjectRelationRepository } from '@/databases/repositories/projectRelation.repository';
import type { ProjectRole } from '@/databases/entities/ProjectRelation';
import type { Scope } from '@n8n/permissions';

let projectRepository: ProjectRepository;
let projectService: ProjectService;
let projectRelationRepository: ProjectRelationRepository;

beforeAll(async () => {
await testDb.init();

projectRepository = Container.get(ProjectRepository);
projectService = Container.get(ProjectService);
projectRelationRepository = Container.get(ProjectRelationRepository);
});

afterAll(async () => {
await testDb.terminate();
});

afterEach(async () => {
await testDb.truncate(['User']);
});

describe('ProjectService', () => {
describe('addUser', () => {
it.each([
'project:viewer',
'project:admin',
'project:editor',
'project:personalOwner',
] as ProjectRole[])(
'creates a relation between the user and the project using the role %s',
async (role) => {
//
// ARRANGE
//
const member = await createMember();
const project = await projectRepository.save(
projectRepository.create({
name: 'Team Project',
type: 'team',
}),
);

//
// ACT
//
await projectService.addUser(project.id, member.id, role);

//
// ASSERT
//
await projectRelationRepository.findOneOrFail({
where: { userId: member.id, projectId: project.id, role },
});
},
);

it('changes the role the user has in the project if the user is already part of the project, instead of creating a new relationship', async () => {
//
// ARRANGE
//
const member = await createMember();
const project = await projectRepository.save(
projectRepository.create({
name: 'Team Project',
type: 'team',
}),
);
await projectService.addUser(project.id, member.id, 'project:viewer');

await projectRelationRepository.findOneOrFail({
where: { userId: member.id, projectId: project.id, role: 'project:viewer' },
});

//
// ACT
//
await projectService.addUser(project.id, member.id, 'project:admin');

//
// ASSERT
//
const relationships = await projectRelationRepository.find({
where: { userId: member.id, projectId: project.id },
});

expect(relationships).toHaveLength(1);
expect(relationships[0]).toHaveProperty('role', 'project:admin');
});
});

describe('getProjectWithScope', () => {
it.each([
{ role: 'project:admin', scope: 'workflow:list' },
{ role: 'project:admin', scope: 'workflow:create' },
] as Array<{
role: ProjectRole;
scope: Scope;
}>)(
'should return the project if the user has the role $role and wants the scope $scope',
async ({ role, scope }) => {
//
// ARRANGE
//
const projectOwner = await createMember();
const project = await projectRepository.save(
projectRepository.create({
name: 'Team Project',
type: 'team',
}),
);
await projectService.addUser(project.id, projectOwner.id, role);

//
// ACT
//
const projectFromService = await projectService.getProjectWithScope(
projectOwner,
project.id,
scope,
);

//
// ASSERT
//
if (projectFromService === null) {
fail('Expected projectFromService not to be null');
}
expect(project.id).toBe(projectFromService.id);
},
);

it.each([
{ role: 'project:viewer', scope: 'workflow:create' },
{ role: 'project:viewer', scope: 'credential:create' },
] as Array<{
role: ProjectRole;
scope: Scope;
}>)(
'should return the project if the user has the role $role and wants the scope $scope',
async ({ role, scope }) => {
//
// ARRANGE
//
const projectViewer = await createMember();
const project = await projectRepository.save(
projectRepository.create({
name: 'Team Project',
type: 'team',
}),
);
await projectService.addUser(project.id, projectViewer.id, role);

//
// ACT
//
const projectFromService = await projectService.getProjectWithScope(
projectViewer,
project.id,
scope,
);

//
// ASSERT
//
expect(projectFromService).toBeNull();
},
);

it('should not return the project if the user is not part of it', async () => {
//
// ARRANGE
//
const member = await createMember();
const project = await projectRepository.save(
projectRepository.create({
name: 'Team Project',
type: 'team',
}),
);

//
// ACT
//
const projectFromService = await projectService.getProjectWithScope(
member,
project.id,
'workflow:list',
);

//
// ASSERT
//
expect(projectFromService).toBeNull();
});
});
});
Loading
Loading