feat(core): Allow custom project roles from being set to a user project relation (#18926)

This commit is contained in:
Guillaume Jacquart
2025-09-01 10:22:33 +02:00
committed by GitHub
parent 5b5f60212a
commit 027edbe89d
32 changed files with 597 additions and 121 deletions

View File

@@ -16,6 +16,7 @@ describe('InviteUsersRequestDto', () => {
request: [ request: [
{ email: 'user1@example.com', role: 'global:member' }, { email: 'user1@example.com', role: 'global:member' },
{ email: 'user2@example.com', role: 'global:admin' }, { email: 'user2@example.com', role: 'global:admin' },
{ email: 'user3@example.com', role: 'custom:role' },
], ],
}, },
])('should validate $name', ({ request }) => { ])('should validate $name', ({ request }) => {
@@ -42,7 +43,7 @@ describe('InviteUsersRequestDto', () => {
request: [ request: [
{ {
email: 'user@example.com', email: 'user@example.com',
role: 'invalid-role', role: 'global:owner',
}, },
], ],
expectedErrorPath: [0, 'role'], expectedErrorPath: [0, 'role'],

View File

@@ -1,10 +1,9 @@
import { assignableGlobalRoleSchema } from '@n8n/permissions';
import { z } from 'zod'; import { z } from 'zod';
const roleSchema = z.enum(['global:member', 'global:admin']);
const invitedUserSchema = z.object({ const invitedUserSchema = z.object({
email: z.string().email(), email: z.string().email(),
role: roleSchema.default('global:member'), role: assignableGlobalRoleSchema.default('global:member'),
}); });
const invitationsSchema = z.array(invitedUserSchema); const invitationsSchema = z.array(invitedUserSchema);

View File

@@ -93,7 +93,7 @@ describe('AddUsersToProjectDto', () => {
relations: [ relations: [
{ {
userId: 'user-123', userId: 'user-123',
role: 'invalid-role', role: '',
}, },
], ],
}, },

View File

@@ -31,13 +31,6 @@ describe('ChangeUserRoleInProject', () => {
}, },
expectedErrorPath: ['role'], expectedErrorPath: ['role'],
}, },
{
name: 'invalid role value',
request: {
role: 'invalid-role',
},
expectedErrorPath: ['role'],
},
{ {
name: 'personal owner role', name: 'personal owner role',
request: { role: PROJECT_OWNER_ROLE_SLUG }, request: { role: PROJECT_OWNER_ROLE_SLUG },

View File

@@ -120,7 +120,7 @@ describe('UpdateProjectDto', () => {
relations: [ relations: [
{ {
userId: 'user-123', userId: 'user-123',
role: 'invalid-role', role: 'project:personalOwner',
}, },
], ],
}, },

View File

@@ -1,6 +1,6 @@
import { teamRoleSchema } from '@n8n/permissions'; import { assignableProjectRoleSchema } from '@n8n/permissions';
import { Z } from 'zod-class'; import { Z } from 'zod-class';
export class ChangeUserRoleInProject extends Z.class({ export class ChangeUserRoleInProject extends Z.class({
role: teamRoleSchema, role: assignableProjectRoleSchema,
}) {} }) {}

View File

@@ -11,27 +11,28 @@ describe('RoleChangeRequestDto', () => {
expect(result.error?.issues[0].message).toBe('New role is required'); expect(result.error?.issues[0].message).toBe('New role is required');
}); });
it('should fail validation with invalid newRoleName', () => { it('should fail validation with invalid newRoleName global:owner', () => {
const data = { const data = {
newRoleName: 'invalidRole', newRoleName: 'global:owner',
}; };
const result = RoleChangeRequestDto.safeParse(data); const result = RoleChangeRequestDto.safeParse(data);
expect(result.success).toBe(false); expect(result.success).toBe(false);
expect(result.error?.issues[0].path[0]).toBe('newRoleName'); expect(result.error?.issues[0].path[0]).toBe('newRoleName');
expect(result.error?.issues[0].message).toBe( expect(result.error?.issues[0].message).toBe('This global role value is not assignable');
"Invalid enum value. Expected 'global:admin' | 'global:member', received 'invalidRole'",
);
}); });
it('should pass validation with valid data', () => { it.each<string>(['global:admin', 'custom:role'])(
'should pass validation with valid newRoleName %s',
(role) => {
const data = { const data = {
newRoleName: 'global:admin', newRoleName: role,
}; };
const result = RoleChangeRequestDto.safeParse(data); const result = RoleChangeRequestDto.safeParse(data);
expect(result.success).toBe(true); expect(result.success).toBe(true);
}); },
);
}); });

View File

@@ -1,8 +1,11 @@
import { z } from 'zod'; import { assignableGlobalRoleSchema } from '@n8n/permissions';
import { Z } from 'zod-class'; import { Z } from 'zod-class';
export class RoleChangeRequestDto extends Z.class({ export class RoleChangeRequestDto extends Z.class({
newRoleName: z.enum(['global:admin', 'global:member'], { newRoleName: assignableGlobalRoleSchema
required_error: 'New role is required', // enforce required (non-nullable, non-optional) with custom error message on undefined
.nullish()
.refine((val): val is NonNullable<typeof val> => val !== null && typeof val !== 'undefined', {
message: 'New role is required',
}), }),
}) {} }) {}

View File

