mirror of
https://github.com/Abdulazizzn/n8n-enterprise-unlocked.git
synced 2025-12-16 17:46:45 +00:00
feat(API): Add user management endpoints to the Projects Public API (#12329)
Co-authored-by: कारतोफ्फेलस्क्रिप्ट™ <aditya@netroy.in> Co-authored-by: Danny Martini <danny@n8n.io> Co-authored-by: Andreas Fitzek <andreas.fitzek@n8n.io> Co-authored-by: Guillaume Jacquart <jacquart.guillaume@gmail.com>
This commit is contained in:
@@ -28,6 +28,8 @@ export { ChangePasswordRequestDto } from './password-reset/change-password-reque
|
||||
export { CreateProjectDto } from './project/create-project.dto';
|
||||
export { UpdateProjectDto } from './project/update-project.dto';
|
||||
export { DeleteProjectDto } from './project/delete-project.dto';
|
||||
export { AddUsersToProjectDto } from './project/add-users-to-project.dto';
|
||||
export { ChangeUserRoleInProject } from './project/change-user-role-in-project.dto';
|
||||
|
||||
export { SamlAcsDto } from './saml/saml-acs.dto';
|
||||
export { SamlPreferences } from './saml/saml-preferences.dto';
|
||||
|
||||
@@ -0,0 +1,143 @@
|
||||
import { AddUsersToProjectDto } from '../add-users-to-project.dto';
|
||||
|
||||
describe('AddUsersToProjectDto', () => {
|
||||
describe('Valid requests', () => {
|
||||
test.each([
|
||||
{
|
||||
name: 'with single user',
|
||||
request: {
|
||||
relations: [
|
||||
{
|
||||
userId: 'user-123',
|
||||
role: 'project:admin',
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'with multiple relations',
|
||||
request: {
|
||||
relations: [
|
||||
{
|
||||
userId: 'user-123',
|
||||
role: 'project:admin',
|
||||
},
|
||||
{
|
||||
userId: 'user-456',
|
||||
role: 'project:editor',
|
||||
},
|
||||
{
|
||||
userId: 'user-789',
|
||||
role: 'project:viewer',
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'with all possible roles unless the `project:personalOwner`',
|
||||
request: {
|
||||
relations: [
|
||||
{ userId: 'user-1', role: 'project:admin' },
|
||||
{ userId: 'user-2', role: 'project:editor' },
|
||||
{ userId: 'user-3', role: 'project:viewer' },
|
||||
],
|
||||
},
|
||||
},
|
||||
])('should validate $name', ({ request }) => {
|
||||
const result = AddUsersToProjectDto.safeParse(request);
|
||||
expect(result.success).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Invalid requests', () => {
|
||||
test.each([
|
||||
{
|
||||
name: 'missing relations array',
|
||||
request: {},
|
||||
expectedErrorPath: ['relations'],
|
||||
},
|
||||
{
|
||||
name: 'empty relations array',
|
||||
request: {
|
||||
relations: [],
|
||||
},
|
||||
expectedErrorPath: ['relations'],
|
||||
},
|
||||
{
|
||||
name: 'invalid userId type',
|
||||
request: {
|
||||
relations: [
|
||||
{
|
||||
userId: 123,
|
||||
role: 'project:admin',
|
||||
},
|
||||
],
|
||||
},
|
||||
expectedErrorPath: ['relations', 0, 'userId'],
|
||||
},
|
||||
{
|
||||
name: 'empty userId',
|
||||
request: {
|
||||
relations: [
|
||||
{
|
||||
userId: '',
|
||||
role: 'project:admin',
|
||||
},
|
||||
],
|
||||
},
|
||||
expectedErrorPath: ['relations', 0, 'userId'],
|
||||
},
|
||||
{
|
||||
name: 'invalid role',
|
||||
request: {
|
||||
relations: [
|
||||
{
|
||||
userId: 'user-123',
|
||||
role: 'invalid-role',
|
||||
},
|
||||
],
|
||||
},
|
||||
expectedErrorPath: ['relations', 0, 'role'],
|
||||
},
|
||||
{
|
||||
name: 'missing role',
|
||||
request: {
|
||||
relations: [
|
||||
{
|
||||
userId: 'user-123',
|
||||
},
|
||||
],
|
||||
},
|
||||
expectedErrorPath: ['relations', 0, 'role'],
|
||||
},
|
||||
{
|
||||
name: 'invalid relations array type',
|
||||
request: {
|
||||
relations: 'not-an-array',
|
||||
},
|
||||
expectedErrorPath: ['relations'],
|
||||
},
|
||||
{
|
||||
name: 'invalid user object in array',
|
||||
request: {
|
||||
relations: ['not-an-object'],
|
||||
},
|
||||
expectedErrorPath: ['relations', 0],
|
||||
},
|
||||
{
|
||||
name: 'invalid with `project:personalOwner` role',
|
||||
request: {
|
||||
relations: [{ userId: 'user-1', role: 'project:personalOwner' }],
|
||||
},
|
||||
},
|
||||
])('should fail validation for $name', ({ request, expectedErrorPath }) => {
|
||||
const result = AddUsersToProjectDto.safeParse(request);
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
|
||||
if (expectedErrorPath) {
|
||||
expect(result.error?.issues[0].path).toEqual(expectedErrorPath);
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,54 @@
|
||||
import { ChangeUserRoleInProject } from '../change-user-role-in-project.dto';
|
||||
|
||||
describe('ChangeUserRoleInProject', () => {
|
||||
describe('Allow valid roles', () => {
|
||||
test.each(['project:admin', 'project:editor', 'project:viewer'])('should allow %s', (role) => {
|
||||
const result = ChangeUserRoleInProject.safeParse({ role });
|
||||
expect(result.success).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Reject invalid roles', () => {
|
||||
test.each([
|
||||
{
|
||||
name: 'missing role',
|
||||
request: {},
|
||||
expectedErrorPath: ['role'],
|
||||
},
|
||||
{
|
||||
name: 'empty role',
|
||||
request: {
|
||||
role: '',
|
||||
},
|
||||
expectedErrorPath: ['role'],
|
||||
},
|
||||
{
|
||||
name: 'invalid role type',
|
||||
request: {
|
||||
role: 123,
|
||||
},
|
||||
expectedErrorPath: ['role'],
|
||||
},
|
||||
{
|
||||
name: 'invalid role value',
|
||||
request: {
|
||||
role: 'invalid-role',
|
||||
},
|
||||
expectedErrorPath: ['role'],
|
||||
},
|
||||
{
|
||||
name: 'personal owner role',
|
||||
request: { role: 'project:personalOwner' },
|
||||
expectedErrorPath: ['role'],
|
||||
},
|
||||
])('should reject $name', ({ request, expectedErrorPath }) => {
|
||||
const result = ChangeUserRoleInProject.safeParse(request);
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
|
||||
if (expectedErrorPath) {
|
||||
expect(result.error?.issues[0].path).toEqual(expectedErrorPath);
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,8 @@
|
||||
import { z } from 'zod';
|
||||
import { Z } from 'zod-class';
|
||||
|
||||
import { projectRelationSchema } from '../../schemas/project.schema';
|
||||
|
||||
export class AddUsersToProjectDto extends Z.class({
|
||||
relations: z.array(projectRelationSchema).min(1),
|
||||
}) {}
|
||||
@@ -0,0 +1,6 @@
|
||||
import { projectRoleSchema } from '@n8n/permissions';
|
||||
import { Z } from 'zod-class';
|
||||
|
||||
export class ChangeUserRoleInProject extends Z.class({
|
||||
role: projectRoleSchema.exclude(['project:personalOwner']),
|
||||
}) {}
|
||||
@@ -13,7 +13,7 @@ export const projectIconSchema = z.object({
|
||||
export type ProjectIcon = z.infer<typeof projectIconSchema>;
|
||||
|
||||
export const projectRelationSchema = z.object({
|
||||
userId: z.string(),
|
||||
role: projectRoleSchema,
|
||||
userId: z.string().min(1),
|
||||
role: projectRoleSchema.exclude(['project:personalOwner']),
|
||||
});
|
||||
export type ProjectRelation = z.infer<typeof projectRelationSchema>;
|
||||
|
||||
@@ -8,13 +8,14 @@ export const assignableGlobalRoleSchema = globalRoleSchema.exclude([
|
||||
'global:owner', // Owner cannot be changed
|
||||
]);
|
||||
|
||||
export const projectRoleSchema = z.enum([
|
||||
export const personalRoleSchema = z.enum([
|
||||
'project:personalOwner', // personalOwner is only used for personal projects
|
||||
'project:admin',
|
||||
'project:editor',
|
||||
'project:viewer',
|
||||
]);
|
||||
|
||||
export const teamRoleSchema = z.enum(['project:admin', 'project:editor', 'project:viewer']);
|
||||
|
||||
export const projectRoleSchema = z.enum([...personalRoleSchema.options, ...teamRoleSchema.options]);
|
||||
|
||||
export const credentialSharingRoleSchema = z.enum(['credential:owner', 'credential:user']);
|
||||
|
||||
export const workflowSharingRoleSchema = z.enum(['workflow:owner', 'workflow:editor']);
|
||||
|
||||
@@ -7,6 +7,7 @@ import type {
|
||||
globalRoleSchema,
|
||||
projectRoleSchema,
|
||||
roleNamespaceSchema,
|
||||
teamRoleSchema,
|
||||
workflowSharingRoleSchema,
|
||||
} from './schemas.ee';
|
||||
|
||||
@@ -49,6 +50,7 @@ export type GlobalRole = z.infer<typeof globalRoleSchema>;
|
||||
export type AssignableGlobalRole = z.infer<typeof assignableGlobalRoleSchema>;
|
||||
export type CredentialSharingRole = z.infer<typeof credentialSharingRoleSchema>;
|
||||
export type WorkflowSharingRole = z.infer<typeof workflowSharingRoleSchema>;
|
||||
export type TeamProjectRole = z.infer<typeof teamRoleSchema>;
|
||||
export type ProjectRole = z.infer<typeof projectRoleSchema>;
|
||||
|
||||
/** Union of all possible role types in the system */
|
||||
|
||||
@@ -1,11 +1,19 @@
|
||||
import { CreateProjectDto, DeleteProjectDto, UpdateProjectDto } from '@n8n/api-types';
|
||||
import {
|
||||
AddUsersToProjectDto,
|
||||
ChangeUserRoleInProject,
|
||||
CreateProjectDto,
|
||||
DeleteProjectDto,
|
||||
UpdateProjectDto,
|
||||
} from '@n8n/api-types';
|
||||
import { ProjectRepository } from '@n8n/db';
|
||||
import { Container } from '@n8n/di';
|
||||
import type { Response } from 'express';
|
||||
|
||||
import { ProjectController } from '@/controllers/project.controller';
|
||||
import { ResponseError } from '@/errors/response-errors/abstract/response.error';
|
||||
import type { PaginatedRequest } from '@/public-api/types';
|
||||
import type { AuthenticatedRequest } from '@/requests';
|
||||
import { ProjectService } from '@/services/project.service.ee';
|
||||
|
||||
import {
|
||||
apiKeyHasScopeWithGlobalScopeFallback,
|
||||
@@ -15,7 +23,6 @@ import {
|
||||
import { encodeNextCursor } from '../../shared/services/pagination.service';
|
||||
|
||||
type GetAll = PaginatedRequest;
|
||||
|
||||
export = {
|
||||
createProject: [
|
||||
isLicensed('feat:projectRole:admin'),
|
||||
@@ -91,4 +98,67 @@ export = {
|
||||
});
|
||||
},
|
||||
],
|
||||
addUsersToProject: [
|
||||
isLicensed('feat:projectRole:admin'),
|
||||
apiKeyHasScopeWithGlobalScopeFallback({ scope: 'project:update' }),
|
||||
async (req: AuthenticatedRequest<{ projectId: string }>, res: Response) => {
|
||||
const payload = AddUsersToProjectDto.safeParse(req.body);
|
||||
if (payload.error) {
|
||||
return res.status(400).json(payload.error.errors[0]);
|
||||
}
|
||||
|
||||
try {
|
||||
await Container.get(ProjectService).addUsersToProject(
|
||||
req.params.projectId,
|
||||
payload.data.relations,
|
||||
);
|
||||
} catch (error) {
|
||||
if (error instanceof ResponseError) {
|
||||
return res.status(error.httpStatusCode).send({ message: error.message });
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
|
||||
return res.status(201).send();
|
||||
},
|
||||
],
|
||||
changeUserRoleInProject: [
|
||||
isLicensed('feat:projectRole:admin'),
|
||||
apiKeyHasScopeWithGlobalScopeFallback({ scope: 'project:update' }),
|
||||
async (req: AuthenticatedRequest<{ projectId: string; userId: string }>, res: Response) => {
|
||||
const payload = ChangeUserRoleInProject.safeParse(req.body);
|
||||
if (payload.error) {
|
||||
return res.status(400).json(payload.error.errors[0]);
|
||||
}
|
||||
|
||||
const { projectId, userId } = req.params;
|
||||
const { role } = payload.data;
|
||||
try {
|
||||
await Container.get(ProjectService).changeUserRoleInProject(projectId, userId, role);
|
||||
} catch (error) {
|
||||
if (error instanceof ResponseError) {
|
||||
return res.status(error.httpStatusCode).send({ message: error.message });
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
|
||||
return res.status(204).send();
|
||||
},
|
||||
],
|
||||
deleteUserFromProject: [
|
||||
isLicensed('feat:projectRole:admin'),
|
||||
apiKeyHasScopeWithGlobalScopeFallback({ scope: 'project:update' }),
|
||||
async (req: AuthenticatedRequest<{ projectId: string; userId: string }>, res: Response) => {
|
||||
const { projectId, userId } = req.params;
|
||||
try {
|
||||
await Container.get(ProjectService).deleteUserFromProject(projectId, userId);
|
||||
} catch (error) {
|
||||
if (error instanceof ResponseError) {
|
||||
return res.status(error.httpStatusCode).send({ message: error.message });
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
return res.status(204).send();
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
delete:
|
||||
x-eov-operation-id: deleteUserFromProject
|
||||
x-eov-operation-handler: v1/handlers/projects/projects.handler
|
||||
tags:
|
||||
- Projects
|
||||
summary: Delete a user from a project
|
||||
description: Delete a user from a project from your instance.
|
||||
parameters:
|
||||
- name: projectId
|
||||
in: path
|
||||
description: The ID of the project.
|
||||
required: true
|
||||
schema:
|
||||
type: string
|
||||
- name: userId
|
||||
in: path
|
||||
description: The ID of the user.
|
||||
required: true
|
||||
schema:
|
||||
type: string
|
||||
responses:
|
||||
'204':
|
||||
description: Operation successful.
|
||||
'401':
|
||||
$ref: '../../../../shared/spec/responses/unauthorized.yml'
|
||||
'403':
|
||||
$ref: '../../../../shared/spec/responses/forbidden.yml'
|
||||
'404':
|
||||
$ref: '../../../../shared/spec/responses/notFound.yml'
|
||||
patch:
|
||||
x-eov-operation-id: changeUserRoleInProject
|
||||
x-eov-operation-handler: v1/handlers/projects/projects.handler
|
||||
tags:
|
||||
- Projects
|
||||
summary: Change a user's role in a project
|
||||
description: Change a user's role in a project.
|
||||
parameters:
|
||||
- name: projectId
|
||||
in: path
|
||||
description: The ID of the project.
|
||||
required: true
|
||||
schema:
|
||||
type: string
|
||||
- name: userId
|
||||
in: path
|
||||
description: The ID of the user.
|
||||
required: true
|
||||
schema:
|
||||
type: string
|
||||
requestBody:
|
||||
description: Updated project object.
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
role:
|
||||
type: string
|
||||
description: The role assigned to the user in the project.
|
||||
example: 'project:viewer'
|
||||
required:
|
||||
- role
|
||||
responses:
|
||||
'204':
|
||||
description: Operation successful.
|
||||
'401':
|
||||
$ref: '../../../../shared/spec/responses/unauthorized.yml'
|
||||
'403':
|
||||
$ref: '../../../../shared/spec/responses/forbidden.yml'
|
||||
'404':
|
||||
$ref: '../../../../shared/spec/responses/notFound.yml'
|
||||
@@ -0,0 +1,49 @@
|
||||
post:
|
||||
x-eov-operation-id: addUsersToProject
|
||||
x-eov-operation-handler: v1/handlers/projects/projects.handler
|
||||
tags:
|
||||
- Projects
|
||||
summary: Add one or more users to a project
|
||||
description: Add one or more users to a project from your instance.
|
||||
parameters:
|
||||
- name: projectId
|
||||
in: path
|
||||
description: The ID of the project.
|
||||
required: true
|
||||
schema:
|
||||
type: string
|
||||
requestBody:
|
||||
description: Payload containing an array of one or more users to add to the project.
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
relations:
|
||||
type: array
|
||||
description: A list of userIds and roles to add to the project.
|
||||
items:
|
||||
type: object
|
||||
properties:
|
||||
userId:
|
||||
type: string
|
||||
description: The unique identifier of the user.
|
||||
example: '91765f0d-3b29-45df-adb9-35b23937eb92'
|
||||
role:
|
||||
type: string
|
||||
description: The role assigned to the user in the project.
|
||||
example: 'project:viewer'
|
||||
required:
|
||||
- userId
|
||||
- role
|
||||
required:
|
||||
- relations
|
||||
responses:
|
||||
'201':
|
||||
description: Operation successful.
|
||||
'401':
|
||||
$ref: '../../../../shared/spec/responses/unauthorized.yml'
|
||||
'403':
|
||||
$ref: '../../../../shared/spec/responses/forbidden.yml'
|
||||
'404':
|
||||
$ref: '../../../../shared/spec/responses/notFound.yml'
|
||||
@@ -6,7 +6,12 @@ delete:
|
||||
summary: Delete a project
|
||||
description: Delete a project from your instance.
|
||||
parameters:
|
||||
- $ref: '../schemas/parameters/projectId.yml'
|
||||
- in: path
|
||||
name: projectId
|
||||
description: The ID of the project.
|
||||
required: true
|
||||
schema:
|
||||
type: string
|
||||
responses:
|
||||
'204':
|
||||
description: Operation successful.
|
||||
@@ -20,9 +25,16 @@ put:
|
||||
x-eov-operation-id: updateProject
|
||||
x-eov-operation-handler: v1/handlers/projects/projects.handler
|
||||
tags:
|
||||
- Project
|
||||
- Projects
|
||||
summary: Update a project
|
||||
description: Update a project.
|
||||
parameters:
|
||||
- in: path
|
||||
name: projectId
|
||||
description: The ID of the project.
|
||||
required: true
|
||||
schema:
|
||||
type: string
|
||||
requestBody:
|
||||
description: Updated project object.
|
||||
content:
|
||||
|
||||
@@ -82,6 +82,10 @@ paths:
|
||||
$ref: './handlers/projects/spec/paths/projects.yml'
|
||||
/projects/{projectId}:
|
||||
$ref: './handlers/projects/spec/paths/projects.projectId.yml'
|
||||
/projects/{projectId}/users:
|
||||
$ref: './handlers/projects/spec/paths/projects.projectId.users.yml'
|
||||
/projects/{projectId}/users/{userId}:
|
||||
$ref: './handlers/projects/spec/paths/projects.projectId.users.userId.yml'
|
||||
components:
|
||||
schemas:
|
||||
$ref: './shared/spec/schemas/_index.yml'
|
||||
|
||||
237
packages/cli/src/services/__tests__/project.service.ee.test.ts
Normal file
237
packages/cli/src/services/__tests__/project.service.ee.test.ts
Normal file
@@ -0,0 +1,237 @@
|
||||
import type { ProjectRelation } from '@n8n/api-types';
|
||||
import type { DatabaseConfig } from '@n8n/config';
|
||||
import type {
|
||||
Project,
|
||||
ProjectRepository,
|
||||
SharedCredentialsRepository,
|
||||
ProjectRelationRepository,
|
||||
SharedCredentials,
|
||||
} from '@n8n/db';
|
||||
import type { EntityManager } from '@n8n/typeorm';
|
||||
import { mock } from 'jest-mock-extended';
|
||||
|
||||
import type { CacheService } from '../cache/cache.service';
|
||||
import { ProjectService } from '../project.service.ee';
|
||||
import type { RoleService } from '../role.service';
|
||||
|
||||
describe('ProjectService', () => {
|
||||
const manager = mock<EntityManager>();
|
||||
const projectRepository = mock<ProjectRepository>();
|
||||
const projectRelationRepository = mock<ProjectRelationRepository>({ manager });
|
||||
const roleService = mock<RoleService>();
|
||||
const sharedCredentialsRepository = mock<SharedCredentialsRepository>();
|
||||
const cacheService = mock<CacheService>();
|
||||
const projectService = new ProjectService(
|
||||
mock(),
|
||||
projectRepository,
|
||||
projectRelationRepository,
|
||||
roleService,
|
||||
sharedCredentialsRepository,
|
||||
cacheService,
|
||||
mock(),
|
||||
mock<DatabaseConfig>({ type: 'postgresdb' }),
|
||||
);
|
||||
|
||||
describe('addUsersToProject', () => {
|
||||
it('throws if called with a personal project', async () => {
|
||||
// ARRANGE
|
||||
const projectId = '12345';
|
||||
projectRepository.findOne.mockResolvedValueOnce(
|
||||
mock<Project>({ type: 'personal', projectRelations: [] }),
|
||||
);
|
||||
roleService.isRoleLicensed.mockReturnValueOnce(true);
|
||||
|
||||
// ACT & ASSERT
|
||||
await expect(
|
||||
projectService.addUsersToProject(projectId, [{ userId: '1234', role: 'project:admin' }]),
|
||||
).rejects.toThrowError("Can't add users to personal projects.");
|
||||
});
|
||||
|
||||
it('throws if trying to add a personalOwner to a team project', async () => {
|
||||
// ARRANGE
|
||||
const projectId = '12345';
|
||||
projectRepository.findOne.mockResolvedValueOnce(
|
||||
mock<Project>({ type: 'team', projectRelations: [] }),
|
||||
);
|
||||
roleService.isRoleLicensed.mockReturnValueOnce(true);
|
||||
|
||||
// ACT & ASSERT
|
||||
await expect(
|
||||
projectService.addUsersToProject(projectId, [
|
||||
{ userId: '1234', role: 'project:personalOwner' },
|
||||
]),
|
||||
).rejects.toThrowError("Can't add a personalOwner to a team project.");
|
||||
});
|
||||
});
|
||||
|
||||
describe('syncProjectRelations', () => {
|
||||
const projectId = '12345';
|
||||
const mockRelations: ProjectRelation[] = [
|
||||
{ userId: 'user1', role: 'project:admin' },
|
||||
{ userId: 'user2', role: 'project:viewer' },
|
||||
];
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
manager.transaction.mockImplementation(async (arg1: unknown, arg2?: unknown) => {
|
||||
const runInTransaction = (arg2 ?? arg1) as (
|
||||
entityManager: EntityManager,
|
||||
) => Promise<unknown>;
|
||||
return await runInTransaction(manager);
|
||||
});
|
||||
});
|
||||
|
||||
it('should successfully sync project relations', async () => {
|
||||
projectRepository.findOne.mockResolvedValueOnce(
|
||||
mock<Project>({
|
||||
id: projectId,
|
||||
type: 'team',
|
||||
projectRelations: [],
|
||||
}),
|
||||
);
|
||||
roleService.isRoleLicensed.mockReturnValue(true);
|
||||
|
||||
sharedCredentialsRepository.find.mockResolvedValueOnce([
|
||||
mock<SharedCredentials>({ credentialsId: 'cred1' }),
|
||||
mock<SharedCredentials>({ credentialsId: 'cred2' }),
|
||||
]);
|
||||
|
||||
await projectService.syncProjectRelations(projectId, mockRelations);
|
||||
|
||||
expect(projectRepository.findOne).toHaveBeenCalledWith({
|
||||
where: { id: projectId, type: 'team' },
|
||||
relations: { projectRelations: true },
|
||||
});
|
||||
|
||||
expect(manager.delete).toHaveBeenCalled();
|
||||
expect(manager.insert).toHaveBeenCalled();
|
||||
expect(cacheService.deleteMany).toHaveBeenCalledWith([
|
||||
'credential-can-use-secrets:cred1',
|
||||
'credential-can-use-secrets:cred2',
|
||||
]);
|
||||
});
|
||||
|
||||
it('should throw error if project not found', async () => {
|
||||
projectRepository.findOne.mockResolvedValueOnce(null);
|
||||
|
||||
await expect(projectService.syncProjectRelations(projectId, mockRelations)).rejects.toThrow(
|
||||
`Could not find project with ID: ${projectId}`,
|
||||
);
|
||||
});
|
||||
|
||||
it('should throw error if unlicensed role is used', async () => {
|
||||
projectRepository.findOne.mockResolvedValueOnce(
|
||||
mock<Project>({
|
||||
id: projectId,
|
||||
type: 'team',
|
||||
projectRelations: [],
|
||||
}),
|
||||
);
|
||||
roleService.isRoleLicensed.mockReturnValue(false);
|
||||
|
||||
await expect(projectService.syncProjectRelations(projectId, mockRelations)).rejects.toThrow(
|
||||
'Your instance is not licensed to use role "project:admin"',
|
||||
);
|
||||
});
|
||||
|
||||
it('should not throw error for existing role even if unlicensed', async () => {
|
||||
projectRepository.findOne.mockResolvedValueOnce(
|
||||
mock<Project>({
|
||||
id: projectId,
|
||||
type: 'team',
|
||||
projectRelations: [{ userId: 'user1', role: 'project:admin' }],
|
||||
}),
|
||||
);
|
||||
roleService.isRoleLicensed.mockReturnValue(false);
|
||||
|
||||
sharedCredentialsRepository.find.mockResolvedValueOnce([]);
|
||||
|
||||
await expect(
|
||||
projectService.syncProjectRelations(projectId, [
|
||||
{ userId: 'user1', role: 'project:admin' },
|
||||
]),
|
||||
).resolves.not.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe('changeUserRoleInProject', () => {
|
||||
const projectId = '12345';
|
||||
const mockRelations: ProjectRelation[] = [
|
||||
{ userId: 'user1', role: 'project:admin' },
|
||||
{ userId: 'user2', role: 'project:viewer' },
|
||||
];
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
manager.transaction.mockImplementation(async (arg1: unknown, arg2?: unknown) => {
|
||||
const runInTransaction = (arg2 ?? arg1) as (
|
||||
entityManager: EntityManager,
|
||||
) => Promise<unknown>;
|
||||
return await runInTransaction(manager);
|
||||
});
|
||||
});
|
||||
|
||||
it('should successfully change the user role in the project', async () => {
|
||||
projectRepository.findOne.mockResolvedValueOnce(
|
||||
mock<Project>({
|
||||
id: projectId,
|
||||
type: 'team',
|
||||
projectRelations: mockRelations,
|
||||
}),
|
||||
);
|
||||
roleService.isRoleLicensed.mockReturnValue(true);
|
||||
|
||||
await projectService.changeUserRoleInProject(projectId, 'user2', 'project:admin');
|
||||
|
||||
expect(projectRepository.findOne).toHaveBeenCalledWith({
|
||||
where: { id: projectId, type: 'team' },
|
||||
relations: { projectRelations: true },
|
||||
});
|
||||
|
||||
expect(projectRelationRepository.update).toHaveBeenCalledWith(
|
||||
{ projectId, userId: 'user2' },
|
||||
{ role: 'project:admin' },
|
||||
);
|
||||
});
|
||||
|
||||
it('should throw if the user is not part of the project', async () => {
|
||||
projectRepository.findOne.mockResolvedValueOnce(
|
||||
mock<Project>({
|
||||
id: projectId,
|
||||
type: 'team',
|
||||
projectRelations: mockRelations,
|
||||
}),
|
||||
);
|
||||
roleService.isRoleLicensed.mockReturnValue(true);
|
||||
|
||||
await expect(
|
||||
projectService.changeUserRoleInProject(projectId, 'user3', 'project:admin'),
|
||||
).rejects.toThrow(`Could not find project with ID: ${projectId}`);
|
||||
|
||||
expect(projectRepository.findOne).toHaveBeenCalledWith({
|
||||
where: { id: projectId, type: 'team' },
|
||||
relations: { projectRelations: true },
|
||||
});
|
||||
});
|
||||
|
||||
it('should throw if the role to be set is `project:personalOwner`', async () => {
|
||||
await expect(
|
||||
projectService.changeUserRoleInProject(projectId, 'user2', 'project:personalOwner'),
|
||||
).rejects.toThrow('Personal owner cannot be added to a team project.');
|
||||
});
|
||||
|
||||
it('should throw if the project is not a team project', async () => {
|
||||
projectRepository.findOne.mockResolvedValueOnce(null);
|
||||
roleService.isRoleLicensed.mockReturnValue(true);
|
||||
|
||||
await expect(
|
||||
projectService.changeUserRoleInProject(projectId, 'user2', 'project:admin'),
|
||||
).rejects.toThrow(`Could not find project with ID: ${projectId}`);
|
||||
|
||||
expect(projectRepository.findOne).toHaveBeenCalledWith({
|
||||
where: { id: projectId, type: 'team' },
|
||||
relations: { projectRelations: true },
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -16,7 +16,7 @@ import { hasGlobalScope, rolesWithScope, type Scope, type ProjectRole } from '@n
|
||||
// eslint-disable-next-line n8n-local-rules/misplaced-n8n-typeorm-import
|
||||
import type { FindOptionsWhere, EntityManager } from '@n8n/typeorm';
|
||||
// eslint-disable-next-line n8n-local-rules/misplaced-n8n-typeorm-import
|
||||
import { In, Not } from '@n8n/typeorm';
|
||||
import { In } from '@n8n/typeorm';
|
||||
import { UserError } from 'n8n-workflow';
|
||||
|
||||
import { BadRequestError } from '@/errors/response-errors/bad-request.error';
|
||||
@@ -24,6 +24,9 @@ import { ForbiddenError } from '@/errors/response-errors/forbidden.error';
|
||||
import { NotFoundError } from '@/errors/response-errors/not-found.error';
|
||||
|
||||
import { CacheService } from './cache/cache.service';
|
||||
import { RoleService } from './role.service';
|
||||
|
||||
type Relation = Pick<ProjectRelation, 'userId' | 'role'>;
|
||||
|
||||
export class TeamProjectOverQuotaError extends UserError {
|
||||
constructor(limit: number) {
|
||||
@@ -39,12 +42,28 @@ export class UnlicensedProjectRoleError extends UserError {
|
||||
}
|
||||
}
|
||||
|
||||
class ProjectNotFoundError extends NotFoundError {
|
||||
constructor(projectId: string) {
|
||||
super(`Could not find project with ID: ${projectId}`);
|
||||
}
|
||||
|
||||
static isDefinedAndNotNull<T>(
|
||||
value: T | undefined | null,
|
||||
projectId: string,
|
||||
): asserts value is T {
|
||||
if (value === undefined || value === null) {
|
||||
throw new ProjectNotFoundError(projectId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Service()
|
||||
export class ProjectService {
|
||||
constructor(
|
||||
private readonly sharedWorkflowRepository: SharedWorkflowRepository,
|
||||
private readonly projectRepository: ProjectRepository,
|
||||
private readonly projectRelationRepository: ProjectRelationRepository,
|
||||
private readonly roleService: RoleService,
|
||||
private readonly sharedCredentialsRepository: SharedCredentialsRepository,
|
||||
private readonly cacheService: CacheService,
|
||||
private readonly licenseState: LicenseState,
|
||||
@@ -86,9 +105,7 @@ export class ProjectService {
|
||||
}
|
||||
|
||||
const project = await this.getProjectWithScope(user, projectId, ['project:delete']);
|
||||
if (!project) {
|
||||
throw new NotFoundError(`Could not find project with ID: ${projectId}`);
|
||||
}
|
||||
ProjectNotFoundError.isDefinedAndNotNull(project, projectId);
|
||||
|
||||
let targetProject: Project | null = null;
|
||||
if (migrateToProject) {
|
||||
@@ -202,7 +219,7 @@ export class ProjectService {
|
||||
);
|
||||
|
||||
// Link admin
|
||||
await this.addUser(project.id, adminUser.id, 'project:admin', trx);
|
||||
await this.addUser(project.id, { userId: adminUser.id, role: 'project:admin' }, trx);
|
||||
|
||||
return project;
|
||||
}
|
||||
@@ -225,16 +242,14 @@ export class ProjectService {
|
||||
}
|
||||
}
|
||||
|
||||
async updateProject(
|
||||
projectId: string,
|
||||
data: Pick<UpdateProjectDto, 'name' | 'icon'>,
|
||||
): Promise<Project> {
|
||||
const result = await this.projectRepository.update({ id: projectId, type: 'team' }, data);
|
||||
|
||||
async updateProject(projectId: string, { name, icon }: UpdateProjectDto): Promise<void> {
|
||||
const result = await this.projectRepository.update(
|
||||
{ id: projectId, type: 'team' },
|
||||
{ name, icon },
|
||||
);
|
||||
if (!result.affected) {
|
||||
throw new ForbiddenError('Project not found');
|
||||
throw new ProjectNotFoundError(projectId);
|
||||
}
|
||||
return await this.projectRepository.findOneByOrFail({ id: projectId });
|
||||
}
|
||||
|
||||
async getPersonalProject(user: User): Promise<Project | null> {
|
||||
@@ -250,22 +265,10 @@ export class ProjectService {
|
||||
|
||||
async syncProjectRelations(
|
||||
projectId: string,
|
||||
relations: Array<{ userId: string; role: ProjectRole }>,
|
||||
relations: Required<UpdateProjectDto>['relations'],
|
||||
) {
|
||||
const project = await this.projectRepository.findOneOrFail({
|
||||
where: { id: projectId, type: Not('personal') },
|
||||
relations: { projectRelations: true },
|
||||
});
|
||||
|
||||
// Check to see if the instance is licensed to use all roles provided
|
||||
for (const r of relations) {
|
||||
const existing = project.projectRelations.find((pr) => pr.userId === r.userId);
|
||||
// We don't throw an error if the user already exists with that role so
|
||||
// existing projects continue working as is.
|
||||
if (existing?.role !== r.role && !this.isProjectRoleLicensed(r.role)) {
|
||||
throw new UnlicensedProjectRoleError(r.role);
|
||||
}
|
||||
}
|
||||
const project = await this.getTeamProjectWithRelations(projectId);
|
||||
this.checkRolesLicensed(project, relations);
|
||||
|
||||
await this.projectRelationRepository.manager.transaction(async (em) => {
|
||||
await this.pruneRelations(em, project);
|
||||
@@ -274,17 +277,81 @@ export class ProjectService {
|
||||
await this.clearCredentialCanUseExternalSecretsCache(projectId);
|
||||
}
|
||||
|
||||
private isProjectRoleLicensed(role: ProjectRole) {
|
||||
switch (role) {
|
||||
case 'project:admin':
|
||||
return this.licenseState.isProjectRoleAdminLicensed();
|
||||
case 'project:editor':
|
||||
return this.licenseState.isProjectRoleEditorLicensed();
|
||||
case 'project:viewer':
|
||||
return this.licenseState.isProjectRoleViewerLicensed();
|
||||
default:
|
||||
return true;
|
||||
/**
|
||||
* Adds users to a team project with specified roles.
|
||||
*
|
||||
* Throws if you the project is a personal project.
|
||||
* Throws if the relations contain `project:personalOwner`.
|
||||
*/
|
||||
async addUsersToProject(projectId: string, relations: Relation[]) {
|
||||
const project = await this.getTeamProjectWithRelations(projectId);
|
||||
this.checkRolesLicensed(project, relations);
|
||||
|
||||
if (project.type === 'personal') {
|
||||
throw new ForbiddenError("Can't add users to personal projects.");
|
||||
}
|
||||
|
||||
if (relations.some((r) => r.role === 'project:personalOwner')) {
|
||||
throw new ForbiddenError("Can't add a personalOwner to a team project.");
|
||||
}
|
||||
|
||||
await this.projectRelationRepository.save(
|
||||
relations.map((relation) => ({ projectId, ...relation })),
|
||||
);
|
||||
}
|
||||
|
||||
private async getTeamProjectWithRelations(projectId: string) {
|
||||
const project = await this.projectRepository.findOne({
|
||||
where: { id: projectId, type: 'team' },
|
||||
relations: { projectRelations: true },
|
||||
});
|
||||
ProjectNotFoundError.isDefinedAndNotNull(project, projectId);
|
||||
return project;
|
||||
}
|
||||
|
||||
/** Check to see if the instance is licensed to use all roles provided */
|
||||
private checkRolesLicensed(project: Project, relations: Relation[]) {
|
||||
for (const { role, userId } of relations) {
|
||||
const existing = project.projectRelations.find((pr) => pr.userId === userId);
|
||||
// We don't throw an error if the user already exists with that role so
|
||||
// existing projects continue working as is.
|
||||
if (existing?.role !== role && !this.roleService.isRoleLicensed(role)) {
|
||||
throw new UnlicensedProjectRoleError(role);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private isUserProjectOwner(project: Project, userId: string) {
|
||||
return project.projectRelations.some(
|
||||
(pr) => pr.userId === userId && pr.role === 'project:personalOwner',
|
||||
);
|
||||
}
|
||||
|
||||
async deleteUserFromProject(projectId: string, userId: string) {
|
||||
const project = await this.getTeamProjectWithRelations(projectId);
|
||||
|
||||
// Prevent project owner from being removed
|
||||
if (this.isUserProjectOwner(project, userId)) {
|
||||
throw new ForbiddenError('Project owner cannot be removed from the project');
|
||||
}
|
||||
|
||||
await this.projectRelationRepository.delete({ projectId: project.id, userId });
|
||||
}
|
||||
|
||||
async changeUserRoleInProject(projectId: string, userId: string, role: ProjectRole) {
|
||||
if (role === 'project:personalOwner') {
|
||||
throw new ForbiddenError('Personal owner cannot be added to a team project.');
|
||||
}
|
||||
|
||||
const project = await this.getTeamProjectWithRelations(projectId);
|
||||
ProjectNotFoundError.isDefinedAndNotNull(project, projectId);
|
||||
|
||||
const projectUserExists = project.projectRelations.some((r) => r.userId === userId);
|
||||
if (!projectUserExists) {
|
||||
throw new ProjectNotFoundError(projectId);
|
||||
}
|
||||
|
||||
await this.projectRelationRepository.update({ projectId, userId }, { role });
|
||||
}
|
||||
|
||||
async clearCredentialCanUseExternalSecretsCache(projectId: string) {
|
||||
@@ -351,7 +418,13 @@ export class ProjectService {
|
||||
});
|
||||
}
|
||||
|
||||
async addUser(projectId: string, userId: string, role: ProjectRole, trx?: EntityManager) {
|
||||
/**
|
||||
* Add a user to a team project with specified roles.
|
||||
*
|
||||
* Throws if you the project is a personal project.
|
||||
* Throws if the relations contain `project:personalOwner`.
|
||||
*/
|
||||
async addUser(projectId: string, { userId, role }: Relation, trx?: EntityManager) {
|
||||
trx = trx ?? this.projectRelationRepository.manager;
|
||||
return await trx.save(ProjectRelation, {
|
||||
projectId,
|
||||
|
||||
@@ -112,7 +112,7 @@ export class RoleService {
|
||||
return [...scopesSet].sort();
|
||||
}
|
||||
|
||||
private isRoleLicensed(role: AllRoleTypes) {
|
||||
isRoleLicensed(role: AllRoleTypes) {
|
||||
// TODO: move this info into FrontendSettings
|
||||
switch (role) {
|
||||
case 'project:admin':
|
||||
|
||||
@@ -231,7 +231,7 @@ describe('GET /credentials', () => {
|
||||
// ARRANGE
|
||||
//
|
||||
const project1 = await projectService.createTeamProject(member, { name: 'Team Project' });
|
||||
await projectService.addUser(project1.id, anotherMember.id, 'project:editor');
|
||||
await projectService.addUser(project1.id, { userId: anotherMember.id, role: 'project:editor' });
|
||||
// anotherMember should see this one
|
||||
const credential1 = await saveCredential(randomCredentialPayload(), { project: project1 });
|
||||
|
||||
|
||||
@@ -510,7 +510,7 @@ describe('PATCH /projects/:projectId', () => {
|
||||
const resp = await ownerAgent
|
||||
.patch(`/projects/${personalProject.id}`)
|
||||
.send({ name: 'New Name' });
|
||||
expect(resp.status).toBe(403);
|
||||
expect(resp.status).toBe(404);
|
||||
|
||||
const updatedProject = await findProject(personalProject.id);
|
||||
expect(updatedProject.name).not.toEqual('New Name');
|
||||
|
||||
@@ -1,24 +1,28 @@
|
||||
import { SharedWorkflowRepository } from '@n8n/db';
|
||||
import { Container } from '@n8n/di';
|
||||
|
||||
import { License } from '@/license';
|
||||
import { ProjectService } from '@/services/project.service.ee';
|
||||
import { LicenseMocker } from '@test-integration/license';
|
||||
|
||||
import { linkUserToProject, createTeamProject } from './shared/db/projects';
|
||||
import { linkUserToProject, createTeamProject, getAllProjectRelations } from './shared/db/projects';
|
||||
import { createUser } from './shared/db/users';
|
||||
import { createWorkflow } from './shared/db/workflows';
|
||||
import * as testDb from './shared/test-db';
|
||||
|
||||
describe('ProjectService', () => {
|
||||
let projectService: ProjectService;
|
||||
|
||||
let sharedWorkflowRepository: SharedWorkflowRepository;
|
||||
|
||||
beforeAll(async () => {
|
||||
await testDb.init();
|
||||
|
||||
projectService = Container.get(ProjectService);
|
||||
|
||||
sharedWorkflowRepository = Container.get(SharedWorkflowRepository);
|
||||
|
||||
const license: LicenseMocker = new LicenseMocker();
|
||||
license.mock(Container.get(License));
|
||||
license.enable('feat:projectRole:editor');
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
@@ -35,6 +39,71 @@ describe('ProjectService', () => {
|
||||
await testDb.terminate();
|
||||
});
|
||||
|
||||
describe('addUsersToProject', () => {
|
||||
it("don't throw a unique constraint violation error when adding a user that is already part of the project", async () => {
|
||||
// ARRANGE
|
||||
const user = await createUser();
|
||||
const project = await createTeamProject('project', user);
|
||||
|
||||
// ACT
|
||||
// add user again
|
||||
await projectService.addUsersToProject(project.id, [
|
||||
{ userId: user.id, role: 'project:admin' },
|
||||
]);
|
||||
|
||||
// ASSERT
|
||||
const relations = await getAllProjectRelations({ projectId: project.id });
|
||||
expect(relations).toHaveLength(1);
|
||||
expect(relations[0]).toMatchObject({
|
||||
projectId: project.id,
|
||||
userId: user.id,
|
||||
role: 'project:admin',
|
||||
});
|
||||
});
|
||||
|
||||
it('allows changing a users role', async () => {
|
||||
// ARRANGE
|
||||
const user = await createUser();
|
||||
const project = await createTeamProject('project', user);
|
||||
|
||||
// ACT
|
||||
// add user again
|
||||
await projectService.addUsersToProject(project.id, [
|
||||
{ userId: user.id, role: 'project:editor' },
|
||||
]);
|
||||
|
||||
// ASSERT
|
||||
const relations = await getAllProjectRelations({ projectId: project.id });
|
||||
expect(relations).toHaveLength(1);
|
||||
expect(relations[0]).toMatchObject({
|
||||
projectId: project.id,
|
||||
userId: user.id,
|
||||
role: 'project:editor',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('addUser', () => {
|
||||
it("don't throw a unique constraint violation error when adding a user that is already part of the project", async () => {
|
||||
// ARRANGE
|
||||
const user = await createUser();
|
||||
const project = await createTeamProject('project', user);
|
||||
|
||||
// ACT
|
||||
// add user again
|
||||
await projectService.addUser(project.id, { userId: user.id, role: 'project:admin' });
|
||||
|
||||
// ASSERT
|
||||
const relations = await getAllProjectRelations({ projectId: project.id });
|
||||
expect(relations).toHaveLength(1);
|
||||
expect(relations[0]).toMatchObject({
|
||||
projectId: project.id,
|
||||
userId: user.id,
|
||||
role: 'project:admin',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('findRolesInProjects', () => {
|
||||
describe('when user has roles in projects where workflow is accessible', () => {
|
||||
it('should return roles and project IDs', async () => {
|
||||
|
||||
@@ -1,8 +1,18 @@
|
||||
import { FeatureNotLicensedError } from '@/errors/feature-not-licensed.error';
|
||||
import { Telemetry } from '@/telemetry';
|
||||
import { mockInstance } from '@test/mocking';
|
||||
import { createTeamProject, getProjectByNameOrFail } from '@test-integration/db/projects';
|
||||
import { createMemberWithApiKey, createOwnerWithApiKey } from '@test-integration/db/users';
|
||||
import {
|
||||
createTeamProject,
|
||||
getProjectByNameOrFail,
|
||||
linkUserToProject,
|
||||
getAllProjectRelations,
|
||||
getProjectRoleForUser,
|
||||
} from '@test-integration/db/projects';
|
||||
import {
|
||||
createMemberWithApiKey,
|
||||
createOwnerWithApiKey,
|
||||
createMember,
|
||||
} from '@test-integration/db/users';
|
||||
import { setupTestServer } from '@test-integration/utils';
|
||||
|
||||
import * as testDb from '../shared/test-db';
|
||||
@@ -394,4 +404,440 @@ describe('Projects in Public API', () => {
|
||||
expect(response.body).toHaveProperty('message', 'Forbidden');
|
||||
});
|
||||
});
|
||||
|
||||
describe('POST /projects/:id/users', () => {
|
||||
it('if not authenticated, should reject with 401', async () => {
|
||||
const project = await createTeamProject();
|
||||
|
||||
const response = await testServer
|
||||
.publicApiAgentWithoutApiKey()
|
||||
.post(`/projects/${project.id}/users`);
|
||||
|
||||
expect(response.status).toBe(401);
|
||||
expect(response.body).toHaveProperty('message', "'X-N8N-API-KEY' header required");
|
||||
});
|
||||
|
||||
it('if not licensed, should reject with a 403', async () => {
|
||||
const owner = await createOwnerWithApiKey();
|
||||
const project = await createTeamProject();
|
||||
const member = await createMember();
|
||||
|
||||
const payload = {
|
||||
relations: [
|
||||
{
|
||||
userId: member.id,
|
||||
role: 'project:viewer',
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const response = await testServer
|
||||
.publicApiAgentFor(owner)
|
||||
.post(`/projects/${project.id}/users`)
|
||||
.send(payload);
|
||||
|
||||
expect(response.status).toBe(403);
|
||||
expect(response.body).toHaveProperty(
|
||||
'message',
|
||||
new FeatureNotLicensedError('feat:projectRole:admin').message,
|
||||
);
|
||||
});
|
||||
|
||||
it('if missing scope, should reject with 403', async () => {
|
||||
testServer.license.setQuota('quota:maxTeamProjects', -1);
|
||||
testServer.license.enable('feat:projectRole:admin');
|
||||
const member = await createMemberWithApiKey();
|
||||
const project = await createTeamProject();
|
||||
|
||||
const payload = {
|
||||
relations: [
|
||||
{
|
||||
userId: member.id,
|
||||
role: 'project:viewer',
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const response = await testServer
|
||||
.publicApiAgentFor(member)
|
||||
.post(`/projects/${project.id}/users`)
|
||||
.send(payload);
|
||||
|
||||
expect(response.status).toBe(403);
|
||||
expect(response.body).toHaveProperty('message', 'Forbidden');
|
||||
});
|
||||
|
||||
describe('when user has correct license', () => {
|
||||
beforeEach(() => {
|
||||
testServer.license.setQuota('quota:maxTeamProjects', -1);
|
||||
testServer.license.enable('feat:projectRole:admin');
|
||||
});
|
||||
|
||||
it("should reject with 400 if the payload can't be validated", async () => {
|
||||
// ARRANGE
|
||||
const owner = await createOwnerWithApiKey();
|
||||
const member = await createMember();
|
||||
|
||||
const payload = {
|
||||
relations: [
|
||||
{
|
||||
userId: member.id,
|
||||
// role does not exist
|
||||
role: 'project:boss',
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
// ACT
|
||||
const response = await testServer
|
||||
.publicApiAgentFor(owner)
|
||||
.post('/projects/123456/users')
|
||||
.send(payload)
|
||||
.expect(400);
|
||||
|
||||
// ASSERT
|
||||
expect(response.body).toHaveProperty(
|
||||
'message',
|
||||
"Invalid enum value. Expected 'project:admin' | 'project:editor' | 'project:viewer', received 'project:boss'",
|
||||
);
|
||||
});
|
||||
|
||||
it('should reject with 404 if no project found', async () => {
|
||||
const owner = await createOwnerWithApiKey();
|
||||
const member = await createMember();
|
||||
|
||||
const payload = {
|
||||
relations: [
|
||||
{
|
||||
userId: member.id,
|
||||
role: 'project:viewer',
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const response = await testServer
|
||||
.publicApiAgentFor(owner)
|
||||
.post('/projects/123456/users')
|
||||
.send(payload);
|
||||
|
||||
expect(response.status).toBe(404);
|
||||
expect(response.body).toHaveProperty('message', 'Could not find project with ID: 123456');
|
||||
});
|
||||
|
||||
it('should add expected users to project', async () => {
|
||||
testServer.license.enable('feat:projectRole:viewer');
|
||||
testServer.license.enable('feat:projectRole:editor');
|
||||
const owner = await createOwnerWithApiKey();
|
||||
const project = await createTeamProject('shared-project', owner);
|
||||
const member = await createMember();
|
||||
const member2 = await createMember();
|
||||
const projectBefore = await getAllProjectRelations({
|
||||
projectId: project.id,
|
||||
});
|
||||
|
||||
const payload = {
|
||||
relations: [
|
||||
{
|
||||
userId: member.id,
|
||||
role: 'project:viewer',
|
||||
},
|
||||
{
|
||||
userId: member2.id,
|
||||
role: 'project:editor',
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const response = await testServer
|
||||
.publicApiAgentFor(owner)
|
||||
.post(`/projects/${project.id}/users`)
|
||||
.send(payload);
|
||||
|
||||
const projectAfter = await getAllProjectRelations({
|
||||
projectId: project.id,
|
||||
});
|
||||
|
||||
expect(response.status).toBe(201);
|
||||
expect(projectBefore.length).toEqual(1);
|
||||
expect(projectBefore[0].userId).toEqual(owner.id);
|
||||
|
||||
expect(projectAfter.length).toEqual(3);
|
||||
const adminRelation = projectAfter.find(
|
||||
(relation) => relation.userId === owner.id && relation.role === 'project:admin',
|
||||
);
|
||||
expect(adminRelation).toEqual(
|
||||
expect.objectContaining({ userId: owner.id, role: 'project:admin' }),
|
||||
);
|
||||
const viewerRelation = projectAfter.find(
|
||||
(relation) => relation.userId === member.id && relation.role === 'project:viewer',
|
||||
);
|
||||
expect(viewerRelation).toEqual(
|
||||
expect.objectContaining({ userId: member.id, role: 'project:viewer' }),
|
||||
);
|
||||
const editorRelation = projectAfter.find(
|
||||
(relation) => relation.userId === member2.id && relation.role === 'project:editor',
|
||||
);
|
||||
expect(editorRelation).toEqual(
|
||||
expect.objectContaining({ userId: member2.id, role: 'project:editor' }),
|
||||
);
|
||||
});
|
||||
|
||||
it('should reject with 400 if license does not include user role', async () => {
|
||||
const owner = await createOwnerWithApiKey();
|
||||
const project = await createTeamProject('shared-project', owner);
|
||||
const member = await createMember();
|
||||
|
||||
const payload = {
|
||||
relations: [
|
||||
{
|
||||
userId: member.id,
|
||||
role: 'project:viewer',
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const response = await testServer
|
||||
.publicApiAgentFor(owner)
|
||||
.post(`/projects/${project.id}/users`)
|
||||
.send(payload);
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
expect(response.body).toHaveProperty(
|
||||
'message',
|
||||
'Your instance is not licensed to use role "project:viewer".',
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('PATCH /projects/:id/users/:userId', () => {
|
||||
it('if not authenticated, should reject with 401', async () => {
|
||||
const response = await testServer
|
||||
.publicApiAgentWithoutApiKey()
|
||||
.patch('/projects/123/users/456')
|
||||
.send({ role: 'project:viewer' });
|
||||
|
||||
expect(response.status).toBe(401);
|
||||
expect(response.body).toHaveProperty('message', "'X-N8N-API-KEY' header required");
|
||||
});
|
||||
|
||||
it('if not licensed, should reject with a 403', async () => {
|
||||
const owner = await createOwnerWithApiKey();
|
||||
|
||||
const response = await testServer
|
||||
.publicApiAgentFor(owner)
|
||||
.patch('/projects/123/users/456')
|
||||
.send({ role: 'project:viewer' });
|
||||
|
||||
expect(response.status).toBe(403);
|
||||
expect(response.body).toHaveProperty(
|
||||
'message',
|
||||
new FeatureNotLicensedError('feat:projectRole:admin').message,
|
||||
);
|
||||
});
|
||||
|
||||
it('if missing scope, should reject with 403', async () => {
|
||||
testServer.license.setQuota('quota:maxTeamProjects', -1);
|
||||
testServer.license.enable('feat:projectRole:admin');
|
||||
const member = await createMemberWithApiKey();
|
||||
|
||||
const response = await testServer
|
||||
.publicApiAgentFor(member)
|
||||
.patch('/projects/123/users/456')
|
||||
.send({ role: 'project:viewer' });
|
||||
|
||||
expect(response.status).toBe(403);
|
||||
expect(response.body).toHaveProperty('message', 'Forbidden');
|
||||
});
|
||||
|
||||
describe('when user has correct license', () => {
|
||||
beforeEach(() => {
|
||||
testServer.license.setQuota('quota:maxTeamProjects', -1);
|
||||
testServer.license.enable('feat:projectRole:admin');
|
||||
});
|
||||
|
||||
it("should reject with 400 if the payload can't be validated", async () => {
|
||||
// ARRANGE
|
||||
const owner = await createOwnerWithApiKey();
|
||||
|
||||
// ACT
|
||||
const response = await testServer
|
||||
.publicApiAgentFor(owner)
|
||||
.patch('/projects/1234/users/1235')
|
||||
// role does not exist
|
||||
.send({ role: 'project:boss' })
|
||||
.expect(400);
|
||||
|
||||
// ASSERT
|
||||
expect(response.body).toHaveProperty(
|
||||
'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 () => {
|
||||
const owner = await createOwnerWithApiKey();
|
||||
const project = await createTeamProject('shared-project', owner);
|
||||
|
||||
const member = await createMember();
|
||||
expect(await getProjectRoleForUser(project.id, member.id)).toBeUndefined();
|
||||
|
||||
await linkUserToProject(member, project, 'project:viewer');
|
||||
expect(await getProjectRoleForUser(project.id, member.id)).toBe('project:viewer');
|
||||
|
||||
await testServer
|
||||
.publicApiAgentFor(owner)
|
||||
.patch(`/projects/${project.id}/users/${member.id}`)
|
||||
.send({ role: 'project:editor' })
|
||||
.expect(204);
|
||||
|
||||
expect(await getProjectRoleForUser(project.id, member.id)).toBe('project:editor');
|
||||
});
|
||||
|
||||
it('should reject with 404 if no project found', async () => {
|
||||
const owner = await createOwnerWithApiKey();
|
||||
const member = await createMember();
|
||||
|
||||
const response = await testServer
|
||||
.publicApiAgentFor(owner)
|
||||
.patch(`/projects/123456/users/${member.id}`)
|
||||
.send({ role: 'project:editor' })
|
||||
.expect(404);
|
||||
|
||||
expect(response.body).toHaveProperty('message', 'Could not find project with ID: 123456');
|
||||
});
|
||||
|
||||
it('should reject with 404 if user is not in the project', async () => {
|
||||
const owner = await createOwnerWithApiKey();
|
||||
const project = await createTeamProject('shared-project', owner);
|
||||
const member = await createMember();
|
||||
|
||||
expect(await getProjectRoleForUser(project.id, member.id)).toBeUndefined();
|
||||
|
||||
const response = await testServer
|
||||
.publicApiAgentFor(owner)
|
||||
.patch(`/projects/${project.id}/users/${member.id}`)
|
||||
.send({ role: 'project:editor' })
|
||||
.expect(404);
|
||||
|
||||
expect(response.body).toHaveProperty(
|
||||
'message',
|
||||
`Could not find project with ID: ${project.id}`,
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('DELETE /projects/:id/users/:userId', () => {
|
||||
it('if not authenticated, should reject with 401', async () => {
|
||||
const project = await createTeamProject();
|
||||
const member = await createMember();
|
||||
|
||||
const response = await testServer
|
||||
.publicApiAgentWithoutApiKey()
|
||||
.delete(`/projects/${project.id}/users/${member.id}`);
|
||||
|
||||
expect(response.status).toBe(401);
|
||||
expect(response.body).toHaveProperty('message', "'X-N8N-API-KEY' header required");
|
||||
});
|
||||
|
||||
it('if not licensed, should reject with a 403', async () => {
|
||||
const owner = await createOwnerWithApiKey();
|
||||
const project = await createTeamProject();
|
||||
const member = await createMember();
|
||||
|
||||
const response = await testServer
|
||||
.publicApiAgentFor(owner)
|
||||
.delete(`/projects/${project.id}/users/${member.id}`);
|
||||
|
||||
expect(response.status).toBe(403);
|
||||
expect(response.body).toHaveProperty(
|
||||
'message',
|
||||
new FeatureNotLicensedError('feat:projectRole:admin').message,
|
||||
);
|
||||
});
|
||||
|
||||
it('if missing scope, should reject with 403', async () => {
|
||||
testServer.license.setQuota('quota:maxTeamProjects', -1);
|
||||
testServer.license.enable('feat:projectRole:admin');
|
||||
const member = await createMemberWithApiKey();
|
||||
const project = await createTeamProject();
|
||||
|
||||
const response = await testServer
|
||||
.publicApiAgentFor(member)
|
||||
.delete(`/projects/${project.id}/users/${member.id}`);
|
||||
|
||||
expect(response.status).toBe(403);
|
||||
expect(response.body).toHaveProperty('message', 'Forbidden');
|
||||
});
|
||||
|
||||
describe('when user has correct license', () => {
|
||||
beforeEach(() => {
|
||||
testServer.license.setQuota('quota:maxTeamProjects', -1);
|
||||
testServer.license.enable('feat:projectRole:admin');
|
||||
});
|
||||
|
||||
it('should remove given user from project', async () => {
|
||||
const owner = await createOwnerWithApiKey();
|
||||
const project = await createTeamProject('shared-project', owner);
|
||||
const member = await createMember();
|
||||
await linkUserToProject(member, project, 'project:viewer');
|
||||
const projectBefore = await getAllProjectRelations({
|
||||
projectId: project.id,
|
||||
});
|
||||
|
||||
const response = await testServer
|
||||
.publicApiAgentFor(owner)
|
||||
.delete(`/projects/${project.id}/users/${member.id}`);
|
||||
|
||||
const projectAfter = await getAllProjectRelations({
|
||||
projectId: project.id,
|
||||
});
|
||||
|
||||
expect(response.status).toBe(204);
|
||||
expect(projectBefore.length).toEqual(2);
|
||||
expect(projectBefore.find((p) => p.role === 'project:admin')?.userId).toEqual(owner.id);
|
||||
expect(projectBefore.find((p) => p.role === 'project:viewer')?.userId).toEqual(member.id);
|
||||
|
||||
expect(projectAfter.length).toEqual(1);
|
||||
expect(projectAfter[0].userId).toEqual(owner.id);
|
||||
});
|
||||
|
||||
it('should reject with 404 if no project found', async () => {
|
||||
const owner = await createOwnerWithApiKey();
|
||||
const member = await createMember();
|
||||
|
||||
const response = await testServer
|
||||
.publicApiAgentFor(owner)
|
||||
.delete(`/projects/123456/users/${member.id}`);
|
||||
|
||||
expect(response.status).toBe(404);
|
||||
expect(response.body).toHaveProperty('message', 'Could not find project with ID: 123456');
|
||||
});
|
||||
|
||||
it('should remain unchanged if user if not in project', async () => {
|
||||
const owner = await createOwnerWithApiKey();
|
||||
const project = await createTeamProject('shared-project', owner);
|
||||
const member = await createMember();
|
||||
const projectBefore = await getAllProjectRelations({
|
||||
projectId: project.id,
|
||||
});
|
||||
|
||||
const response = await testServer
|
||||
.publicApiAgentFor(owner)
|
||||
.delete(`/projects/${project.id}/users/${member.id}`);
|
||||
|
||||
const projectAfter = await getAllProjectRelations({
|
||||
projectId: project.id,
|
||||
});
|
||||
|
||||
expect(response.status).toBe(204);
|
||||
expect(projectBefore.length).toEqual(1);
|
||||
expect(projectBefore[0].userId).toEqual(owner.id);
|
||||
|
||||
expect(projectAfter.length).toEqual(1);
|
||||
expect(projectAfter[0].userId).toEqual(owner.id);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -52,7 +52,7 @@ describe('ProjectService', () => {
|
||||
//
|
||||
// ACT
|
||||
//
|
||||
await projectService.addUser(project.id, member.id, role);
|
||||
await projectService.addUser(project.id, { userId: member.id, role });
|
||||
|
||||
//
|
||||
// ASSERT
|
||||
@@ -74,7 +74,7 @@ describe('ProjectService', () => {
|
||||
type: 'team',
|
||||
}),
|
||||
);
|
||||
await projectService.addUser(project.id, member.id, 'project:viewer');
|
||||
await projectService.addUser(project.id, { userId: member.id, role: 'project:viewer' });
|
||||
|
||||
await projectRelationRepository.findOneOrFail({
|
||||
where: { userId: member.id, projectId: project.id, role: 'project:viewer' },
|
||||
@@ -83,7 +83,7 @@ describe('ProjectService', () => {
|
||||
//
|
||||
// ACT
|
||||
//
|
||||
await projectService.addUser(project.id, member.id, 'project:admin');
|
||||
await projectService.addUser(project.id, { userId: member.id, role: 'project:admin' });
|
||||
|
||||
//
|
||||
// ASSERT
|
||||
@@ -117,7 +117,7 @@ describe('ProjectService', () => {
|
||||
type: 'team',
|
||||
}),
|
||||
);
|
||||
await projectService.addUser(project.id, projectOwner.id, role);
|
||||
await projectService.addUser(project.id, { userId: projectOwner.id, role });
|
||||
|
||||
//
|
||||
// ACT
|
||||
@@ -157,7 +157,7 @@ describe('ProjectService', () => {
|
||||
type: 'team',
|
||||
}),
|
||||
);
|
||||
await projectService.addUser(project.id, projectViewer.id, role);
|
||||
await projectService.addUser(project.id, { userId: projectViewer.id, role });
|
||||
|
||||
//
|
||||
// ACT
|
||||
@@ -200,4 +200,44 @@ describe('ProjectService', () => {
|
||||
expect(projectFromService).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('deleteUserFromProject', () => {
|
||||
it('should not allow project owner to be removed from the project', async () => {
|
||||
const role = 'project:personalOwner';
|
||||
|
||||
const user = await createMember();
|
||||
const project = await projectRepository.save(
|
||||
projectRepository.create({
|
||||
name: 'Team Project',
|
||||
type: 'team',
|
||||
}),
|
||||
);
|
||||
await projectService.addUser(project.id, { userId: user.id, role });
|
||||
|
||||
await expect(projectService.deleteUserFromProject(project.id, user.id)).rejects.toThrowError(
|
||||
/^Project owner cannot be removed from the project$/,
|
||||
);
|
||||
});
|
||||
|
||||
it('should remove user from project if not owner', async () => {
|
||||
const role = 'project:editor';
|
||||
|
||||
const user = await createMember();
|
||||
const project = await projectRepository.save(
|
||||
projectRepository.create({
|
||||
name: 'Team Project',
|
||||
type: 'team',
|
||||
}),
|
||||
);
|
||||
await projectService.addUser(project.id, { userId: user.id, role });
|
||||
|
||||
await projectService.deleteUserFromProject(project.id, user.id);
|
||||
|
||||
const relations = await projectRelationRepository.findOne({
|
||||
where: { userId: user.id, projectId: project.id, role },
|
||||
});
|
||||
|
||||
expect(relations).toBeNull();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -66,3 +66,23 @@ export const getProjectRelations = async ({
|
||||
where: { projectId, userId, role },
|
||||
});
|
||||
};
|
||||
|
||||
export const getProjectRoleForUser = async (
|
||||
projectId: string,
|
||||
userId: string,
|
||||
): Promise<ProjectRole | undefined> => {
|
||||
return (
|
||||
await Container.get(ProjectRelationRepository).findOne({
|
||||
select: ['role'],
|
||||
where: { projectId, userId },
|
||||
})
|
||||
)?.role;
|
||||
};
|
||||
|
||||
export const getAllProjectRelations = async ({
|
||||
projectId,
|
||||
}: Partial<ProjectRelation>): Promise<ProjectRelation[]> => {
|
||||
return await Container.get(ProjectRelationRepository).find({
|
||||
where: { projectId },
|
||||
});
|
||||
};
|
||||
|
||||
@@ -72,7 +72,7 @@ describe('WorkflowSharingService', () => {
|
||||
// ARRANGE
|
||||
//
|
||||
const project = await projectService.createTeamProject(member, { name: 'Team Project' });
|
||||
await projectService.addUser(project.id, anotherMember.id, 'project:admin');
|
||||
await projectService.addUser(project.id, { userId: anotherMember.id, role: 'project:admin' });
|
||||
const workflow = await createWorkflow(undefined, project);
|
||||
|
||||
//
|
||||
@@ -96,8 +96,14 @@ describe('WorkflowSharingService', () => {
|
||||
const workflow1 = await createWorkflow(undefined, project1);
|
||||
const project2 = await projectService.createTeamProject(member, { name: 'Team Project 2' });
|
||||
const workflow2 = await createWorkflow(undefined, project2);
|
||||
await projectService.addUser(project1.id, anotherMember.id, 'project:admin');
|
||||
await projectService.addUser(project2.id, anotherMember.id, 'project:viewer');
|
||||
await projectService.addUser(project1.id, {
|
||||
userId: anotherMember.id,
|
||||
role: 'project:admin',
|
||||
});
|
||||
await projectService.addUser(project2.id, {
|
||||
userId: anotherMember.id,
|
||||
role: 'project:viewer',
|
||||
});
|
||||
|
||||
//
|
||||
// ACT
|
||||
|
||||
@@ -54,7 +54,6 @@ const { objectContaining, arrayContaining, any } = expect;
|
||||
const activeWorkflowManagerLike = mockInstance(ActiveWorkflowManager);
|
||||
|
||||
let projectRepository: ProjectRepository;
|
||||
let projectService: ProjectService;
|
||||
|
||||
beforeEach(async () => {
|
||||
await testDb.truncate([
|
||||
@@ -68,7 +67,6 @@ beforeEach(async () => {
|
||||
'User',
|
||||
]);
|
||||
projectRepository = Container.get(ProjectRepository);
|
||||
projectService = Container.get(ProjectService);
|
||||
owner = await createOwner();
|
||||
authOwnerAgent = testServer.authAgentFor(owner);
|
||||
member = await createMember();
|
||||
@@ -288,7 +286,10 @@ describe('POST /workflows', () => {
|
||||
type: 'team',
|
||||
}),
|
||||
);
|
||||
await projectService.addUser(project.id, owner.id, 'project:admin');
|
||||
await Container.get(ProjectService).addUser(project.id, {
|
||||
userId: owner.id,
|
||||
role: 'project:admin',
|
||||
});
|
||||
|
||||
//
|
||||
// ACT
|
||||
@@ -362,7 +363,10 @@ describe('POST /workflows', () => {
|
||||
type: 'team',
|
||||
}),
|
||||
);
|
||||
await projectService.addUser(project.id, member.id, 'project:viewer');
|
||||
await Container.get(ProjectService).addUser(project.id, {
|
||||
userId: member.id,
|
||||
role: 'project:viewer',
|
||||
});
|
||||
|
||||
//
|
||||
// ACT
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<script lang="ts" setup>
|
||||
import type { ProjectRole } from '@n8n/permissions';
|
||||
import type { ProjectRole, TeamProjectRole } from '@n8n/permissions';
|
||||
import { computed, ref, watch, onBeforeMount, onMounted, nextTick } from 'vue';
|
||||
import { useRouter } from 'vue-router';
|
||||
import { deepCopy } from 'n8n-workflow';
|
||||
@@ -192,12 +192,15 @@ const updateProject = async () => {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
if (formData.value.relations.some((r) => r.role === 'project:personalOwner')) {
|
||||
throw new Error('Invalid role selected for this project.');
|
||||
}
|
||||
await projectsStore.updateProject(projectsStore.currentProject.id, {
|
||||
name: formData.value.name!,
|
||||
icon: projectIcon.value,
|
||||
relations: formData.value.relations.map((r: ProjectRelation) => ({
|
||||
userId: r.id,
|
||||
role: r.role,
|
||||
role: r.role as TeamProjectRole,
|
||||
})),
|
||||
});
|
||||
isDirty.value = false;
|
||||
|
||||
Reference in New Issue
Block a user