@@ -82,7 +82,7 @@ describe('project.schema', () => {
}, },
{ {
name: 'invalid role', name: 'invalid role',
value: { userId: 'user-123', role: 'invalid-role' }, value: { userId: 'user-123', role: 'project:personalOwner' },
expected: false, expected: false,
}, },
{ {

View File

@@ -1,4 +1,4 @@
import { teamRoleSchema } from '@n8n/permissions'; import { assignableProjectRoleSchema } from '@n8n/permissions';
import { z } from 'zod'; import { z } from 'zod';
export const projectNameSchema = z.string().min(1).max(255); export const projectNameSchema = z.string().min(1).max(255);
@@ -16,6 +16,6 @@ export const projectDescriptionSchema = z.string().max(512);
export const projectRelationSchema = z.object({ export const projectRelationSchema = z.object({
userId: z.string().min(1), userId: z.string().min(1),
role: teamRoleSchema, role: assignableProjectRoleSchema,
}); });
export type ProjectRelation = z.infer<typeof projectRelationSchema>; export type ProjectRelation = z.infer<typeof projectRelationSchema>;

View File

@@ -1,11 +1,16 @@
import type { Project, User, ProjectRelation } from '@n8n/db'; import type { Project, User, ProjectRelation } from '@n8n/db';
import { ProjectRelationRepository, ProjectRepository } from '@n8n/db'; import { ProjectRelationRepository, ProjectRepository } from '@n8n/db';
import { Container } from '@n8n/di'; import { Container } from '@n8n/di';
import { PROJECT_OWNER_ROLE_SLUG, type CustomRole } from '@n8n/permissions'; import type { AssignableProjectRole } from '@n8n/permissions';
import { PROJECT_OWNER_ROLE_SLUG } from '@n8n/permissions';
import { randomName } from '../random'; import { randomName } from '../random';
export const linkUserToProject = async (user: User, project: Project, role: CustomRole) => { export const linkUserToProject = async (
user: User,
project: Project,
role: AssignableProjectRole,
) => {
const projectRelationRepository = Container.get(ProjectRelationRepository); const projectRelationRepository = Container.get(ProjectRelationRepository);
await projectRelationRepository.save( await projectRelationRepository.save(
projectRelationRepository.create({ projectRelationRepository.create({
@@ -68,7 +73,7 @@ export const getProjectRelations = async ({
export const getProjectRoleForUser = async ( export const getProjectRoleForUser = async (
projectId: string, projectId: string,
userId: string, userId: string,
): Promise<CustomRole | undefined> => { ): Promise<AssignableProjectRole | undefined> => {
return ( return (
await Container.get(ProjectRelationRepository).findOne({ await Container.get(ProjectRelationRepository).findOne({
where: { projectId, userId }, where: { projectId, userId },

View File

@@ -327,12 +327,6 @@ export namespace ListQuery {
}; };
} }
export type ProjectRole =
| 'project:personalOwner'
| 'project:admin'
| 'project:editor'
| 'project:viewer';
export interface IGetExecutionsQueryFilter { export interface IGetExecutionsQueryFilter {
id?: FindOperator<string> | string; id?: FindOperator<string> | string;
finished?: boolean; finished?: boolean;

View File

@@ -1,6 +1,6 @@
import { DatabaseConfig } from '@n8n/config'; import { DatabaseConfig } from '@n8n/config';
import { Service } from '@n8n/di'; import { Service } from '@n8n/di';
import { DataSource, EntityManager, Repository } from '@n8n/typeorm'; import { DataSource, EntityManager, In, Repository } from '@n8n/typeorm';
import { UserError } from 'n8n-workflow'; import { UserError } from 'n8n-workflow';
import { Role } from '../entities'; import { Role } from '../entities';
@@ -25,6 +25,13 @@ export class RoleRepository extends Repository<Role> {
}); });
} }
async findBySlugs(slugs: string[], roleType: 'global' | 'project' | 'workflow' | 'credential') {
return await this.find({
where: { slug: In(slugs), roleType },
relations: ['scopes'],
});
}
async removeBySlug(slug: string) { async removeBySlug(slug: string) {
const result = await this.delete({ slug }); const result = await this.delete({ slug });
if (result.affected !== 1) { if (result.affected !== 1) {

View File

@@ -9,9 +9,10 @@ import {
roleNamespaceSchema, roleNamespaceSchema,
globalRoleSchema, globalRoleSchema,
assignableGlobalRoleSchema, assignableGlobalRoleSchema,
projectRoleSchema, systemProjectRoleSchema,
credentialSharingRoleSchema, credentialSharingRoleSchema,
workflowSharingRoleSchema, workflowSharingRoleSchema,
customProjectRoleSchema,
} from '../schemas.ee'; } from '../schemas.ee';
describe('roleNamespaceSchema', () => { describe('roleNamespaceSchema', () => {
@@ -49,8 +50,6 @@ describe('assignableGlobalRoleSchema', () => {
{ name: 'excluded role: global:owner', value: 'global:owner', expected: false }, { name: 'excluded role: global:owner', value: 'global:owner', expected: false },
{ name: 'valid role: global:admin', value: 'global:admin', expected: true }, { name: 'valid role: global:admin', value: 'global:admin', expected: true },
{ name: 'valid role: global:member', value: 'global:member', expected: true }, { name: 'valid role: global:member', value: 'global:member', expected: true },
{ name: 'invalid role', value: 'global:invalid', expected: false },
{ name: 'invalid prefix', value: 'invalid:admin', expected: false },
{ name: 'object value', value: {}, expected: false }, { name: 'object value', value: {}, expected: false },
])('should validate $name', ({ value, expected }) => { ])('should validate $name', ({ value, expected }) => {
const result = assignableGlobalRoleSchema.safeParse(value); const result = assignableGlobalRoleSchema.safeParse(value);
@@ -58,7 +57,7 @@ describe('assignableGlobalRoleSchema', () => {
}); });
}); });
describe('projectRoleSchema', () => { describe('systemProjectRoleSchema', () => {
test.each([ test.each([
{ {
name: `valid role: ${PROJECT_OWNER_ROLE_SLUG}`, name: `valid role: ${PROJECT_OWNER_ROLE_SLUG}`,
@@ -82,7 +81,7 @@ describe('projectRoleSchema', () => {
}, },
{ name: 'invalid role', value: 'invalid-role', expected: false }, { name: 'invalid role', value: 'invalid-role', expected: false },
])('should validate $name', ({ value, expected }) => { ])('should validate $name', ({ value, expected }) => {
const result = projectRoleSchema.safeParse(value); const result = systemProjectRoleSchema.safeParse(value);
expect(result.success).toBe(expected); expect(result.success).toBe(expected);
}); });
}); });
@@ -114,3 +113,15 @@ describe('workflowSharingRoleSchema', () => {
expect(result.success).toBe(expected); expect(result.success).toBe(expected);
}); });
}); });
describe('customProjectRoleSchema', () => {
test.each([
{ name: 'valid role: custom:role', value: 'custom:role', expected: true },
{ name: 'undefined value', value: undefined, expected: false },
{ name: 'empty string', value: '', expected: false },
{ name: 'system role', value: PROJECT_ADMIN_ROLE_SLUG, expected: false },
])('should validate $name', ({ value, expected }) => {
const result = customProjectRoleSchema.safeParse(value);
expect(result.success).toBe(expected);
});
});

View File

@@ -7,6 +7,9 @@ export * from './roles/role-maps.ee';
export * from './roles/all-roles'; export * from './roles/all-roles';
export { export {
systemProjectRoleSchema,
assignableProjectRoleSchema,
assignableGlobalRoleSchema,
projectRoleSchema, projectRoleSchema,
teamRoleSchema, teamRoleSchema,
roleSchema, roleSchema,

View File

@@ -7,21 +7,42 @@ export const roleNamespaceSchema = z.enum(['global', 'project', 'credential', 'w
export const globalRoleSchema = z.enum(['global:owner', 'global:admin', 'global:member']); export const globalRoleSchema = z.enum(['global:owner', 'global:admin', 'global:member']);
export const assignableGlobalRoleSchema = globalRoleSchema.exclude([ const customGlobalRoleSchema = z
.string()
.nonempty()
.refine((val) => !globalRoleSchema.safeParse(val).success, {
message: 'This global role value is not assignable',
});
export const assignableGlobalRoleSchema = z.union([
globalRoleSchema.exclude([
'global:owner', // Owner cannot be changed 'global:owner', // Owner cannot be changed
]),
customGlobalRoleSchema,
]); ]);
export const personalRoleSchema = z.enum([ export const personalRoleSchema = z.enum([
'project:personalOwner', // personalOwner is only used for personal projects 'project:personalOwner', // personalOwner is only used for personal projects
]); ]);
// Those are the system roles for projects assignable to a user
export const teamRoleSchema = z.enum(['project:admin', 'project:editor', 'project:viewer']); export const teamRoleSchema = z.enum(['project:admin', 'project:editor', 'project:viewer']);
export const customRoleSchema = z.string().refine((val) => val !== PROJECT_OWNER_ROLE_SLUG, { // Custom project role can be anything but the system roles
message: `'${PROJECT_OWNER_ROLE_SLUG}' is not assignable`, export const customProjectRoleSchema = z
.string()
.nonempty()
.refine((val) => val !== PROJECT_OWNER_ROLE_SLUG && !teamRoleSchema.safeParse(val).success, {
message: 'This global role value is not assignable',
}); });
export const projectRoleSchema = z.union([personalRoleSchema, teamRoleSchema]); // Those are all the system roles for projects
export const systemProjectRoleSchema = z.union([personalRoleSchema, teamRoleSchema]);
// Those are the roles that can be assigned to a user for a project (all roles except personalOwner)
export const assignableProjectRoleSchema = z.union([teamRoleSchema, customProjectRoleSchema]);
export const projectRoleSchema = z.union([systemProjectRoleSchema, customProjectRoleSchema]);
export const credentialSharingRoleSchema = z.enum(['credential:owner', 'credential:user']); export const credentialSharingRoleSchema = z.enum(['credential:owner', 'credential:user']);

View File

@@ -5,11 +5,12 @@ import type {
assignableGlobalRoleSchema, assignableGlobalRoleSchema,
credentialSharingRoleSchema, credentialSharingRoleSchema,
globalRoleSchema, globalRoleSchema,
projectRoleSchema,
Role, Role,
systemProjectRoleSchema,
roleNamespaceSchema, roleNamespaceSchema,
teamRoleSchema, teamRoleSchema,
workflowSharingRoleSchema, workflowSharingRoleSchema,
assignableProjectRoleSchema,
} from './schemas.ee'; } from './schemas.ee';
import { ALL_API_KEY_SCOPES } from './scope-information'; import { ALL_API_KEY_SCOPES } from './scope-information';
@@ -58,8 +59,8 @@ export type AssignableGlobalRole = z.infer<typeof assignableGlobalRoleSchema>;
export type CredentialSharingRole = z.infer<typeof credentialSharingRoleSchema>; export type CredentialSharingRole = z.infer<typeof credentialSharingRoleSchema>;
export type WorkflowSharingRole = z.infer<typeof workflowSharingRoleSchema>; export type WorkflowSharingRole = z.infer<typeof workflowSharingRoleSchema>;
export type TeamProjectRole = z.infer<typeof teamRoleSchema>; export type TeamProjectRole = z.infer<typeof teamRoleSchema>;
export type ProjectRole = z.infer<typeof projectRoleSchema>; export type ProjectRole = z.infer<typeof systemProjectRoleSchema>;
export type CustomRole = string; export type AssignableProjectRole = z.infer<typeof assignableProjectRoleSchema>;
/** Union of all possible role types in the system */ /** Union of all possible role types in the system */
export type AllRoleTypes = GlobalRole | ProjectRole | WorkflowSharingRole | CredentialSharingRole; export type AllRoleTypes = GlobalRole | ProjectRole | WorkflowSharingRole | CredentialSharingRole;

View File

@@ -64,7 +64,10 @@ export class ProjectController {
uiContext: payload.uiContext, uiContext: payload.uiContext,
}); });
const relations = await this.projectsService.getProjectRelations(project.id); const relation = await this.projectsService.getProjectRelationForUserAndProject(
req.user.id,
project.id,
);
return { return {
...project, ...project,
@@ -72,10 +75,7 @@ export class ProjectController {
scopes: [ scopes: [
...combineScopes({ ...combineScopes({
global: getAuthPrincipalScopes(req.user), global: getAuthPrincipalScopes(req.user),
project: project: relation?.role.scopes.map((scope) => scope.slug) ?? [],
relations
.find((pr) => pr.userId === req.user.id)
?.role.scopes.map((scope) => scope.slug) || [],
}), }),
], ],
}; };
@@ -156,14 +156,14 @@ export class ProjectController {
throw new NotFoundError('Could not find a personal project for this user'); throw new NotFoundError('Could not find a personal project for this user');
} }
const relations = await this.projectsService.getProjectRelations(project.id); const relation = await this.projectsService.getProjectRelationForUserAndProject(
req.user.id,
project.id,
);
const scopes: Scope[] = [ const scopes: Scope[] = [
...combineScopes({ ...combineScopes({
global: getAuthPrincipalScopes(req.user), global: getAuthPrincipalScopes(req.user),
project: project: relation?.role.scopes.map((scope) => scope.slug) ?? [],
relations
.find((pr) => pr.userId === req.user.id)
?.role.scopes.map((scope) => scope.slug) ?? [],
}), }),
]; ];
return { return {

View File

@@ -17,7 +17,7 @@ patch:
properties: properties:
newRoleName: newRoleName:
type: string type: string
enum: [global:admin, global:member] example: global:member
required: required:
- newRoleName - newRoleName
responses: responses:

View File

@@ -48,7 +48,7 @@ post:
format: email format: email
role: role:
type: string type: string
enum: [global:admin, global:member] example: global:member
required: required:
- email - email
responses: responses:

View File

@@ -10,7 +10,7 @@ import type {
} from '@n8n/db'; } from '@n8n/db';
import type { import type {
AssignableGlobalRole, AssignableGlobalRole,
CustomRole, AssignableProjectRole,
GlobalRole, GlobalRole,
ProjectRole, ProjectRole,
Scope, Scope,
@@ -275,7 +275,7 @@ export declare namespace ActiveWorkflowRequest {
export declare namespace ProjectRequest { export declare namespace ProjectRequest {
type GetMyProjectsResponse = Array< type GetMyProjectsResponse = Array<
Project & { role: ProjectRole | GlobalRole | CustomRole; scopes?: Scope[] } Project & { role: ProjectRole | AssignableProjectRole | GlobalRole; scopes?: Scope[] }
>; >;
type ProjectRelationResponse = { type ProjectRelationResponse = {
@@ -283,7 +283,7 @@ export declare namespace ProjectRequest {
email: string; email: string;
firstName: string; firstName: string;
lastName: string; lastName: string;
role: ProjectRole | CustomRole; role: ProjectRole | AssignableProjectRole;
}; };
type ProjectWithRelations = { type ProjectWithRelations = {
id: string; id: string;

View File

@@ -1,12 +1,17 @@
import { mockInstance } from '@n8n/backend-test-utils'; import { mockInstance } from '@n8n/backend-test-utils';
import { GlobalConfig } from '@n8n/config'; import { GlobalConfig } from '@n8n/config';
import { GLOBAL_MEMBER_ROLE, User, UserRepository } from '@n8n/db'; import type { Project } from '@n8n/db';
import { GLOBAL_ADMIN_ROLE, GLOBAL_MEMBER_ROLE, User, UserRepository } from '@n8n/db';
import type { EntityManager } from '@n8n/typeorm';
import { mock } from 'jest-mock-extended'; import { mock } from 'jest-mock-extended';
import { v4 as uuid } from 'uuid'; import { v4 as uuid } from 'uuid';
import { BadRequestError } from '@/errors/response-errors/bad-request.error';
import { UrlService } from '@/services/url.service'; import { UrlService } from '@/services/url.service';
import { UserService } from '@/services/user.service'; import { UserService } from '@/services/user.service';
import type { RoleService } from '../role.service';
describe('UserService', () => { describe('UserService', () => {
const globalConfig = mockInstance(GlobalConfig, { const globalConfig = mockInstance(GlobalConfig, {
host: 'localhost', host: 'localhost',
@@ -17,8 +22,22 @@ describe('UserService', () => {
editorBaseUrl: '', editorBaseUrl: '',
}); });
const urlService = new UrlService(globalConfig); const urlService = new UrlService(globalConfig);
const userRepository = mockInstance(UserRepository); const userRepository = mockInstance(UserRepository, {
const userService = new UserService(mock(), userRepository, mock(), urlService, mock(), mock()); manager: mock<EntityManager>({
transaction: async (cb) =>
typeof cb === 'function' ? await cb(mock<EntityManager>()) : await Promise.resolve(),
}),
});
const roleService = mock<RoleService>();
const userService = new UserService(
mock(),
userRepository,
mock(),
urlService,
mock(),
mock(),
roleService,
);
const commonMockUser = Object.assign(new User(), { const commonMockUser = Object.assign(new User(), {
id: uuid(), id: uuid(),
@@ -107,4 +126,42 @@ describe('UserService', () => {
expect(userRepository.update).not.toHaveBeenCalled(); expect(userRepository.update).not.toHaveBeenCalled();
}); });
}); });
describe('inviteUsers', () => {
it('should invite users', async () => {
const owner = Object.assign(new User(), { id: uuid() });
const invitations = [
{ email: 'test1@example.com', role: GLOBAL_ADMIN_ROLE.slug },
{ email: 'test2@example.com', role: GLOBAL_MEMBER_ROLE.slug },
{ email: 'test3@example.com', role: 'custom:role' },
];
roleService.checkRolesExist.mockResolvedValue();
userRepository.findManyByEmail.mockResolvedValue([]);
userRepository.createUserWithProject.mockImplementation(async (userData) => {
return { user: { ...userData, id: uuid() } as User, project: mock<Project>() };
});
await userService.inviteUsers(owner, invitations);
expect(userRepository.createUserWithProject).toHaveBeenCalledTimes(3);
});
it('should fail if role do not exist', async () => {
const owner = Object.assign(new User(), { id: uuid() });
const invitations = [{ email: 'test1@example.com', role: 'nonexistent:role' }];
roleService.checkRolesExist.mockRejectedValue(
new BadRequestError('Role nonexistent:role does not exist'),
);
userRepository.findManyByEmail.mockResolvedValue([]);
userRepository.createUserWithProject.mockImplementation(async (userData) => {
return { user: { ...userData, id: uuid() } as User, project: mock<Project>() };
});
await expect(userService.inviteUsers(owner, invitations)).rejects.toThrowError(
'Role nonexistent:role does not exist',
);
});
});
}); });

View File

@@ -17,7 +17,7 @@ import {
rolesWithScope, rolesWithScope,
type Scope, type Scope,
type ProjectRole, type ProjectRole,
CustomRole, AssignableProjectRole,
PROJECT_OWNER_ROLE_SLUG, PROJECT_OWNER_ROLE_SLUG,
PROJECT_ADMIN_ROLE_SLUG, PROJECT_ADMIN_ROLE_SLUG,
} from '@n8n/permissions'; } from '@n8n/permissions';
@@ -43,7 +43,7 @@ export class TeamProjectOverQuotaError extends UserError {
} }
export class UnlicensedProjectRoleError extends UserError { export class UnlicensedProjectRoleError extends UserError {
constructor(role: ProjectRole | CustomRole) { constructor(role: ProjectRole | AssignableProjectRole) {
super(`Your instance is not licensed to use role "${role}".`); super(`Your instance is not licensed to use role "${role}".`);
} }
} }
@@ -272,11 +272,20 @@ export class ProjectService {
async syncProjectRelations( async syncProjectRelations(
projectId: string, projectId: string,
relations: Required<UpdateProjectDto>['relations'], relations: Array<{ role: AssignableProjectRole; userId: string }>,
): Promise<{ project: Project; newRelations: Required<UpdateProjectDto>['relations'] }> { ): Promise<{
project: Project;
newRelations: Array<{ role: AssignableProjectRole; userId: string }>;
}> {
const project = await this.getTeamProjectWithRelations(projectId); const project = await this.getTeamProjectWithRelations(projectId);
this.checkRolesLicensed(project, relations); this.checkRolesLicensed(project, relations);
// Check that all roles exist
await this.roleService.checkRolesExist(
relations.map((r) => r.role),
'project',
);
await this.projectRelationRepository.manager.transaction(async (em) => { await this.projectRelationRepository.manager.transaction(async (em) => {
await this.pruneRelations(em, project); await this.pruneRelations(em, project);
await this.addManyRelations(em, project, relations); await this.addManyRelations(em, project, relations);
@@ -298,11 +307,17 @@ export class ProjectService {
*/ */
async addUsersToProject( async addUsersToProject(
projectId: string, projectId: string,
relations: Array<{ userId: string; role: ProjectRole | CustomRole }>, relations: Array<{ userId: string; role: AssignableProjectRole }>,
) { ) {
const project = await this.getTeamProjectWithRelations(projectId); const project = await this.getTeamProjectWithRelations(projectId);
this.checkRolesLicensed(project, relations); this.checkRolesLicensed(project, relations);
// Check that project role exists
await this.roleService.checkRolesExist(
relations.map((r) => r.role),
'project',
);
if (project.type === 'personal') { if (project.type === 'personal') {
throw new ForbiddenError("Can't add users to personal projects."); throw new ForbiddenError("Can't add users to personal projects.");
} }
@@ -332,7 +347,7 @@ export class ProjectService {
/** Check to see if the instance is licensed to use all roles provided */ /** Check to see if the instance is licensed to use all roles provided */
private checkRolesLicensed( private checkRolesLicensed(
project: Project, project: Project,
relations: Array<{ role: ProjectRole | CustomRole; userId: string }>, relations: Array<{ role: AssignableProjectRole; userId: string }>,
) { ) {
for (const { role, userId } of relations) { for (const { role, userId } of relations) {
const existing = project.projectRelations.find((pr) => pr.userId === userId); const existing = project.projectRelations.find((pr) => pr.userId === userId);
@@ -361,12 +376,16 @@ export class ProjectService {
await this.projectRelationRepository.delete({ projectId: project.id, userId }); await this.projectRelationRepository.delete({ projectId: project.id, userId });
} }
async changeUserRoleInProject(projectId: string, userId: string, role: ProjectRole) { async changeUserRoleInProject(projectId: string, userId: string, role: AssignableProjectRole) {
if (role === PROJECT_OWNER_ROLE_SLUG) { if (role === PROJECT_OWNER_ROLE_SLUG) {
throw new ForbiddenError('Personal owner cannot be added to a team project.'); throw new ForbiddenError('Personal owner cannot be added to a team project.');
} }
const project = await this.getTeamProjectWithRelations(projectId); const project = await this.getTeamProjectWithRelations(projectId);
// Check that project role exists
await this.roleService.checkRolesExist([role], 'project');
ProjectNotFoundError.isDefinedAndNotNull(project, projectId); ProjectNotFoundError.isDefinedAndNotNull(project, projectId);
const projectUserExists = project.projectRelations.some((r) => r.userId === userId); const projectUserExists = project.projectRelations.some((r) => r.userId === userId);
@@ -399,7 +418,7 @@ export class ProjectService {
async addManyRelations( async addManyRelations(
em: EntityManager, em: EntityManager,
project: Project, project: Project,
relations: Array<{ userId: string; role: ProjectRole | CustomRole }>, relations: Array<{ userId: string; role: AssignableProjectRole }>,
) { ) {
await em.insert( await em.insert(
ProjectRelation, ProjectRelation,
@@ -450,7 +469,7 @@ export class ProjectService {
*/ */
async addUser( async addUser(
projectId: string, projectId: string,
{ userId, role }: { userId: string; role: ProjectRole | CustomRole }, { userId, role }: { userId: string; role: AssignableProjectRole },
trx?: EntityManager, trx?: EntityManager,
) { ) {
trx = trx ?? this.projectRelationRepository.manager; trx = trx ?? this.projectRelationRepository.manager;
@@ -476,6 +495,16 @@ export class ProjectService {
}); });
} }
async getProjectRelationForUserAndProject(
userId: string,
projectId: string,
): Promise<ProjectRelation | null> {
return await this.projectRelationRepository.findOne({
where: { projectId, userId },
relations: { user: true, role: true },
});
}
async getUserOwnedOrAdminProjects(userId: string): Promise<Project[]> { async getUserOwnedOrAdminProjects(userId: string): Promise<Project[]> {
return await this.projectRepository.find({ return await this.projectRepository.find({
where: { where: {

View File

@@ -1,3 +1,4 @@
import { CreateRoleDto, UpdateRoleDto } from '@n8n/api-types';
import { import {
CredentialsEntity, CredentialsEntity,
SharedCredentials, SharedCredentials,
@@ -10,21 +11,24 @@ import {
Role, Role,
Scope as DBScope, Scope as DBScope,
ScopeRepository, ScopeRepository,
GLOBAL_ADMIN_ROLE,
} from '@n8n/db'; } from '@n8n/db';
import { Service } from '@n8n/di'; import { Service } from '@n8n/di';
import type { CustomRole, ProjectRole, Scope, Role as RoleDTO } from '@n8n/permissions'; import type { Scope, Role as RoleDTO, AssignableProjectRole } from '@n8n/permissions';
import { import {
combineScopes, combineScopes,
getAuthPrincipalScopes, getAuthPrincipalScopes,
getRoleScopes, getRoleScopes,
isBuiltInRole, isBuiltInRole,
PROJECT_ADMIN_ROLE_SLUG,
PROJECT_EDITOR_ROLE_SLUG,
PROJECT_VIEWER_ROLE_SLUG,
} from '@n8n/permissions'; } from '@n8n/permissions';
import { UnexpectedError, UserError } from 'n8n-workflow'; import { UnexpectedError, UserError } from 'n8n-workflow';
import { License } from '@/license';
import { CreateRoleDto, UpdateRoleDto } from '@n8n/api-types';
import { NotFoundError } from '@/errors/response-errors/not-found.error';
import { BadRequestError } from '@/errors/response-errors/bad-request.error'; import { BadRequestError } from '@/errors/response-errors/bad-request.error';
import { NotFoundError } from '@/errors/response-errors/not-found.error';
import { License } from '@/license';
@Service() @Service()
export class RoleService { export class RoleService {
@@ -124,6 +128,25 @@ export class RoleService {
return this.dbRoleToRoleDTO(createdRole); return this.dbRoleToRoleDTO(createdRole);
} }
async checkRolesExist(
roleSlugs: string[],
roleType: 'global' | 'project' | 'workflow' | 'credential',
) {
const uniqueRoleSlugs = new Set(roleSlugs);
const roles = await this.roleRepository.findBySlugs(Array.from(uniqueRoleSlugs), roleType);
if (roles.length < uniqueRoleSlugs.size) {
const nonExistentRoles = Array.from(uniqueRoleSlugs).filter(
(slug) => !roles.find((role) => role.slug === slug),
);
throw new BadRequestError(
nonExistentRoles.length === 1
? `Role ${nonExistentRoles[0]} does not exist`
: `Roles ${nonExistentRoles.join(', ')} do not exist`,
);
}
}
addScopes( addScopes(
rawWorkflow: ListQueryDb.Workflow.WithSharing | ListQueryDb.Workflow.WithOwnedByAndSharedWith, rawWorkflow: ListQueryDb.Workflow.WithSharing | ListQueryDb.Workflow.WithOwnedByAndSharedWith,
user: User, user: User,
@@ -209,7 +232,7 @@ export class RoleService {
return [...scopesSet].sort(); return [...scopesSet].sort();
} }
isRoleLicensed(role: ProjectRole | CustomRole) { isRoleLicensed(role: AssignableProjectRole) {
// TODO: move this info into FrontendSettings // TODO: move this info into FrontendSettings
if (!isBuiltInRole(role)) { if (!isBuiltInRole(role)) {
@@ -220,13 +243,13 @@ export class RoleService {
} }
switch (role) { switch (role) {
case 'project:admin': case PROJECT_ADMIN_ROLE_SLUG:
return this.license.isProjectRoleAdminLicensed(); return this.license.isProjectRoleAdminLicensed();
case 'project:editor': case PROJECT_EDITOR_ROLE_SLUG:
return this.license.isProjectRoleEditorLicensed(); return this.license.isProjectRoleEditorLicensed();
case 'project:viewer': case PROJECT_VIEWER_ROLE_SLUG:
return this.license.isProjectRoleViewerLicensed(); return this.license.isProjectRoleViewerLicensed();
case 'global:admin': case GLOBAL_ADMIN_ROLE.slug:
return this.license.isAdvancedPermissionsLicensed(); return this.license.isAdvancedPermissionsLicensed();
default: default:
// TODO: handle custom roles licensing // TODO: handle custom roles licensing

View File

@@ -16,6 +16,7 @@ import { UrlService } from '@/services/url.service';
import { UserManagementMailer } from '@/user-management/email'; import { UserManagementMailer } from '@/user-management/email';
import { PublicApiKeyService } from './public-api-key.service'; import { PublicApiKeyService } from './public-api-key.service';
import { RoleService } from './role.service';
@Service() @Service()
export class UserService { export class UserService {
@@ -26,6 +27,7 @@ export class UserService {
private readonly urlService: UrlService, private readonly urlService: UrlService,
private readonly eventService: EventService, private readonly eventService: EventService,
private readonly publicApiKeyService: PublicApiKeyService, private readonly publicApiKeyService: PublicApiKeyService,
private readonly roleService: RoleService,
) {} ) {}
async update(userId: string, data: Partial<User>) { async update(userId: string, data: Partial<User>) {
@@ -210,6 +212,12 @@ export class UserService {
: 'Creating 1 user shell...', : 'Creating 1 user shell...',
); );
// Check that all roles in the invitations exist in the database
await this.roleService.checkRolesExist(
invitations.map(({ role }) => role),
'global',
);
try { try {
await this.getManager().transaction( await this.getManager().transaction(
async (transactionManager) => async (transactionManager) =>
@@ -246,6 +254,9 @@ export class UserService {
} }
async changeUserRole(user: User, targetUser: User, newRole: RoleChangeRequestDto) { async changeUserRole(user: User, targetUser: User, newRole: RoleChangeRequestDto) {
// Check that new role exists
await this.roleService.checkRolesExist([newRole.newRoleName], 'global');
return await this.userRepository.manager.transaction(async (trx) => { return await this.userRepository.manager.transaction(async (trx) => {
await trx.update(User, { id: targetUser.id }, { role: { slug: newRole.newRoleName } }); await trx.update(User, { id: targetUser.id }, { role: { slug: newRole.newRoleName } });

View File

@@ -1,6 +1,7 @@
import { mockInstance } from '@n8n/backend-test-utils'; import { mockInstance } from '@n8n/backend-test-utils';
import type { GlobalConfig } from '@n8n/config'; import type { GlobalConfig } from '@n8n/config';
import type { ProjectRole, User, UserRepository } from '@n8n/db'; import type { User, UserRepository } from '@n8n/db';
import { PROJECT_EDITOR_ROLE_SLUG, PROJECT_VIEWER_ROLE_SLUG } from '@n8n/permissions';
import { mock } from 'jest-mock-extended'; import { mock } from 'jest-mock-extended';
import type { IWorkflowBase } from 'n8n-workflow'; import type { IWorkflowBase } from 'n8n-workflow';
@@ -157,8 +158,8 @@ describe('UserManagementMailer', () => {
it('should send project share notifications', async () => { it('should send project share notifications', async () => {
const sharer = mock<User>({ firstName: 'Sharer', email: 'sharer@user.com' }); const sharer = mock<User>({ firstName: 'Sharer', email: 'sharer@user.com' });
const newSharees = [ const newSharees = [
{ userId: 'recipient1', role: 'project:editor' as ProjectRole }, { userId: 'recipient1', role: PROJECT_EDITOR_ROLE_SLUG },
{ userId: 'recipient2', role: 'project:viewer' as ProjectRole }, { userId: 'recipient2', role: PROJECT_VIEWER_ROLE_SLUG },
]; ];
const project = { id: 'project1', name: 'Test Project' }; const project = { id: 'project1', name: 'Test Project' };
userRepository.getEmailsByIds.mockResolvedValue([ userRepository.getEmailsByIds.mockResolvedValue([

View File

@@ -1,23 +1,24 @@
import { inTest, Logger } from '@n8n/backend-common'; import { inTest, Logger } from '@n8n/backend-common';
import { GlobalConfig } from '@n8n/config'; import { GlobalConfig } from '@n8n/config';
import type { ProjectRole, User } from '@n8n/db'; import type { User } from '@n8n/db';
import { UserRepository } from '@n8n/db'; import { UserRepository } from '@n8n/db';
import { Container, Service } from '@n8n/di'; import { Container, Service } from '@n8n/di';
import { AssignableProjectRole } from '@n8n/permissions';
import { existsSync } from 'fs'; import { existsSync } from 'fs';
import { readFile } from 'fs/promises'; import { readFile } from 'fs/promises';
import Handlebars from 'handlebars'; import Handlebars from 'handlebars';
import type { IWorkflowBase } from 'n8n-workflow'; import type { IWorkflowBase } from 'n8n-workflow';
import { join as pathJoin } from 'path'; import { join as pathJoin } from 'path';
import type { InviteEmailData, PasswordResetData, SendEmailResult } from './interfaces';
import { NodeMailer } from './node-mailer';
import { InternalServerError } from '@/errors/response-errors/internal-server.error'; import { InternalServerError } from '@/errors/response-errors/internal-server.error';
import { EventService } from '@/events/event.service'; import { EventService } from '@/events/event.service';
import type { RelayEventMap } from '@/events/maps/relay.event-map'; import type { RelayEventMap } from '@/events/maps/relay.event-map';
import { UrlService } from '@/services/url.service'; import { UrlService } from '@/services/url.service';
import { toError } from '@/utils'; import { toError } from '@/utils';
import type { InviteEmailData, PasswordResetData, SendEmailResult } from './interfaces';
import { NodeMailer } from './node-mailer';
type Template = HandlebarsTemplateDelegate<unknown>; type Template = HandlebarsTemplateDelegate<unknown>;
type TemplateName = type TemplateName =
| 'user-invited' | 'user-invited'
@@ -193,7 +194,7 @@ export class UserManagementMailer {
project, project,
}: { }: {
sharer: User; sharer: User;
newSharees: Array<{ userId: string; role: ProjectRole }>; newSharees: Array<{ userId: string; role: AssignableProjectRole }>;
project: { id: string; name: string }; project: { id: string; name: string };
}): Promise<SendEmailResult> { }): Promise<SendEmailResult> {
const recipients = await this.userRepository.getEmailsByIds(newSharees.map((s) => s.userId)); const recipients = await this.userRepository.getEmailsByIds(newSharees.map((s) => s.userId));

View File

@@ -9,13 +9,11 @@ import {
testDb, testDb,
mockInstance, mockInstance,
} from '@n8n/backend-test-utils'; } from '@n8n/backend-test-utils';
import type { Project, ProjectRole, User } from '@n8n/db'; import type { Project, User } from '@n8n/db';
import { FolderRepository, ProjectRepository, WorkflowRepository } from '@n8n/db'; import { FolderRepository, ProjectRepository, WorkflowRepository } from '@n8n/db';
import { Container } from '@n8n/di'; import { Container } from '@n8n/di';
import { DateTime } from 'luxon'; import type { ProjectRole } from '@n8n/permissions';
import { ApplicationError, PROJECT_ROOT } from 'n8n-workflow'; import { PROJECT_EDITOR_ROLE_SLUG, PROJECT_VIEWER_ROLE_SLUG } from '@n8n/permissions';
import { ActiveWorkflowManager } from '@/active-workflow-manager';
import { import {
createCredentials, createCredentials,
getCredentialSharings, getCredentialSharings,
@@ -25,11 +23,15 @@ import {
} from '@test-integration/db/credentials'; } from '@test-integration/db/credentials';
import { createFolder } from '@test-integration/db/folders'; import { createFolder } from '@test-integration/db/folders';
import { createTag } from '@test-integration/db/tags'; import { createTag } from '@test-integration/db/tags';
import { DateTime } from 'luxon';
import { ApplicationError, PROJECT_ROOT } from 'n8n-workflow';
import { createOwner, createMember, createUser, createAdmin } from '../shared/db/users'; import { createOwner, createMember, createUser, createAdmin } from '../shared/db/users';
import type { SuperAgentTest } from '../shared/types'; import type { SuperAgentTest } from '../shared/types';
import * as utils from '../shared/utils/'; import * as utils from '../shared/utils/';
import { ActiveWorkflowManager } from '@/active-workflow-manager';
let owner: User; let owner: User;
let member: User; let member: User;
let authOwnerAgent: SuperAgentTest; let authOwnerAgent: SuperAgentTest;
@@ -1863,7 +1865,7 @@ describe('PUT /projects/:projectId/folders/:folderId/transfer', () => {
.expect(404); .expect(404);
}); });
test.each<ProjectRole>(['project:editor', 'project:viewer'])( test.each<ProjectRole>([PROJECT_EDITOR_ROLE_SLUG, PROJECT_VIEWER_ROLE_SLUG])(
'%ss cannot transfer workflows', '%ss cannot transfer workflows',
async (projectRole) => { async (projectRole) => {
// //

View File

@@ -483,8 +483,8 @@ describe('Projects in Public API', () => {
relations: [ relations: [
{ {
userId: member.id, userId: member.id,
// role does not exist // field does not exist
role: 'project:boss', invalidField: 'invalidValue',
}, },
], ],
}; };
@@ -499,10 +499,33 @@ describe('Projects in Public API', () => {
// ASSERT // ASSERT
expect(response.body).toHaveProperty( expect(response.body).toHaveProperty(
'message', 'message',
"Invalid enum value. Expected 'project:admin' | 'project:editor' | 'project:viewer', received 'project:boss'", "request/body/relations/0 must have required property 'role'",
); );
}); });
it('should reject if the relations have a role that do not exist', async () => {
const owner = await createOwnerWithApiKey();
const member = await createMember();
const project = await createTeamProject('shared-project', owner);
const payload = {
relations: [
{
userId: member.id,
role: 'project:invalid-role',
},
],
};
await testServer
.publicApiAgentFor(owner)
.post(`/projects/${project.id}/users`)
.send(payload)
.expect(400);
// TODO: add message check once we properly validate role from database
});
it('should reject with 404 if no project found', async () => { it('should reject with 404 if no project found', async () => {
const owner = await createOwnerWithApiKey(); const owner = await createOwnerWithApiKey();
const member = await createMember(); const member = await createMember();
@@ -654,23 +677,23 @@ describe('Projects in Public API', () => {
testServer.license.enable('feat:projectRole:admin'); testServer.license.enable('feat:projectRole:admin');
}); });
it("should reject with 400 if the payload can't be validated", async () => { it('should reject with 400 if the role do not exist', async () => {
// ARRANGE // ARRANGE
const owner = await createOwnerWithApiKey(); const owner = await createOwnerWithApiKey();
const member = await createMember();
const project = await createTeamProject('shared-project', owner);
await linkUserToProject(member, project, 'project:viewer');
// ACT // ACT
const response = await testServer await testServer
.publicApiAgentFor(owner) .publicApiAgentFor(owner)
.patch('/projects/1234/users/1235') .patch(`/projects/${project.id}/users/${member.id}`)
// role does not exist // role does not exist
.send({ role: 'project:boss' }) .send({ role: 'project:boss' })
.expect(400); .expect(400);
// ASSERT // ASSERT
expect(response.body).toHaveProperty( // TODO: add message check once we properly validate that the role exists
'message',
"Invalid enum value. Expected 'project:admin' | 'project:editor' | 'project:viewer', received 'project:boss'",
);
}); });
it("should change a user's role in a project", async () => { it("should change a user's role in a project", async () => {

View File

@@ -9,6 +9,7 @@ import {
getUserById, getUserById,
} from '@test-integration/db/users'; } from '@test-integration/db/users';
import { setupTestServer } from '@test-integration/utils'; import { setupTestServer } from '@test-integration/utils';
import { createRole } from '@test-integration/db/roles';
describe('Users in Public API', () => { describe('Users in Public API', () => {
const testServer = setupTestServer({ endpointGroups: ['publicApi'] }); const testServer = setupTestServer({ endpointGroups: ['publicApi'] });
@@ -61,13 +62,32 @@ describe('Users in Public API', () => {
expect(response.body).toHaveProperty('message', 'Forbidden'); expect(response.body).toHaveProperty('message', 'Forbidden');
}); });
it('should fail if role does not exist', async () => {
/**
* Arrange
*/
testServer.license.enable('feat:advancedPermissions');
const owner = await createOwnerWithApiKey();
const payload = [{ email: 'test@test.com', role: 'non-existing-role' }];
/**
* Act
*/
const response = await testServer.publicApiAgentFor(owner).post('/users').send(payload);
/**
* Assert
*/
expect(response.status).toBe(400);
expect(response.body).toHaveProperty('message', 'Role non-existing-role does not exist');
});
it('should create a user', async () => { it('should create a user', async () => {
/** /**
* Arrange * Arrange
*/ */
testServer.license.enable('feat:advancedPermissions'); testServer.license.enable('feat:advancedPermissions');
const owner = await createOwnerWithApiKey(); const owner = await createOwnerWithApiKey();
await createOwnerWithApiKey();
const payload = [{ email: 'test@test.com', role: 'global:admin' }]; const payload = [{ email: 'test@test.com', role: 'global:admin' }];
/** /**
@@ -97,6 +117,27 @@ describe('Users in Public API', () => {
expect(returnedUser.email).toBe(payloadUser.email); expect(returnedUser.email).toBe(payloadUser.email);
expect(storedUser.role.slug).toBe(payloadUser.role); expect(storedUser.role.slug).toBe(payloadUser.role);
}); });
it('should create a user with an existing custom role', async () => {
/**
* Arrange
*/
testServer.license.enable('feat:advancedPermissions');
const owner = await createOwnerWithApiKey();
const customRole = 'custom:role';
await createRole({ slug: customRole, displayName: 'Custom role', roleType: 'global' });
const payload = [{ email: 'test@test.com', role: customRole }];
/**
* Act
*/
const response = await testServer.publicApiAgentFor(owner).post('/users').send(payload);
/**
* Assert
*/
expect(response.status).toBe(201);
});
}); });
describe('DELETE /users/:id', () => { describe('DELETE /users/:id', () => {
@@ -277,5 +318,32 @@ describe('Users in Public API', () => {
const storedUser = await getUserById(member.id); const storedUser = await getUserById(member.id);
expect(storedUser.role.slug).toBe(payload.newRoleName); expect(storedUser.role.slug).toBe(payload.newRoleName);
}); });
it('should change a user role to an existing custom role', async () => {
/**
* Arrange
*/
testServer.license.enable('feat:advancedPermissions');
const owner = await createOwnerWithApiKey();
const member = await createMember();
const customRole = 'custom:role';
await createRole({ slug: customRole, displayName: 'Custom role', roleType: 'global' });
const payload = { newRoleName: customRole };
/**
* Act
*/
const response = await testServer
.publicApiAgentFor(owner)
.patch(`/users/${member.id}/role`)
.send(payload);
/**
* Assert
*/
expect(response.status).toBe(204);
const storedUser = await getUserById(member.id);
expect(storedUser.role.slug).toBe(payload.newRoleName);
});
}); });
}); });

View File

@@ -3,13 +3,16 @@ import { ProjectRelationRepository, ProjectRepository } from '@n8n/db';
import { Container } from '@n8n/di'; import { Container } from '@n8n/di';
import { PROJECT_OWNER_ROLE_SLUG, type ProjectRole, type Scope } from '@n8n/permissions'; import { PROJECT_OWNER_ROLE_SLUG, type ProjectRole, type Scope } from '@n8n/permissions';
import { createMember } from '../shared/db/users'; import { License } from '@/license';
import { ProjectService } from '@/services/project.service.ee'; import { ProjectService } from '@/services/project.service.ee';
import { createRole } from '@test-integration/db/roles';
import { createMember } from '../shared/db/users';
let projectRepository: ProjectRepository; let projectRepository: ProjectRepository;
let projectService: ProjectService; let projectService: ProjectService;
let projectRelationRepository: ProjectRelationRepository; let projectRelationRepository: ProjectRelationRepository;
let license: License;
beforeAll(async () => { beforeAll(async () => {
await testDb.init(); await testDb.init();
@@ -17,6 +20,7 @@ beforeAll(async () => {
projectRepository = Container.get(ProjectRepository); projectRepository = Container.get(ProjectRepository);
projectService = Container.get(ProjectService); projectService = Container.get(ProjectService);
projectRelationRepository = Container.get(ProjectRelationRepository); projectRelationRepository = Container.get(ProjectRelationRepository);
license = Container.get(License);
}); });
afterAll(async () => { afterAll(async () => {
@@ -95,6 +99,36 @@ describe('ProjectService', () => {
expect(relationships).toHaveLength(1); expect(relationships).toHaveLength(1);
expect(relationships[0]).toHaveProperty('role.slug', 'project:admin'); expect(relationships[0]).toHaveProperty('role.slug', 'project:admin');
}); });
it('adds a user to a project with a custom role', async () => {
//
// ARRANGE
//
const member = await createMember();
const project = await projectRepository.save(
projectRepository.create({
name: 'Team Project',
type: 'team',
}),
);
const role = await createRole({ slug: 'project:custom', displayName: 'Custom Role' });
//
// ACT
//
await projectService.addUser(project.id, { userId: member.id, role: role.slug });
//
// ASSERT
//
const relationships = await projectRelationRepository.find({
where: { userId: member.id, projectId: project.id },
relations: { role: true },
});
expect(relationships).toHaveLength(1);
expect(relationships[0].role.slug).toBe('project:custom');
});
}); });
describe('getProjectWithScope', () => { describe('getProjectWithScope', () => {
@@ -240,4 +274,165 @@ describe('ProjectService', () => {
expect(relations).toBeNull(); expect(relations).toBeNull();
}); });
}); });
describe('addUsersToProject', () => {
it('should add multiple users to a project', async () => {
//
// ARRANGE
//
const members = await Promise.all([createMember(), createMember()]);
const project = await projectRepository.save(
projectRepository.create({
name: 'Team Project',
type: 'team',
}),
);
jest.spyOn(license, 'isProjectRoleEditorLicensed').mockReturnValue(true);
//
// ACT
//
await projectService.addUsersToProject(
project.id,
members.map((member) => ({ userId: member.id, role: 'project:editor' })),
);
//
// ASSERT
//
const relations = await projectRelationRepository.find({
where: { projectId: project.id },
});
expect(relations).toHaveLength(members.length);
});
it('fails to add a user to a project with a non-existing role', async () => {
//
// ARRANGE
//
const member = await createMember();
const project = await projectRepository.save(
projectRepository.create({
name: 'Team Project',
type: 'team',
}),
);
//
// ACT
//
await expect(
projectService.addUsersToProject(project.id, [
{ userId: member.id, role: 'custom:non-existing' },
]),
).rejects.toThrowError('Role custom:non-existing does not exist');
});
});
describe('syncProjectRelations', () => {
it('should synchronize project relations for a user', async () => {
//
// ARRANGE
//
const user = await createMember();
const project = await projectRepository.save(
projectRepository.create({
name: 'Team Project',
type: 'team',
}),
);
jest.spyOn(license, 'isProjectRoleEditorLicensed').mockReturnValue(true);
//
// ACT
//
await projectService.syncProjectRelations(project.id, [
{ userId: user.id, role: 'project:editor' },
]);
//
// ASSERT
//
const relations = await projectRelationRepository.find({
where: { userId: user.id, projectId: project.id },
});
expect(relations).toHaveLength(1);
});
it('should fail to synchronize users with non-existing roles', async () => {
//
// ARRANGE
//
const user = await createMember();
const project = await projectRepository.save(
projectRepository.create({
name: 'Team Project',
type: 'team',
}),
);
jest.spyOn(license, 'isProjectRoleEditorLicensed').mockReturnValue(true);
//
// ACT
//
await expect(
projectService.syncProjectRelations(project.id, [
{ userId: user.id, role: 'project:non-existing' },
]),
).rejects.toThrowError('Role project:non-existing does not exist');
});
});
describe('changeUserRoleInProject', () => {
it('should change user role in project', async () => {
//
// ARRANGE
//
const user = await createMember();
const project = await projectRepository.save(
projectRepository.create({
name: 'Team Project',
type: 'team',
}),
);
//
// ACT
//
await projectService.addUser(project.id, { userId: user.id, role: 'project:viewer' });
await projectService.changeUserRoleInProject(project.id, user.id, 'project:editor');
//
// ASSERT
//
const relations = await projectRelationRepository.find({
where: { userId: user.id, projectId: project.id },
relations: { role: true },
});
expect(relations).toHaveLength(1);
expect(relations[0].role.slug).toBe('project:editor');
});
it('should fail to change user role in project with non-existing role', async () => {
//
// ARRANGE
//
const user = await createMember();
const project = await projectRepository.save(
projectRepository.create({
name: 'Team Project',
type: 'team',
}),
);
//
// ACT
//
await projectService.addUser(project.id, { userId: user.id, role: 'project:viewer' });
await expect(
projectService.changeUserRoleInProject(project.id, user.id, 'project:non-existing'),
).rejects.toThrowError('Role project:non-existing does not exist');
});
});
}); });

View File

@@ -41,6 +41,7 @@ import { createAdmin, createMember, createOwner, createUser, getUserById } from
import type { SuperAgentTest } from './shared/types'; import type { SuperAgentTest } from './shared/types';
import * as utils from './shared/utils/'; import * as utils from './shared/utils/';
import { validateUser } from './shared/utils/users'; import { validateUser } from './shared/utils/users';
import { createRole } from '@test-integration/db/roles';
mockInstance(Telemetry); mockInstance(Telemetry);
mockInstance(ExecutionService); mockInstance(ExecutionService);
@@ -1573,4 +1574,30 @@ describe('PATCH /users/:id/role', () => {
expect(response.statusCode).toBe(200); expect(response.statusCode).toBe(200);
expect(response.body.data).toStrictEqual({ success: true }); expect(response.body.data).toStrictEqual({ success: true });
}); });
test('should fail to change to non-existing role', async () => {
const customRole = 'custom:project-role';
await createRole({ slug: customRole, displayName: 'Custom Role', roleType: 'project' });
const response = await ownerAgent.patch(`/users/${member.id}/role`).send({
newRoleName: customRole,
});
expect(response.statusCode).toBe(400);
expect(response.body.message).toBe('Role custom:project-role does not exist');
});
test('should change to existing custom role', async () => {
const customRole = 'custom:role';
await createRole({ slug: customRole, displayName: 'Custom Role', roleType: 'global' });
const response = await ownerAgent.patch(`/users/${member.id}/role`).send({
newRoleName: customRole,
});
expect(response.statusCode).toBe(200);
expect(response.body.data).toStrictEqual({ success: true });
const user = await getUserById(member.id);
expect(user.role.slug).toBe(customRole);
});
}); });