feat(core): Rebuild project roles to load from the database (#17909)

This commit is contained in:
Guillaume Jacquart
2025-08-28 11:00:31 +02:00
committed by GitHub
parent ab7998b441
commit f757790394
63 changed files with 546 additions and 305 deletions

View File

@@ -1,3 +1,5 @@
import { PROJECT_OWNER_ROLE_SLUG } from '@n8n/permissions';
import { ChangeUserRoleInProject } from '../change-user-role-in-project.dto'; import { ChangeUserRoleInProject } from '../change-user-role-in-project.dto';
describe('ChangeUserRoleInProject', () => { describe('ChangeUserRoleInProject', () => {
@@ -38,7 +40,7 @@ describe('ChangeUserRoleInProject', () => {
}, },
{ {
name: 'personal owner role', name: 'personal owner role',
request: { role: 'project:personalOwner' }, request: { role: PROJECT_OWNER_ROLE_SLUG },
expectedErrorPath: ['role'], expectedErrorPath: ['role'],
}, },
])('should reject $name', ({ request, expectedErrorPath }) => { ])('should reject $name', ({ request, expectedErrorPath }) => {

View File

@@ -1,6 +1,6 @@
import { projectRoleSchema } from '@n8n/permissions'; import { teamRoleSchema } 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: projectRoleSchema.exclude(['project:personalOwner']), role: teamRoleSchema,
}) {} }) {}

View File

@@ -1,4 +1,4 @@
import { projectRoleSchema } from '@n8n/permissions'; import { teamRoleSchema } 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: projectRoleSchema.exclude(['project:personalOwner']), role: teamRoleSchema,
}); });
export type ProjectRelation = z.infer<typeof projectRelationSchema>; export type ProjectRelation = z.infer<typeof projectRelationSchema>;

View File

@@ -1,17 +1,17 @@
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 type { ProjectRole } from '@n8n/permissions'; import { PROJECT_OWNER_ROLE_SLUG, type CustomRole } from '@n8n/permissions';
import { randomName } from '../random'; import { randomName } from '../random';
export const linkUserToProject = async (user: User, project: Project, role: ProjectRole) => { export const linkUserToProject = async (user: User, project: Project, role: CustomRole) => {
const projectRelationRepository = Container.get(ProjectRelationRepository); const projectRelationRepository = Container.get(ProjectRelationRepository);
await projectRelationRepository.save( await projectRelationRepository.save(
projectRelationRepository.create({ projectRelationRepository.create({
projectId: project.id, projectId: project.id,
userId: user.id, userId: user.id,
role, role: { slug: role },
}), }),
); );
}; };
@@ -41,7 +41,7 @@ export const getPersonalProject = async (user: User): Promise<Project> => {
where: { where: {
projectRelations: { projectRelations: {
userId: user.id, userId: user.id,
role: 'project:personalOwner', role: { slug: PROJECT_OWNER_ROLE_SLUG },
}, },
type: 'personal', type: 'personal',
}, },
@@ -61,19 +61,20 @@ export const getProjectRelations = async ({
}: Partial<ProjectRelation>): Promise<ProjectRelation[]> => { }: Partial<ProjectRelation>): Promise<ProjectRelation[]> => {
return await Container.get(ProjectRelationRepository).find({ return await Container.get(ProjectRelationRepository).find({
where: { projectId, userId, role }, where: { projectId, userId, role },
relations: { role: true },
}); });
}; };
export const getProjectRoleForUser = async ( export const getProjectRoleForUser = async (
projectId: string, projectId: string,
userId: string, userId: string,
): Promise<ProjectRole | undefined> => { ): Promise<CustomRole | undefined> => {
return ( return (
await Container.get(ProjectRelationRepository).findOne({ await Container.get(ProjectRelationRepository).findOne({
select: ['role'],
where: { projectId, userId }, where: { projectId, userId },
relations: { role: true },
}) })
)?.role; )?.role?.slug;
}; };
export const getAllProjectRelations = async ({ export const getAllProjectRelations = async ({
@@ -81,5 +82,6 @@ export const getAllProjectRelations = async ({
}: Partial<ProjectRelation>): Promise<ProjectRelation[]> => { }: Partial<ProjectRelation>): Promise<ProjectRelation[]> => {
return await Container.get(ProjectRelationRepository).find({ return await Container.get(ProjectRelationRepository).find({
where: { projectId }, where: { projectId },
relations: { role: true },
}); });
}; };

View File

@@ -1,4 +1,13 @@
import { GLOBAL_SCOPE_MAP, type GlobalRole } from '@n8n/permissions'; import {
GLOBAL_SCOPE_MAP,
PROJECT_ADMIN_ROLE_SLUG,
PROJECT_EDITOR_ROLE_SLUG,
PROJECT_OWNER_ROLE_SLUG,
PROJECT_SCOPE_MAP,
PROJECT_VIEWER_ROLE_SLUG,
type GlobalRole,
type ProjectRole,
} from '@n8n/permissions';
import type { Role } from 'entities'; import type { Role } from 'entities';
@@ -16,15 +25,44 @@ export function buildInRoleToRoleObject(role: GlobalRole): Role {
systemRole: true, systemRole: true,
roleType: 'global', roleType: 'global',
description: `Built-in global role with ${role} permissions.`, description: `Built-in global role with ${role} permissions.`,
}; } as Role;
}
export function buildInProjectRoleToRoleObject(role: ProjectRole): Role {
return {
slug: role,
displayName: role,
scopes: PROJECT_SCOPE_MAP[role].map((scope) => {
return {
slug: scope,
displayName: scope,
description: null,
};
}),
systemRole: true,
roleType: 'project',
description: `Built-in project role with ${role} permissions.`,
} as Role;
} }
export const GLOBAL_OWNER_ROLE = buildInRoleToRoleObject('global:owner'); export const GLOBAL_OWNER_ROLE = buildInRoleToRoleObject('global:owner');
export const GLOBAL_ADMIN_ROLE = buildInRoleToRoleObject('global:admin'); export const GLOBAL_ADMIN_ROLE = buildInRoleToRoleObject('global:admin');
export const GLOBAL_MEMBER_ROLE = buildInRoleToRoleObject('global:member'); export const GLOBAL_MEMBER_ROLE = buildInRoleToRoleObject('global:member');
export const PROJECT_OWNER_ROLE = buildInProjectRoleToRoleObject(PROJECT_OWNER_ROLE_SLUG);
export const PROJECT_ADMIN_ROLE = buildInProjectRoleToRoleObject(PROJECT_ADMIN_ROLE_SLUG);
export const PROJECT_EDITOR_ROLE = buildInProjectRoleToRoleObject(PROJECT_EDITOR_ROLE_SLUG);
export const PROJECT_VIEWER_ROLE = buildInProjectRoleToRoleObject(PROJECT_VIEWER_ROLE_SLUG);
export const GLOBAL_ROLES: Record<GlobalRole, Role> = { export const GLOBAL_ROLES: Record<GlobalRole, Role> = {
'global:owner': GLOBAL_OWNER_ROLE, 'global:owner': GLOBAL_OWNER_ROLE,
'global:admin': GLOBAL_ADMIN_ROLE, 'global:admin': GLOBAL_ADMIN_ROLE,
'global:member': GLOBAL_MEMBER_ROLE, 'global:member': GLOBAL_MEMBER_ROLE,
}; };
export const PROJECT_ROLES: Record<ProjectRole, Role> = {
[PROJECT_OWNER_ROLE_SLUG]: PROJECT_OWNER_ROLE,
[PROJECT_ADMIN_ROLE_SLUG]: PROJECT_ADMIN_ROLE,
[PROJECT_EDITOR_ROLE_SLUG]: PROJECT_EDITOR_ROLE,
[PROJECT_VIEWER_ROLE_SLUG]: PROJECT_VIEWER_ROLE,
};

View File

@@ -1,14 +1,15 @@
import { ProjectRole } from '@n8n/permissions'; import { Entity, JoinColumn, ManyToOne, PrimaryColumn } from '@n8n/typeorm';
import { Column, Entity, ManyToOne, PrimaryColumn } from '@n8n/typeorm';
import { WithTimestamps } from './abstract-entity'; import { WithTimestamps } from './abstract-entity';
import { Project } from './project'; import { Project } from './project';
import { Role } from './role';
import { User } from './user'; import { User } from './user';
@Entity() @Entity()
export class ProjectRelation extends WithTimestamps { export class ProjectRelation extends WithTimestamps {
@Column({ type: 'varchar' }) @ManyToOne('Role', 'projectRelations')
role: ProjectRole; @JoinColumn({ name: 'role', referencedColumnName: 'slug' })
role: Role;
@ManyToOne('User', 'projectRelations') @ManyToOne('User', 'projectRelations')
user: User; user: User;

View File

@@ -1,5 +1,6 @@
import { Column, Entity, JoinTable, ManyToMany, PrimaryColumn } from '@n8n/typeorm'; import { Column, Entity, JoinTable, ManyToMany, OneToMany, PrimaryColumn } from '@n8n/typeorm';
import type { ProjectRelation } from './project-relation';
import { Scope } from './scope'; import { Scope } from './scope';
@Entity({ @Entity({
@@ -45,6 +46,9 @@ export class Role {
*/ */
roleType: 'global' | 'project' | 'workflow' | 'credential'; roleType: 'global' | 'project' | 'workflow' | 'credential';
@OneToMany('ProjectRelation', 'role')
projectRelations: ProjectRelation[];
@ManyToMany(() => Scope, { @ManyToMany(() => Scope, {
eager: true, eager: true,
}) })

View File

@@ -0,0 +1,46 @@
import { PROJECT_ROLES, PROJECT_VIEWER_ROLE } from '../../constants';
import type { MigrationContext, ReversibleMigration } from '../migration-types';
/*
* This migration links the role table to the project relation table, by adding a new foreign key on the 'role' column
* It also ensures that all project relations have a valid role set in the 'role' column.
* The migration will insert the project roles that we need into the role table if they do not exist.
*/
export class LinkRoleToProjectRelationTable1753953244168 implements ReversibleMigration {
async up({ schemaBuilder: { addForeignKey }, escape, dbType, runQuery }: MigrationContext) {
const roleTableName = escape.tableName('role');
const projectRelationTableName = escape.tableName('project_relation');
const slugColumn = escape.columnName('slug');
const roleColumn = escape.columnName('role');
const roleTypeColumn = escape.columnName('roleType');
const systemRoleColumn = escape.columnName('systemRole');
const isPostgresOrSqlite = dbType === 'postgresdb' || dbType === 'sqlite';
const query = isPostgresOrSqlite
? `INSERT INTO ${roleTableName} (${slugColumn}, ${roleTypeColumn}, ${systemRoleColumn}) VALUES (:slug, :roleType, :systemRole) ON CONFLICT DO NOTHING`
: `INSERT IGNORE INTO ${roleTableName} (${slugColumn}, ${roleTypeColumn}, ${systemRoleColumn}) VALUES (:slug, :roleType, :systemRole)`;
// Make sure that the project roles that we need exist
for (const role of Object.values(PROJECT_ROLES)) {
await runQuery(query, {
slug: role.slug,
roleType: role.roleType,
systemRole: role.systemRole,
});
}
// Fallback to 'project:viewer' for users that do not have a correct role set
// This should not happen in a correctly set up system, but we want to ensure
// that all users have a role set, before we add the foreign key constraint
await runQuery(
`UPDATE ${projectRelationTableName} SET ${roleColumn} = '${PROJECT_VIEWER_ROLE.slug}' WHERE NOT EXISTS (SELECT 1 FROM ${roleTableName} WHERE ${slugColumn} = ${roleColumn})`,
);
await addForeignKey('project_relation', 'role', ['role', 'slug']);
}
async down({ schemaBuilder: { dropForeignKey } }: MigrationContext) {
await dropForeignKey('project_relation', 'role', ['role', 'slug']);
}
}

View File

@@ -1,4 +1,5 @@
import { AddMfaColumns1690000000030 } from './../common/1690000000040-AddMfaColumns'; import { AddMfaColumns1690000000030 } from './../common/1690000000040-AddMfaColumns';
import { LinkRoleToProjectRelationTable1753953244168 } from './../common/1753953244168-LinkRoleToProjectRelationTable';
import { InitialMigration1588157391238 } from './1588157391238-InitialMigration'; import { InitialMigration1588157391238 } from './1588157391238-InitialMigration';
import { WebhookModel1592447867632 } from './1592447867632-WebhookModel'; import { WebhookModel1592447867632 } from './1592447867632-WebhookModel';
import { CreateIndexStoppedAt1594902918301 } from './1594902918301-CreateIndexStoppedAt'; import { CreateIndexStoppedAt1594902918301 } from './1594902918301-CreateIndexStoppedAt';
@@ -197,4 +198,5 @@ export const mysqlMigrations: Migration[] = [
CreateDataStoreTables1754475614601, CreateDataStoreTables1754475614601,
RemoveOldRoleColumn1750252139170, RemoveOldRoleColumn1750252139170,
ReplaceDataStoreTablesWithDataTables1754475614602, ReplaceDataStoreTablesWithDataTables1754475614602,
LinkRoleToProjectRelationTable1753953244168,
]; ];

View File

@@ -1,5 +1,6 @@
import { AddMfaColumns1690000000030 } from './../common/1690000000040-AddMfaColumns'; import { AddMfaColumns1690000000030 } from './../common/1690000000040-AddMfaColumns';
import { AddInputsOutputsToTestCaseExecution1752669793000 } from './../common/1752669793000-AddInputsOutputsToTestCaseExecution'; import { AddInputsOutputsToTestCaseExecution1752669793000 } from './../common/1752669793000-AddInputsOutputsToTestCaseExecution';
import { LinkRoleToProjectRelationTable1753953244168 } from './../common/1753953244168-LinkRoleToProjectRelationTable';
import { InitialMigration1587669153312 } from './1587669153312-InitialMigration'; import { InitialMigration1587669153312 } from './1587669153312-InitialMigration';
import { WebhookModel1589476000887 } from './1589476000887-WebhookModel'; import { WebhookModel1589476000887 } from './1589476000887-WebhookModel';
import { CreateIndexStoppedAt1594828256133 } from './1594828256133-CreateIndexStoppedAt'; import { CreateIndexStoppedAt1594828256133 } from './1594828256133-CreateIndexStoppedAt';
@@ -195,4 +196,5 @@ export const postgresMigrations: Migration[] = [
CreateDataStoreTables1754475614601, CreateDataStoreTables1754475614601,
RemoveOldRoleColumn1750252139170, RemoveOldRoleColumn1750252139170,
ReplaceDataStoreTablesWithDataTables1754475614602, ReplaceDataStoreTablesWithDataTables1754475614602,
LinkRoleToProjectRelationTable1753953244168,
]; ];

View File

@@ -93,6 +93,7 @@ import { AddInputsOutputsToTestCaseExecution1752669793000 } from '../common/1752
import { CreateDataStoreTables1754475614601 } from '../common/1754475614601-CreateDataStoreTables'; import { CreateDataStoreTables1754475614601 } from '../common/1754475614601-CreateDataStoreTables';
import { ReplaceDataStoreTablesWithDataTables1754475614602 } from '../common/1754475614602-ReplaceDataStoreTablesWithDataTables'; import { ReplaceDataStoreTablesWithDataTables1754475614602 } from '../common/1754475614602-ReplaceDataStoreTablesWithDataTables';
import type { Migration } from '../migration-types'; import type { Migration } from '../migration-types';
import { LinkRoleToProjectRelationTable1753953244168 } from './../common/1753953244168-LinkRoleToProjectRelationTable';
const sqliteMigrations: Migration[] = [ const sqliteMigrations: Migration[] = [
InitialMigration1588102412422, InitialMigration1588102412422,
@@ -189,6 +190,7 @@ const sqliteMigrations: Migration[] = [
CreateDataStoreTables1754475614601, CreateDataStoreTables1754475614601,
RemoveOldRoleColumn1750252139170, RemoveOldRoleColumn1750252139170,
ReplaceDataStoreTablesWithDataTables1754475614602, ReplaceDataStoreTablesWithDataTables1754475614602,
LinkRoleToProjectRelationTable1753953244168,
]; ];
export { sqliteMigrations }; export { sqliteMigrations };

View File

@@ -1,5 +1,5 @@
import { Service } from '@n8n/di'; import { Service } from '@n8n/di';
import type { ProjectRole } from '@n8n/permissions'; import { PROJECT_OWNER_ROLE_SLUG, type ProjectRole } from '@n8n/permissions';
import { DataSource, In, Repository } from '@n8n/typeorm'; import { DataSource, In, Repository } from '@n8n/typeorm';
import { ProjectRelation } from '../entities'; import { ProjectRelation } from '../entities';
@@ -14,7 +14,7 @@ export class ProjectRelationRepository extends Repository<ProjectRelation> {
return await this.find({ return await this.find({
where: { where: {
projectId: In(projectIds), projectId: In(projectIds),
role: 'project:personalOwner', role: { slug: PROJECT_OWNER_ROLE_SLUG },
}, },
relations: { relations: {
user: { user: {
@@ -28,7 +28,7 @@ export class ProjectRelationRepository extends Repository<ProjectRelation> {
const projectRelations = await this.find({ const projectRelations = await this.find({
where: { where: {
userId: In(userIds), userId: In(userIds),
role: 'project:personalOwner', role: { slug: PROJECT_OWNER_ROLE_SLUG },
}, },
}); });
@@ -73,6 +73,7 @@ export class ProjectRelationRepository extends Repository<ProjectRelation> {
where: { where: {
userId, userId,
}, },
relations: { role: true },
}); });
} }
} }

View File

@@ -1,4 +1,5 @@
import { Service } from '@n8n/di'; import { Service } from '@n8n/di';
import { PROJECT_OWNER_ROLE_SLUG } from '@n8n/permissions';
import type { EntityManager } from '@n8n/typeorm'; import type { EntityManager } from '@n8n/typeorm';
import { DataSource, Repository } from '@n8n/typeorm'; import { DataSource, Repository } from '@n8n/typeorm';
@@ -14,7 +15,11 @@ export class ProjectRepository extends Repository<Project> {
const em = entityManager ?? this.manager; const em = entityManager ?? this.manager;
return await em.findOne(Project, { return await em.findOne(Project, {
where: { type: 'personal', projectRelations: { userId, role: 'project:personalOwner' } }, where: {
type: 'personal',
projectRelations: { userId, role: { slug: PROJECT_OWNER_ROLE_SLUG } },
},
relations: ['projectRelations.role'],
}); });
} }
@@ -22,7 +27,10 @@ export class ProjectRepository extends Repository<Project> {
const em = entityManager ?? this.manager; const em = entityManager ?? this.manager;
return await em.findOneOrFail(Project, { return await em.findOneOrFail(Project, {
where: { type: 'personal', projectRelations: { userId, role: 'project:personalOwner' } }, where: {
type: 'personal',
projectRelations: { userId, role: { slug: PROJECT_OWNER_ROLE_SLUG } },
},
}); });
} }

View File

@@ -120,7 +120,7 @@ export class SharedCredentialsRepository extends Repository<SharedCredentials> {
project: { project: {
projectRelations: { projectRelations: {
userId: In(userIds), userId: In(userIds),
role: In(projectRoles), role: { slug: In(projectRoles) },
}, },
}, },
}, },

View File

@@ -1,5 +1,5 @@
import { Service } from '@n8n/di'; import { Service } from '@n8n/di';
import type { WorkflowSharingRole } from '@n8n/permissions'; import { PROJECT_OWNER_ROLE_SLUG, type WorkflowSharingRole } from '@n8n/permissions';
import { DataSource, Repository, In, Not } from '@n8n/typeorm'; import { DataSource, Repository, In, Not } from '@n8n/typeorm';
import type { EntityManager, FindManyOptions, FindOptionsWhere } from '@n8n/typeorm'; import type { EntityManager, FindManyOptions, FindOptionsWhere } from '@n8n/typeorm';
@@ -28,7 +28,7 @@ export class SharedWorkflowRepository extends Repository<SharedWorkflow> {
role: 'workflow:owner', role: 'workflow:owner',
workflowId: In(workflowIds), workflowId: In(workflowIds),
}, },
relations: { project: { projectRelations: { user: true } } }, relations: { project: { projectRelations: { user: true, role: true } } },
}); });
} }
@@ -46,7 +46,7 @@ export class SharedWorkflowRepository extends Repository<SharedWorkflow> {
}, },
where: { where: {
workflowId, workflowId,
project: { projectRelations: { role: 'project:personalOwner', userId } }, project: { projectRelations: { role: { slug: PROJECT_OWNER_ROLE_SLUG }, userId } },
}, },
}); });

View File

@@ -1,5 +1,6 @@
import type { UsersListFilterDto } from '@n8n/api-types'; import type { UsersListFilterDto } from '@n8n/api-types';
import { Service } from '@n8n/di'; import { Service } from '@n8n/di';
import { PROJECT_OWNER_ROLE_SLUG } from '@n8n/permissions';
import type { DeepPartial, EntityManager, SelectQueryBuilder } from '@n8n/typeorm'; import type { DeepPartial, EntityManager, SelectQueryBuilder } from '@n8n/typeorm';
import { Brackets, DataSource, In, IsNull, Not, Repository } from '@n8n/typeorm'; import { Brackets, DataSource, In, IsNull, Not, Repository } from '@n8n/typeorm';
@@ -106,8 +107,8 @@ export class UserRepository extends Repository<User> {
await entityManager.save<ProjectRelation>( await entityManager.save<ProjectRelation>(
entityManager.create(ProjectRelation, { entityManager.create(ProjectRelation, {
projectId: savedProject.id, projectId: savedProject.id,
userId: userWithRole.id, userId: savedUser.id,
role: 'project:personalOwner', role: { slug: PROJECT_OWNER_ROLE_SLUG },
}), }),
); );
return { user: userWithRole, project: savedProject }; return { user: userWithRole, project: savedProject };
@@ -129,7 +130,7 @@ export class UserRepository extends Repository<User> {
return await this.findOne({ return await this.findOne({
where: { where: {
projectRelations: { projectRelations: {
role: 'project:personalOwner', role: { slug: PROJECT_OWNER_ROLE_SLUG },
project: { sharedWorkflows: { workflowId, role: 'workflow:owner' } }, project: { sharedWorkflows: { workflowId, role: 'workflow:owner' } },
}, },
}, },
@@ -146,7 +147,7 @@ export class UserRepository extends Repository<User> {
return await this.findOne({ return await this.findOne({
where: { where: {
projectRelations: { projectRelations: {
role: 'project:personalOwner', role: { slug: PROJECT_OWNER_ROLE_SLUG },
projectId, projectId,
}, },
}, },
@@ -232,15 +233,15 @@ export class UserRepository extends Repository<User> {
expand: UsersListFilterDto['expand'], expand: UsersListFilterDto['expand'],
): SelectQueryBuilder<User> { ): SelectQueryBuilder<User> {
if (expand?.includes('projectRelations')) { if (expand?.includes('projectRelations')) {
queryBuilder.leftJoinAndSelect( queryBuilder
'user.projectRelations', .leftJoinAndSelect(
'projectRelations', 'user.projectRelations',
'projectRelations.role <> :projectRole', 'projectRelations',
{ 'projectRelations.role <> :projectRole',
projectRole: 'project:personalOwner', // Exclude personal project relations { projectRole: PROJECT_OWNER_ROLE_SLUG },
}, )
); .leftJoinAndSelect('projectRelations.project', 'project')
queryBuilder.leftJoinAndSelect('projectRelations.project', 'project'); .leftJoinAndSelect('projectRelations.role', 'projectRole');
} }
return queryBuilder; return queryBuilder;

View File

@@ -1,5 +1,6 @@
import { GlobalConfig } from '@n8n/config'; import { GlobalConfig } from '@n8n/config';
import { Service } from '@n8n/di'; import { Service } from '@n8n/di';
import { PROJECT_OWNER_ROLE_SLUG } from '@n8n/permissions';
import { DataSource, MoreThanOrEqual, QueryFailedError, Repository } from '@n8n/typeorm'; import { DataSource, MoreThanOrEqual, QueryFailedError, Repository } from '@n8n/typeorm';
import { WorkflowStatistics } from '../entities'; import { WorkflowStatistics } from '../entities';
@@ -122,7 +123,7 @@ export class WorkflowStatisticsRepository extends Repository<WorkflowStatistics>
workflow: { workflow: {
shared: { shared: {
role: 'workflow:owner', role: 'workflow:owner',
project: { projectRelations: { userId, role: 'project:personalOwner' } }, project: { projectRelations: { userId, role: { slug: PROJECT_OWNER_ROLE_SLUG } } },
}, },
active: true, active: true,
}, },

View File

@@ -52,9 +52,9 @@ export class AuthRolesService {
}).filter((scope) => scope !== null); }).filter((scope) => scope !== null);
if (scopesToUpdate.length > 0) { if (scopesToUpdate.length > 0) {
this.logger.info(`Updating ${scopesToUpdate.length} scopes...`); this.logger.debug(`Updating ${scopesToUpdate.length} scopes...`);
await this.scopeRepository.save(scopesToUpdate); await this.scopeRepository.save(scopesToUpdate);
this.logger.info('Scopes updated successfully.'); this.logger.debug('Scopes updated successfully.');
} else { } else {
this.logger.debug('No scopes to update.'); this.logger.debug('No scopes to update.');
} }
@@ -118,9 +118,9 @@ export class AuthRolesService {
}) })
.filter((role) => role !== null); .filter((role) => role !== null);
if (rolesToUpdate.length > 0) { if (rolesToUpdate.length > 0) {
this.logger.info(`Updating ${rolesToUpdate.length} ${roleNamespace} roles...`); this.logger.debug(`Updating ${rolesToUpdate.length} ${roleNamespace} roles...`);
await this.roleRepository.save(rolesToUpdate); await this.roleRepository.save(rolesToUpdate);
this.logger.info(`${roleNamespace} roles updated successfully.`); this.logger.debug(`${roleNamespace} roles updated successfully.`);
} else { } else {
this.logger.debug(`No ${roleNamespace} roles to update.`); this.logger.debug(`No ${roleNamespace} roles to update.`);
} }
@@ -128,9 +128,9 @@ export class AuthRolesService {
} }
async init() { async init() {
this.logger.info('Initializing AuthRolesService...'); this.logger.debug('Initializing AuthRolesService...');
await this.syncScopes(); await this.syncScopes();
await this.syncRoles(); await this.syncRoles();
this.logger.info('AuthRolesService initialized successfully.'); this.logger.debug('AuthRolesService initialized successfully.');
} }
} }

View File

@@ -1,3 +1,10 @@
import {
PROJECT_ADMIN_ROLE_SLUG,
PROJECT_EDITOR_ROLE_SLUG,
PROJECT_OWNER_ROLE_SLUG,
PROJECT_VIEWER_ROLE_SLUG,
} from '@/constants.ee';
import { import {
roleNamespaceSchema, roleNamespaceSchema,
globalRoleSchema, globalRoleSchema,
@@ -53,10 +60,26 @@ describe('assignableGlobalRoleSchema', () => {
describe('projectRoleSchema', () => { describe('projectRoleSchema', () => {
test.each([ test.each([
{ name: 'valid role: project:personalOwner', value: 'project:personalOwner', expected: true }, {
{ name: 'valid role: project:admin', value: 'project:admin', expected: true }, name: `valid role: ${PROJECT_OWNER_ROLE_SLUG}`,
{ name: 'valid role: project:editor', value: 'project:editor', expected: true }, value: PROJECT_OWNER_ROLE_SLUG,
{ name: 'valid role: project:viewer', value: 'project:viewer', expected: true }, expected: true,
},
{
name: `valid role: ${PROJECT_ADMIN_ROLE_SLUG}`,
value: PROJECT_ADMIN_ROLE_SLUG,
expected: true,
},
{
name: `valid role: ${PROJECT_EDITOR_ROLE_SLUG}`,
value: PROJECT_EDITOR_ROLE_SLUG,
expected: true,
},
{
name: `valid role: ${PROJECT_VIEWER_ROLE_SLUG}`,
value: PROJECT_VIEWER_ROLE_SLUG,
expected: true,
},
{ 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 = projectRoleSchema.safeParse(value);

View File

@@ -43,3 +43,8 @@ export const API_KEY_RESOURCES = {
sourceControl: ['pull'] as const, sourceControl: ['pull'] as const,
workflowTags: ['update', 'list'] as const, workflowTags: ['update', 'list'] as const,
} as const; } as const;
export const PROJECT_OWNER_ROLE_SLUG = 'project:personalOwner';
export const PROJECT_ADMIN_ROLE_SLUG = 'project:admin';
export const PROJECT_EDITOR_ROLE_SLUG = 'project:editor';
export const PROJECT_VIEWER_ROLE_SLUG = 'project:viewer';

View File

@@ -6,7 +6,7 @@ export * from './scope-information';
export * from './roles/role-maps.ee'; export * from './roles/role-maps.ee';
export * from './roles/all-roles'; export * from './roles/all-roles';
export { projectRoleSchema } from './schemas.ee'; export { projectRoleSchema, teamRoleSchema } from './schemas.ee';
export { hasScope } from './utilities/has-scope.ee'; export { hasScope } from './utilities/has-scope.ee';
export { hasGlobalScope } from './utilities/has-global-scope.ee'; export { hasGlobalScope } from './utilities/has-global-scope.ee';

View File

@@ -1,3 +1,9 @@
import {
PROJECT_ADMIN_ROLE_SLUG,
PROJECT_EDITOR_ROLE_SLUG,
PROJECT_OWNER_ROLE_SLUG,
PROJECT_VIEWER_ROLE_SLUG,
} from '../constants.ee';
import { import {
CREDENTIALS_SHARING_SCOPE_MAP, CREDENTIALS_SHARING_SCOPE_MAP,
GLOBAL_SCOPE_MAP, GLOBAL_SCOPE_MAP,
@@ -11,10 +17,10 @@ const ROLE_NAMES: Record<AllRoleTypes, string> = {
'global:owner': 'Owner', 'global:owner': 'Owner',
'global:admin': 'Admin', 'global:admin': 'Admin',
'global:member': 'Member', 'global:member': 'Member',
'project:personalOwner': 'Project Owner', [PROJECT_OWNER_ROLE_SLUG]: 'Project Owner',
'project:admin': 'Project Admin', [PROJECT_ADMIN_ROLE_SLUG]: 'Project Admin',
'project:editor': 'Project Editor', [PROJECT_EDITOR_ROLE_SLUG]: 'Project Editor',
'project:viewer': 'Project Viewer', [PROJECT_VIEWER_ROLE_SLUG]: 'Project Viewer',
'credential:user': 'Credential User', 'credential:user': 'Credential User',
'credential:owner': 'Credential Owner', 'credential:owner': 'Credential Owner',
'workflow:owner': 'Workflow Owner', 'workflow:owner': 'Workflow Owner',

View File

@@ -1,5 +1,7 @@
import { z } from 'zod'; import { z } from 'zod';
import { PROJECT_OWNER_ROLE_SLUG } from './constants.ee';
export const roleNamespaceSchema = z.enum(['global', 'project', 'credential', 'workflow']); export const roleNamespaceSchema = z.enum(['global', 'project', 'credential', 'workflow']);
export const globalRoleSchema = z.enum(['global:owner', 'global:admin', 'global:member']); export const globalRoleSchema = z.enum(['global:owner', 'global:admin', 'global:member']);
@@ -14,7 +16,11 @@ export const personalRoleSchema = z.enum([
export const teamRoleSchema = z.enum(['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 customRoleSchema = z.string().refine((val) => val !== PROJECT_OWNER_ROLE_SLUG, {
message: `'${PROJECT_OWNER_ROLE_SLUG}' is not assignable`,
});
export const projectRoleSchema = z.union([personalRoleSchema, teamRoleSchema]);
export const credentialSharingRoleSchema = z.enum(['credential:owner', 'credential:user']); export const credentialSharingRoleSchema = z.enum(['credential:owner', 'credential:user']);

View File

@@ -58,6 +58,7 @@ 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 projectRoleSchema>;
export type CustomRole = string;
/** 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

@@ -1,7 +1,7 @@
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 type { User, WorkflowEntity } from '@n8n/db'; import type { User, WorkflowEntity } from '@n8n/db';
import { WorkflowRepository, DbConnection } from '@n8n/db'; import { WorkflowRepository, DbConnection, AuthRolesService } from '@n8n/db';
import { Container } from '@n8n/di'; import { Container } from '@n8n/di';
import { type SelectQueryBuilder } from '@n8n/typeorm'; import { type SelectQueryBuilder } from '@n8n/typeorm';
import { mock } from 'jest-mock-extended'; import { mock } from 'jest-mock-extended';
@@ -39,6 +39,7 @@ mockInstance(CommunityPackagesService);
const dbConnection = mockInstance(DbConnection); const dbConnection = mockInstance(DbConnection);
dbConnection.init.mockResolvedValue(undefined); dbConnection.init.mockResolvedValue(undefined);
dbConnection.migrate.mockResolvedValue(undefined); dbConnection.migrate.mockResolvedValue(undefined);
mockInstance(AuthRolesService);
test('should start a task runner when task runners are enabled', async () => { test('should start a task runner when task runners are enabled', async () => {
// arrange // arrange

View File

@@ -1,11 +1,13 @@
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 type { User, WorkflowEntity } from '@n8n/db'; import type { User, WorkflowEntity } from '@n8n/db';
import { WorkflowRepository, DbConnection } from '@n8n/db'; import { WorkflowRepository, DbConnection, AuthRolesService } from '@n8n/db';
import { Container } from '@n8n/di'; import { Container } from '@n8n/di';
import { mock } from 'jest-mock-extended'; import { mock } from 'jest-mock-extended';
import type { IRun } from 'n8n-workflow'; import type { IRun } from 'n8n-workflow';
import { Execute } from '../execute';
import { ActiveExecutions } from '@/active-executions'; import { ActiveExecutions } from '@/active-executions';
import { DeprecationService } from '@/deprecation/deprecation.service'; import { DeprecationService } from '@/deprecation/deprecation.service';
import { MessageEventBus } from '@/eventbus/message-event-bus/message-event-bus'; import { MessageEventBus } from '@/eventbus/message-event-bus/message-event-bus';
@@ -19,8 +21,6 @@ import { ShutdownService } from '@/shutdown/shutdown.service';
import { TaskRunnerModule } from '@/task-runners/task-runner-module'; import { TaskRunnerModule } from '@/task-runners/task-runner-module';
import { WorkflowRunner } from '@/workflow-runner'; import { WorkflowRunner } from '@/workflow-runner';
import { Execute } from '../execute';
const taskRunnerModule = mockInstance(TaskRunnerModule); const taskRunnerModule = mockInstance(TaskRunnerModule);
const workflowRepository = mockInstance(WorkflowRepository); const workflowRepository = mockInstance(WorkflowRepository);
const ownershipService = mockInstance(OwnershipService); const ownershipService = mockInstance(OwnershipService);
@@ -38,6 +38,7 @@ mockInstance(CommunityPackagesService);
const dbConnection = mockInstance(DbConnection); const dbConnection = mockInstance(DbConnection);
dbConnection.init.mockResolvedValue(undefined); dbConnection.init.mockResolvedValue(undefined);
dbConnection.migrate.mockResolvedValue(undefined); dbConnection.migrate.mockResolvedValue(undefined);
mockInstance(AuthRolesService);
test('should start a task runner when task runners are enabled', async () => { test('should start a task runner when task runners are enabled', async () => {
// arrange // arrange

View File

@@ -9,7 +9,7 @@ import {
} from '@n8n/backend-common'; } from '@n8n/backend-common';
import { GlobalConfig } from '@n8n/config'; import { GlobalConfig } from '@n8n/config';
import { LICENSE_FEATURES } from '@n8n/constants'; import { LICENSE_FEATURES } from '@n8n/constants';
import { DbConnection } from '@n8n/db'; import { AuthRolesService, DbConnection } from '@n8n/db';
import { Container } from '@n8n/di'; import { Container } from '@n8n/di';
import { import {
BinaryDataConfig, BinaryDataConfig,
@@ -121,6 +121,9 @@ export abstract class BaseCommand<F = never> {
await this.exitWithCrash('There was an error running database migrations', error), await this.exitWithCrash('There was an error running database migrations', error),
); );
// Initialize the auth roles service to make sure that roles are correctly setup for the instance
await Container.get(AuthRolesService).init();
Container.get(DeprecationService).warn(); Container.get(DeprecationService).warn();
if (process.env.EXECUTIONS_PROCESS === 'own') process.exit(-1); if (process.env.EXECUTIONS_PROCESS === 'own') process.exit(-1);

View File

@@ -8,6 +8,7 @@ import {
} from '@n8n/db'; } from '@n8n/db';
import { Command } from '@n8n/decorators'; import { Command } from '@n8n/decorators';
import { Container } from '@n8n/di'; import { Container } from '@n8n/di';
import { PROJECT_OWNER_ROLE_SLUG } from '@n8n/permissions';
// eslint-disable-next-line n8n-local-rules/misplaced-n8n-typeorm-import // eslint-disable-next-line n8n-local-rules/misplaced-n8n-typeorm-import
import type { EntityManager } from '@n8n/typeorm'; import type { EntityManager } from '@n8n/typeorm';
import glob from 'fast-glob'; import glob from 'fast-glob';
@@ -17,10 +18,10 @@ import type { ICredentialsEncrypted } from 'n8n-workflow';
import { jsonParse, UserError } from 'n8n-workflow'; import { jsonParse, UserError } from 'n8n-workflow';
import { z } from 'zod'; import { z } from 'zod';
import { UM_FIX_INSTRUCTION } from '@/constants';
import { BaseCommand } from '../base-command'; import { BaseCommand } from '../base-command';
import { UM_FIX_INSTRUCTION } from '@/constants';
const flagsSchema = z.object({ const flagsSchema = z.object({
input: z input: z
.string() .string()
@@ -239,7 +240,7 @@ export class ImportCredentialsCommand extends BaseCommand<z.infer<typeof flagsSc
if (sharedCredential && sharedCredential.project.type === 'personal') { if (sharedCredential && sharedCredential.project.type === 'personal') {
const user = await this.transactionManager.findOneByOrFail(User, { const user = await this.transactionManager.findOneByOrFail(User, {
projectRelations: { projectRelations: {
role: 'project:personalOwner', role: { slug: PROJECT_OWNER_ROLE_SLUG },
projectId: sharedCredential.projectId, projectId: sharedCredential.projectId,
}, },
}); });

View File

@@ -9,18 +9,19 @@ import {
} from '@n8n/db'; } from '@n8n/db';
import { Command } from '@n8n/decorators'; import { Command } from '@n8n/decorators';
import { Container } from '@n8n/di'; import { Container } from '@n8n/di';
import { PROJECT_OWNER_ROLE_SLUG } from '@n8n/permissions';
import glob from 'fast-glob'; import glob from 'fast-glob';
import fs from 'fs'; import fs from 'fs';
import type { IWorkflowBase, WorkflowId } from 'n8n-workflow'; import type { IWorkflowBase, WorkflowId } from 'n8n-workflow';
import { jsonParse, UserError } from 'n8n-workflow'; import { jsonParse, UserError } from 'n8n-workflow';
import { z } from 'zod'; import { z } from 'zod';
import { BaseCommand } from '../base-command';
import { UM_FIX_INSTRUCTION } from '@/constants'; import { UM_FIX_INSTRUCTION } from '@/constants';
import type { IWorkflowToImport } from '@/interfaces'; import type { IWorkflowToImport } from '@/interfaces';
import { ImportService } from '@/services/import.service'; import { ImportService } from '@/services/import.service';
import { BaseCommand } from '../base-command';
function assertHasWorkflowsToImport( function assertHasWorkflowsToImport(
workflows: unknown[], workflows: unknown[],
): asserts workflows is IWorkflowToImport[] { ): asserts workflows is IWorkflowToImport[] {
@@ -167,7 +168,7 @@ export class ImportWorkflowsCommand extends BaseCommand<z.infer<typeof flagsSche
if (sharing && sharing.project.type === 'personal') { if (sharing && sharing.project.type === 'personal') {
const user = await Container.get(UserRepository).findOneByOrFail({ const user = await Container.get(UserRepository).findOneByOrFail({
projectRelations: { projectRelations: {
role: 'project:personalOwner', role: { slug: PROJECT_OWNER_ROLE_SLUG },
projectId: sharing.projectId, projectId: sharing.projectId,
}, },
}); });

View File

@@ -1,7 +1,7 @@
/* eslint-disable @typescript-eslint/no-unsafe-call */ /* eslint-disable @typescript-eslint/no-unsafe-call */
/* eslint-disable @typescript-eslint/no-unsafe-member-access */ /* eslint-disable @typescript-eslint/no-unsafe-member-access */
import { LICENSE_FEATURES } from '@n8n/constants'; import { LICENSE_FEATURES } from '@n8n/constants';
import { AuthRolesService, ExecutionRepository, SettingsRepository } from '@n8n/db'; import { ExecutionRepository, SettingsRepository } from '@n8n/db';
import { Command } from '@n8n/decorators'; import { Command } from '@n8n/decorators';
import { Container } from '@n8n/di'; import { Container } from '@n8n/di';
import glob from 'fast-glob'; import glob from 'fast-glob';
@@ -13,8 +13,6 @@ import replaceStream from 'replacestream';
import { pipeline } from 'stream/promises'; import { pipeline } from 'stream/promises';
import { z } from 'zod'; import { z } from 'zod';
import { BaseCommand } from './base-command';
import { ActiveExecutions } from '@/active-executions'; import { ActiveExecutions } from '@/active-executions';
import { ActiveWorkflowManager } from '@/active-workflow-manager'; import { ActiveWorkflowManager } from '@/active-workflow-manager';
import config from '@/config'; import config from '@/config';
@@ -34,6 +32,8 @@ import { UrlService } from '@/services/url.service';
import { WaitTracker } from '@/wait-tracker'; import { WaitTracker } from '@/wait-tracker';
import { WorkflowRunner } from '@/workflow-runner'; import { WorkflowRunner } from '@/workflow-runner';
import { BaseCommand } from './base-command';
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
const open = require('open'); const open = require('open');
@@ -173,8 +173,6 @@ export class Start extends BaseCommand<z.infer<typeof flagsSchema>> {
await super.init(); await super.init();
await Container.get(AuthRolesService).init();
this.activeWorkflowManager = Container.get(ActiveWorkflowManager); this.activeWorkflowManager = Container.get(ActiveWorkflowManager);
const isMultiMainEnabled = const isMultiMainEnabled =

View File

@@ -14,12 +14,7 @@ import {
Param, Param,
Query, Query,
} from '@n8n/decorators'; } from '@n8n/decorators';
import { import { combineScopes, getAuthPrincipalScopes, hasGlobalScope } from '@n8n/permissions';
combineScopes,
getAuthPrincipalScopes,
getRoleScopes,
hasGlobalScope,
} from '@n8n/permissions';
import type { Scope } from '@n8n/permissions'; import type { Scope } from '@n8n/permissions';
// eslint-disable-next-line n8n-local-rules/misplaced-n8n-typeorm-import // eslint-disable-next-line n8n-local-rules/misplaced-n8n-typeorm-import
import { In, Not } from '@n8n/typeorm'; import { In, Not } from '@n8n/typeorm';
@@ -69,13 +64,18 @@ export class ProjectController {
uiContext: payload.uiContext, uiContext: payload.uiContext,
}); });
const relations = await this.projectsService.getProjectRelations(project.id);
return { return {
...project, ...project,
role: 'project:admin', role: 'project:admin',
scopes: [ scopes: [
...combineScopes({ ...combineScopes({
global: getAuthPrincipalScopes(req.user), global: getAuthPrincipalScopes(req.user),
project: getRoleScopes('project:admin'), project:
relations
.find((pr) => pr.userId === req.user.id)
?.role.scopes.map((scope) => scope.slug) || [],
}), }),
], ],
}; };
@@ -105,14 +105,14 @@ export class ProjectController {
for (const pr of relations) { for (const pr of relations) {
const result: ProjectRequest.GetMyProjectsResponse[number] = Object.assign( const result: ProjectRequest.GetMyProjectsResponse[number] = Object.assign(
this.projectRepository.create(pr.project), this.projectRepository.create(pr.project),
{ role: pr.role, scopes: [] }, { role: pr.role.slug, scopes: [] },
); );
if (result.scopes) { if (result.scopes) {
result.scopes.push( result.scopes.push(
...combineScopes({ ...combineScopes({
global: getAuthPrincipalScopes(req.user), global: getAuthPrincipalScopes(req.user),
project: getRoleScopes(pr.role), project: pr.role.scopes.map((scope) => scope.slug),
}), }),
); );
} }
@@ -155,10 +155,15 @@ export class ProjectController {
if (!project) { if (!project) {
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 scopes: Scope[] = [ const scopes: Scope[] = [
...combineScopes({ ...combineScopes({
global: getAuthPrincipalScopes(req.user), global: getAuthPrincipalScopes(req.user),
project: getRoleScopes('project:personalOwner'), project:
relations
.find((pr) => pr.userId === req.user.id)
?.role.scopes.map((scope) => scope.slug) ?? [],
}), }),
]; ];
return { return {
@@ -191,12 +196,12 @@ export class ProjectController {
email: r.user.email, email: r.user.email,
firstName: r.user.firstName, firstName: r.user.firstName,
lastName: r.user.lastName, lastName: r.user.lastName,
role: r.role, role: r.role.slug,
})), })),
scopes: [ scopes: [
...combineScopes({ ...combineScopes({
global: getAuthPrincipalScopes(req.user), global: getAuthPrincipalScopes(req.user),
...(myRelation ? { project: getRoleScopes(myRelation.role) } : {}), ...(myRelation ? { project: myRelation.role.scopes.map((scope) => scope.slug) } : {}),
}), }),
], ],
}; };

View File

@@ -124,7 +124,7 @@ export class UsersController {
...user, ...user,
projectRelations: u.projectRelations?.map((pr) => ({ projectRelations: u.projectRelations?.map((pr) => ({
id: pr.projectId, id: pr.projectId,
role: pr.role, // normalize role for frontend role: pr.role.slug, // normalize role for frontend
name: pr.project.name, name: pr.project.name,
})), })),
}; };

View File

@@ -3,8 +3,14 @@
/* eslint-disable @typescript-eslint/no-unsafe-assignment */ /* eslint-disable @typescript-eslint/no-unsafe-assignment */
/* eslint-disable @typescript-eslint/no-unsafe-return */ /* eslint-disable @typescript-eslint/no-unsafe-return */
import type { CredentialsEntity, ICredentialsDb } from '@n8n/db'; import type { CredentialsEntity, ICredentialsDb } from '@n8n/db';
import { CredentialsRepository, SharedCredentialsRepository } from '@n8n/db'; import {
CredentialsRepository,
GLOBAL_ADMIN_ROLE,
GLOBAL_OWNER_ROLE,
SharedCredentialsRepository,
} from '@n8n/db';
import { Service } from '@n8n/di'; import { Service } from '@n8n/di';
import { PROJECT_ADMIN_ROLE_SLUG, PROJECT_OWNER_ROLE_SLUG } from '@n8n/permissions';
// eslint-disable-next-line n8n-local-rules/misplaced-n8n-typeorm-import // eslint-disable-next-line n8n-local-rules/misplaced-n8n-typeorm-import
import { EntityNotFoundError, In } from '@n8n/typeorm'; import { EntityNotFoundError, In } from '@n8n/typeorm';
import { Credentials, getAdditionalKeys } from 'n8n-core'; import { Credentials, getAdditionalKeys } from 'n8n-core';
@@ -496,9 +502,9 @@ export class CredentialsHelper extends ICredentialsHelper {
role: 'credential:owner', role: 'credential:owner',
project: { project: {
projectRelations: { projectRelations: {
role: In(['project:personalOwner', 'project:admin']), role: { slug: In([PROJECT_OWNER_ROLE_SLUG, PROJECT_ADMIN_ROLE_SLUG]) },
user: { user: {
role: In(['global:owner', 'global:admin']), role: { slug: In([GLOBAL_OWNER_ROLE.slug, GLOBAL_ADMIN_ROLE.slug]) },
}, },
}, },
}, },

View File

@@ -25,12 +25,17 @@ import {
Param, Param,
Query, Query,
} from '@n8n/decorators'; } from '@n8n/decorators';
import { PROJECT_OWNER_ROLE_SLUG } from '@n8n/permissions';
// eslint-disable-next-line n8n-local-rules/misplaced-n8n-typeorm-import // eslint-disable-next-line n8n-local-rules/misplaced-n8n-typeorm-import
import { In } from '@n8n/typeorm'; import { In } from '@n8n/typeorm';
import { deepCopy } from 'n8n-workflow'; import { deepCopy } from 'n8n-workflow';
import type { ICredentialDataDecryptedObject } from 'n8n-workflow'; import type { ICredentialDataDecryptedObject } from 'n8n-workflow';
import { z } from 'zod'; import { z } from 'zod';
import { CredentialsFinderService } from './credentials-finder.service';
import { CredentialsService } from './credentials.service';
import { EnterpriseCredentialsService } from './credentials.service.ee';
import { BadRequestError } from '@/errors/response-errors/bad-request.error'; import { BadRequestError } from '@/errors/response-errors/bad-request.error';
import { ForbiddenError } from '@/errors/response-errors/forbidden.error'; import { ForbiddenError } from '@/errors/response-errors/forbidden.error';
import { NotFoundError } from '@/errors/response-errors/not-found.error'; import { NotFoundError } from '@/errors/response-errors/not-found.error';
@@ -42,10 +47,6 @@ import { NamingService } from '@/services/naming.service';
import { UserManagementMailer } from '@/user-management/email'; import { UserManagementMailer } from '@/user-management/email';
import * as utils from '@/utils'; import * as utils from '@/utils';
import { CredentialsFinderService } from './credentials-finder.service';
import { CredentialsService } from './credentials.service';
import { EnterpriseCredentialsService } from './credentials.service.ee';
@RestController('/credentials') @RestController('/credentials')
export class CredentialsController { export class CredentialsController {
constructor( constructor(
@@ -364,7 +365,7 @@ export class CredentialsController {
const projectsRelations = await this.projectRelationRepository.findBy({ const projectsRelations = await this.projectRelationRepository.findBy({
projectId: In(newShareeIds), projectId: In(newShareeIds),
role: 'project:personalOwner', role: { slug: PROJECT_OWNER_ROLE_SLUG },
}); });
await this.userManagementMailer.notifyCredentialsShared({ await this.userManagementMailer.notifyCredentialsShared({

View File

@@ -10,7 +10,7 @@ import {
UserRepository, UserRepository,
} from '@n8n/db'; } from '@n8n/db';
import { Service } from '@n8n/di'; import { Service } from '@n8n/di';
import { hasGlobalScope, type Scope } from '@n8n/permissions'; import { hasGlobalScope, PROJECT_OWNER_ROLE_SLUG, type Scope } from '@n8n/permissions';
// eslint-disable-next-line n8n-local-rules/misplaced-n8n-typeorm-import // eslint-disable-next-line n8n-local-rules/misplaced-n8n-typeorm-import
import { import {
In, In,
@@ -27,6 +27,8 @@ import type {
} from 'n8n-workflow'; } from 'n8n-workflow';
import { CREDENTIAL_EMPTY_VALUE, deepCopy, NodeHelpers, UnexpectedError } from 'n8n-workflow'; import { CREDENTIAL_EMPTY_VALUE, deepCopy, NodeHelpers, UnexpectedError } from 'n8n-workflow';
import { CredentialsFinderService } from './credentials-finder.service';
import { CREDENTIAL_BLANKING_VALUE } from '@/constants'; import { CREDENTIAL_BLANKING_VALUE } from '@/constants';
import { CredentialTypes } from '@/credential-types'; import { CredentialTypes } from '@/credential-types';
import { createCredentialsFromCredentialsEntity } from '@/credentials-helper'; import { createCredentialsFromCredentialsEntity } from '@/credentials-helper';
@@ -38,12 +40,9 @@ import { userHasScopes } from '@/permissions.ee/check-access';
import type { CredentialRequest, ListQuery } from '@/requests'; import type { CredentialRequest, ListQuery } from '@/requests';
import { CredentialsTester } from '@/services/credentials-tester.service'; import { CredentialsTester } from '@/services/credentials-tester.service';
import { OwnershipService } from '@/services/ownership.service'; import { OwnershipService } from '@/services/ownership.service';
// eslint-disable-next-line import-x/no-cycle
import { ProjectService } from '@/services/project.service.ee'; import { ProjectService } from '@/services/project.service.ee';
import { RoleService } from '@/services/role.service'; import { RoleService } from '@/services/role.service';
import { CredentialsFinderService } from './credentials-finder.service';
export type CredentialsGetSharedOptions = export type CredentialsGetSharedOptions =
| { allowGlobalScope: true; globalScope: Scope } | { allowGlobalScope: true; globalScope: Scope }
| { allowGlobalScope: false }; | { allowGlobalScope: false };
@@ -300,7 +299,7 @@ export class CredentialsService {
role: 'credential:owner', role: 'credential:owner',
project: { project: {
projectRelations: { projectRelations: {
role: 'project:personalOwner', role: { slug: PROJECT_OWNER_ROLE_SLUG },
userId: user.id, userId: user.id,
}, },
}, },

View File

@@ -1,5 +1,5 @@
import type { SourceControlledFile } from '@n8n/api-types'; import type { SourceControlledFile } from '@n8n/api-types';
import { GLOBAL_ADMIN_ROLE, User } from '@n8n/db'; import { GLOBAL_ADMIN_ROLE, PROJECT_OWNER_ROLE, User } from '@n8n/db';
import type { import type {
SharedCredentials, SharedCredentials,
SharedWorkflow, SharedWorkflow,
@@ -83,7 +83,7 @@ describe('SourceControlExportService', () => {
type: 'personal', type: 'personal',
projectRelations: [ projectRelations: [
{ {
role: 'project:personalOwner', role: PROJECT_OWNER_ROLE,
user: mock({ email: 'user@example.com' }), user: mock({ email: 'user@example.com' }),
}, },
], ],
@@ -268,7 +268,7 @@ describe('SourceControlExportService', () => {
mock<SharedWorkflow>({ mock<SharedWorkflow>({
project: mock({ project: mock({
type: 'personal', type: 'personal',
projectRelations: [{ role: 'project:personalOwner', user: mock() }], projectRelations: [{ role: PROJECT_OWNER_ROLE, user: mock() }],
}), }),
workflow: mock(), workflow: mock(),
}), }),

View File

@@ -80,7 +80,7 @@ describe('SourceControlImportService', () => {
}; };
fsReadFile.mockResolvedValue(JSON.stringify(mockWorkflowData)); fsReadFile.mockResolvedValue(JSON.stringify(mockWorkflowData));
sourceControlScopedService.getAdminProjectsFromContext.mockResolvedValueOnce([]); sourceControlScopedService.getAuthorizedProjectsFromContext.mockResolvedValueOnce([]);
const result = await service.getRemoteVersionIdsFromFiles(globalAdminContext); const result = await service.getRemoteVersionIdsFromFiles(globalAdminContext);
expect(fsReadFile).toHaveBeenCalledWith(mockWorkflowFile, { encoding: 'utf8' }); expect(fsReadFile).toHaveBeenCalledWith(mockWorkflowFile, { encoding: 'utf8' });
@@ -312,7 +312,7 @@ describe('SourceControlImportService', () => {
], ],
}; };
sourceControlScopedService.getAdminProjectsFromContext.mockResolvedValue([ sourceControlScopedService.getAuthorizedProjectsFromContext.mockResolvedValue([
Object.assign(new Project(), { Object.assign(new Project(), {
id: 'project1', id: 'project1',
}), }),

View File

@@ -10,6 +10,7 @@ import {
WorkflowRepository, WorkflowRepository,
} from '@n8n/db'; } from '@n8n/db';
import { Service } from '@n8n/di'; import { Service } from '@n8n/di';
import { PROJECT_OWNER_ROLE_SLUG } from '@n8n/permissions';
// eslint-disable-next-line n8n-local-rules/misplaced-n8n-typeorm-import // eslint-disable-next-line n8n-local-rules/misplaced-n8n-typeorm-import
import { In } from '@n8n/typeorm'; import { In } from '@n8n/typeorm';
import { rmSync } from 'fs'; import { rmSync } from 'fs';
@@ -18,8 +19,6 @@ import { UnexpectedError, type ICredentialDataDecryptedObject } from 'n8n-workfl
import { writeFile as fsWriteFile, rm as fsRm } from 'node:fs/promises'; import { writeFile as fsWriteFile, rm as fsRm } from 'node:fs/promises';
import path from 'path'; import path from 'path';
import { formatWorkflow } from '@/workflows/workflow.formatter';
import { import {
SOURCE_CONTROL_CREDENTIAL_EXPORT_FOLDER, SOURCE_CONTROL_CREDENTIAL_EXPORT_FOLDER,
SOURCE_CONTROL_GIT_FOLDER, SOURCE_CONTROL_GIT_FOLDER,
@@ -44,6 +43,8 @@ import type { RemoteResourceOwner } from './types/resource-owner';
import type { SourceControlContext } from './types/source-control-context'; import type { SourceControlContext } from './types/source-control-context';
import { VariablesService } from '../variables/variables.service.ee'; import { VariablesService } from '../variables/variables.service.ee';
import { formatWorkflow } from '@/workflows/workflow.formatter';
@Service() @Service()
export class SourceControlExportService { export class SourceControlExportService {
private gitFolder: string; private gitFolder: string;
@@ -145,7 +146,7 @@ export class SourceControlExportService {
if (project.type === 'personal') { if (project.type === 'personal') {
const ownerRelation = project.projectRelations.find( const ownerRelation = project.projectRelations.find(
(pr) => pr.role === 'project:personalOwner', (pr) => pr.role.slug === PROJECT_OWNER_ROLE_SLUG,
); );
if (!ownerRelation) { if (!ownerRelation) {
throw new UnexpectedError( throw new UnexpectedError(
@@ -251,7 +252,7 @@ export class SourceControlExportService {
} }
const allowedProjects = const allowedProjects =
await this.sourceControlScopedService.getAdminProjectsFromContext(context); await this.sourceControlScopedService.getAuthorizedProjectsFromContext(context);
const fileName = getFoldersPath(this.gitFolder); const fileName = getFoldersPath(this.gitFolder);
@@ -409,7 +410,7 @@ export class SourceControlExportService {
let owner: RemoteResourceOwner | null = null; let owner: RemoteResourceOwner | null = null;
if (sharing.project.type === 'personal') { if (sharing.project.type === 'personal') {
const ownerRelation = sharing.project.projectRelations.find( const ownerRelation = sharing.project.projectRelations.find(
(pr) => pr.role === 'project:personalOwner', (pr) => pr.role.slug === PROJECT_OWNER_ROLE_SLUG,
); );
if (ownerRelation) { if (ownerRelation) {
owner = { owner = {

View File

@@ -15,6 +15,7 @@ import {
UserRepository, UserRepository,
} from '@n8n/db'; } from '@n8n/db';
import { Service } from '@n8n/di'; import { Service } from '@n8n/di';
import { PROJECT_OWNER_ROLE_SLUG } from '@n8n/permissions';
// eslint-disable-next-line n8n-local-rules/misplaced-n8n-typeorm-import // eslint-disable-next-line n8n-local-rules/misplaced-n8n-typeorm-import
import { In } from '@n8n/typeorm'; import { In } from '@n8n/typeorm';
import glob from 'fast-glob'; import glob from 'fast-glob';
@@ -23,14 +24,6 @@ import { jsonParse, ensureError, UserError, UnexpectedError } from 'n8n-workflow
import { readFile as fsReadFile } from 'node:fs/promises'; import { readFile as fsReadFile } from 'node:fs/promises';
import path from 'path'; import path from 'path';
import { ActiveWorkflowManager } from '@/active-workflow-manager';
import { CredentialsService } from '@/credentials/credentials.service';
import type { IWorkflowToImport } from '@/interfaces';
import { isUniqueConstraintError } from '@/response-helper';
import { TagService } from '@/services/tag.service';
import { assertNever } from '@/utils';
import { WorkflowService } from '@/workflows/workflow.service';
import { import {
SOURCE_CONTROL_CREDENTIAL_EXPORT_FOLDER, SOURCE_CONTROL_CREDENTIAL_EXPORT_FOLDER,
SOURCE_CONTROL_FOLDERS_EXPORT_FILE, SOURCE_CONTROL_FOLDERS_EXPORT_FILE,
@@ -52,6 +45,14 @@ import type { SourceControlContext } from './types/source-control-context';
import type { SourceControlWorkflowVersionId } from './types/source-control-workflow-version-id'; import type { SourceControlWorkflowVersionId } from './types/source-control-workflow-version-id';
import { VariablesService } from '../variables/variables.service.ee'; import { VariablesService } from '../variables/variables.service.ee';
import { ActiveWorkflowManager } from '@/active-workflow-manager';
import { CredentialsService } from '@/credentials/credentials.service';
import type { IWorkflowToImport } from '@/interfaces';
import { isUniqueConstraintError } from '@/response-helper';
import { TagService } from '@/services/tag.service';
import { assertNever } from '@/utils';
import { WorkflowService } from '@/workflows/workflow.service';
const findOwnerProject = ( const findOwnerProject = (
owner: RemoteResourceOwner, owner: RemoteResourceOwner,
accessibleProjects: Project[], accessibleProjects: Project[],
@@ -59,7 +60,7 @@ const findOwnerProject = (
if (typeof owner === 'string') { if (typeof owner === 'string') {
return accessibleProjects.find((project) => return accessibleProjects.find((project) =>
project.projectRelations.some( project.projectRelations.some(
(r) => r.role === 'project:personalOwner' && r.user.email === owner, (r) => r.role.slug === PROJECT_OWNER_ROLE_SLUG && r.user.email === owner,
), ),
); );
} }
@@ -68,7 +69,7 @@ const findOwnerProject = (
(project) => (project) =>
project.type === 'personal' && project.type === 'personal' &&
project.projectRelations.some( project.projectRelations.some(
(r) => r.role === 'project:personalOwner' && r.user.email === owner.personalEmail, (r) => r.role.slug === PROJECT_OWNER_ROLE_SLUG && r.user.email === owner.personalEmail,
), ),
); );
} }
@@ -82,7 +83,7 @@ const getOwnerFromProject = (remoteOwnerProject: Project): StatusResourceOwner |
if (remoteOwnerProject?.type === 'personal') { if (remoteOwnerProject?.type === 'personal') {
const personalEmail = remoteOwnerProject.projectRelations?.find( const personalEmail = remoteOwnerProject.projectRelations?.find(
(r) => r.role === 'project:personalOwner', (r) => r.role.slug === PROJECT_OWNER_ROLE_SLUG,
)?.user?.email; )?.user?.email;
if (personalEmail) { if (personalEmail) {
@@ -148,7 +149,7 @@ export class SourceControlImportService {
}); });
const accessibleProjects = const accessibleProjects =
await this.sourceControlScopedService.getAdminProjectsFromContext(context); await this.sourceControlScopedService.getAuthorizedProjectsFromContext(context);
const remoteWorkflowsRead = await Promise.all( const remoteWorkflowsRead = await Promise.all(
remoteWorkflowFiles.map(async (file) => { remoteWorkflowFiles.map(async (file) => {
@@ -251,7 +252,9 @@ export class SourceControlImportService {
name: true, name: true,
type: true, type: true,
projectRelations: { projectRelations: {
role: true, role: {
slug: true,
},
user: { user: {
email: true, email: true,
}, },
@@ -300,7 +303,7 @@ export class SourceControlImportService {
}); });
const accessibleProjects = const accessibleProjects =
await this.sourceControlScopedService.getAdminProjectsFromContext(context); await this.sourceControlScopedService.getAuthorizedProjectsFromContext(context);
const remoteCredentialFilesRead = await Promise.all( const remoteCredentialFilesRead = await Promise.all(
remoteCredentialFiles.map(async (file) => { remoteCredentialFiles.map(async (file) => {
@@ -368,7 +371,9 @@ export class SourceControlImportService {
name: true, name: true,
type: true, type: true,
projectRelations: { projectRelations: {
role: true, role: {
slug: true,
},
user: { user: {
email: true, email: true,
}, },
@@ -426,7 +431,7 @@ export class SourceControlImportService {
}); });
const accessibleProjects = const accessibleProjects =
await this.sourceControlScopedService.getAdminProjectsFromContext(context); await this.sourceControlScopedService.getAuthorizedProjectsFromContext(context);
mappedFolders.folders = mappedFolders.folders.filter( mappedFolders.folders = mappedFolders.folders.filter(
(folder) => (folder) =>

View File

@@ -29,20 +29,21 @@ export class SourceControlScopedService {
} }
const ctx = new SourceControlContext(req.user); const ctx = new SourceControlContext(req.user);
const projectsWithAdminAccess = await this.getAdminProjectsFromContext(ctx); const projectsWithAdminAccess = await this.getAuthorizedProjectsFromContext(ctx);
if (projectsWithAdminAccess?.length === 0) { if (projectsWithAdminAccess?.length === 0) {
throw new ForbiddenError('You are not allowed to push changes'); throw new ForbiddenError('You are not allowed to push changes');
} }
} }
async getAdminProjectsFromContext(context: SourceControlContext): Promise<Project[]> { async getAuthorizedProjectsFromContext(context: SourceControlContext): Promise<Project[]> {
if (context.hasAccessToAllProjects()) { if (context.hasAccessToAllProjects()) {
// In case the user is a global admin or owner, we don't need a filter // In case the user is a global admin or owner, we don't need a filter
return await this.projectRepository.find({ return await this.projectRepository.find({
relations: { relations: {
projectRelations: { projectRelations: {
user: true, user: true,
role: true,
}, },
}, },
}); });
@@ -52,6 +53,7 @@ export class SourceControlScopedService {
relations: { relations: {
projectRelations: { projectRelations: {
user: true, user: true,
role: true,
}, },
}, },
select: { select: {
@@ -59,7 +61,7 @@ export class SourceControlScopedService {
name: true, name: true,
type: true, type: true,
}, },
where: this.getAdminProjectsByContextFilter(context), where: this.getProjectsWithPushScopeByContextFilter(context),
}); });
} }
@@ -85,7 +87,7 @@ export class SourceControlScopedService {
}); });
} }
getAdminProjectsByContextFilter( getProjectsWithPushScopeByContextFilter(
context: SourceControlContext, context: SourceControlContext,
): FindOptionsWhere<Project> | undefined { ): FindOptionsWhere<Project> | undefined {
if (context.hasAccessToAllProjects()) { if (context.hasAccessToAllProjects()) {
@@ -96,7 +98,11 @@ export class SourceControlScopedService {
return { return {
type: 'team', type: 'team',
projectRelations: { projectRelations: {
role: 'project:admin', role: {
scopes: {
slug: 'sourceControl:push',
},
},
userId: context.user.id, userId: context.user.id,
}, },
}; };
@@ -113,7 +119,7 @@ export class SourceControlScopedService {
// We build a filter to only select folder, that belong to a team project // We build a filter to only select folder, that belong to a team project
// that the user is an admin off // that the user is an admin off
return { return {
homeProject: this.getAdminProjectsByContextFilter(context), homeProject: this.getProjectsWithPushScopeByContextFilter(context),
}; };
} }
@@ -130,7 +136,7 @@ export class SourceControlScopedService {
return { return {
shared: { shared: {
role: 'workflow:owner', role: 'workflow:owner',
project: this.getAdminProjectsByContextFilter(context), project: this.getProjectsWithPushScopeByContextFilter(context),
}, },
}; };
} }
@@ -148,7 +154,7 @@ export class SourceControlScopedService {
return { return {
shared: { shared: {
role: 'credential:owner', role: 'credential:owner',
project: this.getAdminProjectsByContextFilter(context), project: this.getProjectsWithPushScopeByContextFilter(context),
}, },
}; };
} }

View File

@@ -6,6 +6,7 @@ import {
WorkflowRepository, WorkflowRepository,
} from '@n8n/db'; } from '@n8n/db';
import { Service } from '@n8n/di'; import { Service } from '@n8n/di';
import { PROJECT_OWNER_ROLE_SLUG } from '@n8n/permissions';
import { snakeCase } from 'change-case'; import { snakeCase } from 'change-case';
import { BinaryDataConfig, InstanceSettings } from 'n8n-core'; import { BinaryDataConfig, InstanceSettings } from 'n8n-core';
import type { ExecutionStatus, INodesGraphResult, ITelemetryTrackProperties } from 'n8n-workflow'; import type { ExecutionStatus, INodesGraphResult, ITelemetryTrackProperties } from 'n8n-workflow';
@@ -13,6 +14,9 @@ import { TelemetryHelpers } from 'n8n-workflow';
import os from 'node:os'; import os from 'node:os';
import { get as pslGet } from 'psl'; import { get as pslGet } from 'psl';
import { EventRelay } from './event-relay';
import { Telemetry } from '../../telemetry';
import config from '@/config'; import config from '@/config';
import { N8N_VERSION } from '@/constants'; import { N8N_VERSION } from '@/constants';
import { EventService } from '@/events/event.service'; import { EventService } from '@/events/event.service';
@@ -22,9 +26,6 @@ import type { IExecutionTrackProperties } from '@/interfaces';
import { License } from '@/license'; import { License } from '@/license';
import { NodeTypes } from '@/node-types'; import { NodeTypes } from '@/node-types';
import { EventRelay } from './event-relay';
import { Telemetry } from '../../telemetry';
@Service() @Service()
export class TelemetryEventRelay extends EventRelay { export class TelemetryEventRelay extends EventRelay {
constructor( constructor(
@@ -604,7 +605,7 @@ export class TelemetryEventRelay extends EventRelay {
projectId: workflowOwner.id, projectId: workflowOwner.id,
}); });
if (projectRole && projectRole !== 'project:personalOwner') { if (projectRole && projectRole?.slug !== PROJECT_OWNER_ROLE_SLUG) {
userRole = 'member'; userRole = 'member';
} }
} }

View File

@@ -1,12 +1,6 @@
import type { User, ExecutionSummaries } from '@n8n/db'; import type { User, ExecutionSummaries } from '@n8n/db';
import { Get, Patch, Post, RestController } from '@n8n/decorators'; import { Get, Patch, Post, RestController } from '@n8n/decorators';
import type { Scope } from '@n8n/permissions'; import { PROJECT_OWNER_ROLE_SLUG, type Scope } from '@n8n/permissions';
import { BadRequestError } from '@/errors/response-errors/bad-request.error';
import { NotFoundError } from '@/errors/response-errors/not-found.error';
import { License } from '@/license';
import { isPositiveInteger } from '@/utils';
import { WorkflowSharingService } from '@/workflows/workflow-sharing.service';
import { ExecutionService } from './execution.service'; import { ExecutionService } from './execution.service';
import { EnterpriseExecutionsService } from './execution.service.ee'; import { EnterpriseExecutionsService } from './execution.service.ee';
@@ -14,6 +8,12 @@ import { ExecutionRequest } from './execution.types';
import { parseRangeQuery } from './parse-range-query.middleware'; import { parseRangeQuery } from './parse-range-query.middleware';
import { validateExecutionUpdatePayload } from './validation'; import { validateExecutionUpdatePayload } from './validation';
import { BadRequestError } from '@/errors/response-errors/bad-request.error';
import { NotFoundError } from '@/errors/response-errors/not-found.error';
import { License } from '@/license';
import { isPositiveInteger } from '@/utils';
import { WorkflowSharingService } from '@/workflows/workflow-sharing.service';
@RestController('/executions') @RestController('/executions')
export class ExecutionsController { export class ExecutionsController {
constructor( constructor(
@@ -29,7 +29,7 @@ export class ExecutionsController {
} else { } else {
return await this.workflowSharingService.getSharedWorkflowIds(user, { return await this.workflowSharingService.getSharedWorkflowIds(user, {
workflowRoles: ['workflow:owner'], workflowRoles: ['workflow:owner'],
projectRoles: ['project:personalOwner'], projectRoles: [PROJECT_OWNER_ROLE_SLUG],
}); });
} }
} }

View File

@@ -1,10 +1,12 @@
import { createTeamProject, testDb, testModules } from '@n8n/backend-test-utils'; import { createTeamProject, testDb, testModules } from '@n8n/backend-test-utils';
import { import {
type Role,
GLOBAL_MEMBER_ROLE, GLOBAL_MEMBER_ROLE,
GLOBAL_OWNER_ROLE, GLOBAL_OWNER_ROLE,
ProjectRelationRepository, ProjectRelationRepository,
type Project, type Project,
type User, type User,
PROJECT_ADMIN_ROLE,
} from '@n8n/db'; } from '@n8n/db';
import { Container } from '@n8n/di'; import { Container } from '@n8n/di';
import type { EntityManager } from '@n8n/typeorm'; import type { EntityManager } from '@n8n/typeorm';
@@ -71,7 +73,7 @@ describe('dataStoreAggregate', () => {
{ {
userId: user.id, userId: user.id,
projectId: project1.id, projectId: project1.id,
role: 'project:admin', role: PROJECT_ADMIN_ROLE,
user, user,
project: project1, project: project1,
createdAt: new Date(), createdAt: new Date(),
@@ -81,7 +83,7 @@ describe('dataStoreAggregate', () => {
{ {
userId: user.id, userId: user.id,
projectId: project2.id, projectId: project2.id,
role: 'project:viewer', role: { slug: 'project:viewer' } as Role,
user, user,
project: project2, project: project2,
createdAt: new Date(), createdAt: new Date(),
@@ -147,7 +149,7 @@ describe('dataStoreAggregate', () => {
{ {
userId: user.id, userId: user.id,
projectId: project1.id, projectId: project1.id,
role: 'project:admin', role: PROJECT_ADMIN_ROLE,
user, user,
project: project1, project: project1,
createdAt: new Date(), createdAt: new Date(),
@@ -157,7 +159,7 @@ describe('dataStoreAggregate', () => {
{ {
userId: user.id, userId: user.id,
projectId: project2.id, projectId: project2.id,
role: 'project:viewer', role: { slug: 'project:viewer' } as Role,
user, user,
project: project2, project: project2,
createdAt: new Date(), createdAt: new Date(),
@@ -196,7 +198,7 @@ describe('dataStoreAggregate', () => {
{ {
userId: user.id, userId: user.id,
projectId: project1.id, projectId: project1.id,
role: 'project:admin', role: PROJECT_ADMIN_ROLE,
user, user,
project: project1, project: project1,
createdAt: new Date(), createdAt: new Date(),

View File

@@ -32,15 +32,20 @@ describe('userHasScopes', () => {
}), }),
); );
const mockQueryBuilder = {
innerJoin: jest.fn().mockReturnThis(),
where: jest.fn().mockReturnThis(),
andWhere: jest.fn().mockReturnThis(),
groupBy: jest.fn().mockReturnThis(),
having: jest.fn().mockReturnThis(),
select: jest.fn().mockReturnThis(),
getRawMany: jest.fn().mockResolvedValue([{ id: 'projectId' }]),
};
Container.set( Container.set(
ProjectRepository, ProjectRepository,
mock<ProjectRepository>({ mock<ProjectRepository>({
find: jest.fn().mockResolvedValue([ createQueryBuilder: jest.fn().mockReturnValue(mockQueryBuilder),
{
id: 'projectId',
projectRelations: [{ userId: 'userId', role: 'project:admin' }],
},
]),
}), }),
); );
}); });

View File

@@ -2,8 +2,6 @@ import type { User } from '@n8n/db';
import { ProjectRepository, SharedCredentialsRepository, SharedWorkflowRepository } from '@n8n/db'; import { ProjectRepository, SharedCredentialsRepository, SharedWorkflowRepository } from '@n8n/db';
import { Container } from '@n8n/di'; import { Container } from '@n8n/di';
import { hasGlobalScope, rolesWithScope, type Scope } from '@n8n/permissions'; import { hasGlobalScope, rolesWithScope, type Scope } from '@n8n/permissions';
// eslint-disable-next-line n8n-local-rules/misplaced-n8n-typeorm-import
import { In } from '@n8n/typeorm';
import { UnexpectedError } from 'n8n-workflow'; import { UnexpectedError } from 'n8n-workflow';
import { NotFoundError } from '@/errors/response-errors/not-found.error'; import { NotFoundError } from '@/errors/response-errors/not-found.error';
@@ -30,21 +28,21 @@ export async function userHasScopes(
if (globalOnly) return false; if (globalOnly) return false;
// Find which project roles are defined to contain the required scopes. // Find which projects the user has access to with the required scopes.
// Then find projects having this user and having those project roles. // This is done by finding the projects where the user has a role with at least the required scopes
const projectRoles = rolesWithScope('project', scopes);
const userProjectIds = ( const userProjectIds = (
await Container.get(ProjectRepository).find({ await Container.get(ProjectRepository)
where: { .createQueryBuilder('project')
projectRelations: { .innerJoin('project.projectRelations', 'relation')
userId: user.id, .innerJoin('relation.role', 'role')
role: In(projectRoles), .innerJoin('role.scopes', 'scope')
}, .where('relation.userId = :userId', { userId: user.id })
}, .andWhere('scope.slug IN (:...scopes)', { scopes })
select: ['id'], .groupBy('project.id')
}) .having('COUNT(DISTINCT scope.slug) = :scopeCount', { scopeCount: scopes.length })
).map((p) => p.id); .select(['project.id AS id'])
.getRawMany()
).map((row: { id: string }) => row.id);
// Find which resource roles are defined to contain the required scopes. // Find which resource roles are defined to contain the required scopes.
// Then find at least one of the above qualifying projects having one of // Then find at least one of the above qualifying projects having one of

View File

@@ -9,7 +9,7 @@ import {
WorkflowRepository, WorkflowRepository,
} from '@n8n/db'; } from '@n8n/db';
import { Container } from '@n8n/di'; import { Container } from '@n8n/di';
import type { Scope, WorkflowSharingRole } from '@n8n/permissions'; import { PROJECT_OWNER_ROLE_SLUG, type Scope, type WorkflowSharingRole } from '@n8n/permissions';
import type { WorkflowId } from 'n8n-workflow'; import type { WorkflowId } from 'n8n-workflow';
import { License } from '@/license'; import { License } from '@/license';
@@ -32,7 +32,7 @@ export async function getSharedWorkflowIds(
} else { } else {
return await Container.get(WorkflowSharingService).getSharedWorkflowIds(user, { return await Container.get(WorkflowSharingService).getSharedWorkflowIds(user, {
workflowRoles: ['workflow:owner'], workflowRoles: ['workflow:owner'],
projectRoles: ['project:personalOwner'], projectRoles: [PROJECT_OWNER_ROLE_SLUG],
projectId, projectId,
}); });
} }

View File

@@ -8,7 +8,13 @@ import type {
ListQueryDb, ListQueryDb,
WorkflowHistory, WorkflowHistory,
} from '@n8n/db'; } from '@n8n/db';
import type { AssignableGlobalRole, ProjectRole, Scope } from '@n8n/permissions'; import type {
AssignableGlobalRole,
CustomRole,
GlobalRole,
ProjectRole,
Scope,
} from '@n8n/permissions';
import type { import type {
ICredentialDataDecryptedObject, ICredentialDataDecryptedObject,
INodeCredentialTestRequest, INodeCredentialTestRequest,
@@ -268,14 +274,16 @@ export declare namespace ActiveWorkflowRequest {
// ---------------------------------- // ----------------------------------
export declare namespace ProjectRequest { export declare namespace ProjectRequest {
type GetMyProjectsResponse = Array<Project & { role: string; scopes?: Scope[] }>; type GetMyProjectsResponse = Array<
Project & { role: ProjectRole | GlobalRole | CustomRole; scopes?: Scope[] }
>;
type ProjectRelationResponse = { type ProjectRelationResponse = {
id: string; id: string;
email: string; email: string;
firstName: string; firstName: string;
lastName: string; lastName: string;
role: ProjectRole; role: ProjectRole | CustomRole;
}; };
type ProjectWithRelations = { type ProjectWithRelations = {
id: string; id: string;

View File

@@ -1,11 +1,17 @@
import { GLOBAL_MEMBER_ROLE, GLOBAL_OWNER_ROLE, SharedCredentials } from '@n8n/db'; import { GLOBAL_MEMBER_ROLE, GLOBAL_OWNER_ROLE, SharedCredentials } from '@n8n/db';
import type { CredentialsEntity, User } from '@n8n/db'; import type { CredentialsEntity, User } from '@n8n/db';
import { Container } from '@n8n/di'; import { Container } from '@n8n/di';
import {
PROJECT_ADMIN_ROLE_SLUG,
PROJECT_EDITOR_ROLE_SLUG,
PROJECT_OWNER_ROLE_SLUG,
PROJECT_VIEWER_ROLE_SLUG,
} from '@n8n/permissions';
import { In } from '@n8n/typeorm'; import { In } from '@n8n/typeorm';
import { mockEntityManager } from '@test/mocking';
import { mock } from 'jest-mock-extended'; import { mock } from 'jest-mock-extended';
import { CredentialsFinderService } from '@/credentials/credentials-finder.service'; import { CredentialsFinderService } from '@/credentials/credentials-finder.service';
import { mockEntityManager } from '@test/mocking';
describe('CredentialsFinderService', () => { describe('CredentialsFinderService', () => {
const entityManager = mockEntityManager(SharedCredentials); const entityManager = mockEntityManager(SharedCredentials);
@@ -56,10 +62,10 @@ describe('CredentialsFinderService', () => {
project: { project: {
projectRelations: { projectRelations: {
role: In([ role: In([
'project:admin', PROJECT_ADMIN_ROLE_SLUG,
'project:personalOwner', PROJECT_OWNER_ROLE_SLUG,
'project:editor', PROJECT_EDITOR_ROLE_SLUG,
'project:viewer', PROJECT_VIEWER_ROLE_SLUG,
]), ]),
userId: member.id, userId: member.id,
}, },
@@ -84,10 +90,10 @@ describe('CredentialsFinderService', () => {
project: { project: {
projectRelations: { projectRelations: {
role: In([ role: In([
'project:admin', PROJECT_ADMIN_ROLE_SLUG,
'project:personalOwner', PROJECT_OWNER_ROLE_SLUG,
'project:editor', PROJECT_EDITOR_ROLE_SLUG,
'project:viewer', PROJECT_VIEWER_ROLE_SLUG,
]), ]),
userId: member.id, userId: member.id,
}, },

View File

@@ -10,7 +10,9 @@ import {
SharedWorkflowRepository, SharedWorkflowRepository,
UserRepository, UserRepository,
GLOBAL_OWNER_ROLE, GLOBAL_OWNER_ROLE,
PROJECT_OWNER_ROLE,
} from '@n8n/db'; } from '@n8n/db';
import { PROJECT_OWNER_ROLE_SLUG } from '@n8n/permissions';
import { v4 as uuid } from 'uuid'; import { v4 as uuid } from 'uuid';
import { OwnershipService } from '@/services/ownership.service'; import { OwnershipService } from '@/services/ownership.service';
@@ -64,7 +66,7 @@ describe('OwnershipService', () => {
const owner = new User(); const owner = new User();
owner.role = GLOBAL_OWNER_ROLE; owner.role = GLOBAL_OWNER_ROLE;
const projectRelation = new ProjectRelation(); const projectRelation = new ProjectRelation();
projectRelation.role = 'project:personalOwner'; projectRelation.role = PROJECT_OWNER_ROLE;
(projectRelation.project = project), (projectRelation.user = owner); (projectRelation.project = project), (projectRelation.user = owner);
projectRelationRepository.getPersonalProjectOwners.mockResolvedValueOnce([projectRelation]); projectRelationRepository.getPersonalProjectOwners.mockResolvedValueOnce([projectRelation]);
@@ -92,7 +94,7 @@ describe('OwnershipService', () => {
owner.id = uuid(); owner.id = uuid();
owner.role = GLOBAL_OWNER_ROLE; owner.role = GLOBAL_OWNER_ROLE;
const projectRelation = new ProjectRelation(); const projectRelation = new ProjectRelation();
projectRelation.role = 'project:personalOwner'; projectRelation.role = { slug: PROJECT_OWNER_ROLE_SLUG } as any;
(projectRelation.project = project), (projectRelation.user = owner); (projectRelation.project = project), (projectRelation.user = owner);
cacheService.getHashValue.mockResolvedValueOnce(owner); cacheService.getHashValue.mockResolvedValueOnce(owner);
@@ -116,7 +118,7 @@ describe('OwnershipService', () => {
mockOwner.role = GLOBAL_OWNER_ROLE; mockOwner.role = GLOBAL_OWNER_ROLE;
const projectRelation = Object.assign(new ProjectRelation(), { const projectRelation = Object.assign(new ProjectRelation(), {
role: 'project:personalOwner', role: PROJECT_OWNER_ROLE_SLUG,
project: mockProject, project: mockProject,
user: mockOwner, user: mockOwner,
}); });

View File

@@ -1,12 +1,14 @@
import type { ProjectRelation } from '@n8n/api-types'; import type { ProjectRelation } from '@n8n/api-types';
import type { DatabaseConfig } from '@n8n/config'; import type { DatabaseConfig } from '@n8n/config';
import type { import {
Project, type Project,
ProjectRepository, type ProjectRepository,
SharedCredentialsRepository, type SharedCredentialsRepository,
ProjectRelationRepository, type ProjectRelationRepository,
SharedCredentials, type SharedCredentials,
PROJECT_ADMIN_ROLE,
} from '@n8n/db'; } from '@n8n/db';
import { PROJECT_OWNER_ROLE_SLUG } from '@n8n/permissions';
import type { EntityManager } from '@n8n/typeorm'; import type { EntityManager } from '@n8n/typeorm';
import { mock } from 'jest-mock-extended'; import { mock } from 'jest-mock-extended';
@@ -58,7 +60,7 @@ describe('ProjectService', () => {
// ACT & ASSERT // ACT & ASSERT
await expect( await expect(
projectService.addUsersToProject(projectId, [ projectService.addUsersToProject(projectId, [
{ userId: '1234', role: 'project:personalOwner' }, { userId: '1234', role: PROJECT_OWNER_ROLE_SLUG },
]), ]),
).rejects.toThrowError("Can't add a personalOwner to a team project."); ).rejects.toThrowError("Can't add a personalOwner to a team project.");
}); });
@@ -100,7 +102,7 @@ describe('ProjectService', () => {
expect(projectRepository.findOne).toHaveBeenCalledWith({ expect(projectRepository.findOne).toHaveBeenCalledWith({
where: { id: projectId, type: 'team' }, where: { id: projectId, type: 'team' },
relations: { projectRelations: true }, relations: { projectRelations: { role: true } },
}); });
expect(manager.delete).toHaveBeenCalled(); expect(manager.delete).toHaveBeenCalled();
@@ -139,7 +141,7 @@ describe('ProjectService', () => {
mock<Project>({ mock<Project>({
id: projectId, id: projectId,
type: 'team', type: 'team',
projectRelations: [{ userId: 'user1', role: 'project:admin' }], projectRelations: [{ userId: 'user1', role: PROJECT_ADMIN_ROLE }],
}), }),
); );
roleService.isRoleLicensed.mockReturnValue(false); roleService.isRoleLicensed.mockReturnValue(false);
@@ -156,9 +158,9 @@ describe('ProjectService', () => {
describe('changeUserRoleInProject', () => { describe('changeUserRoleInProject', () => {
const projectId = '12345'; const projectId = '12345';
const mockRelations: ProjectRelation[] = [ const mockRelations = [
{ userId: 'user1', role: 'project:admin' }, { userId: 'user1', role: { slug: 'project:admin' } },
{ userId: 'user2', role: 'project:viewer' }, { userId: 'user2', role: { slug: 'project:viewer' } },
]; ];
beforeEach(() => { beforeEach(() => {
@@ -185,12 +187,12 @@ describe('ProjectService', () => {
expect(projectRepository.findOne).toHaveBeenCalledWith({ expect(projectRepository.findOne).toHaveBeenCalledWith({
where: { id: projectId, type: 'team' }, where: { id: projectId, type: 'team' },
relations: { projectRelations: true }, relations: { projectRelations: { role: true } },
}); });
expect(projectRelationRepository.update).toHaveBeenCalledWith( expect(projectRelationRepository.update).toHaveBeenCalledWith(
{ projectId, userId: 'user2' }, { projectId, userId: 'user2' },
{ role: 'project:admin' }, { role: { slug: 'project:admin' } },
); );
}); });
@@ -210,13 +212,13 @@ describe('ProjectService', () => {
expect(projectRepository.findOne).toHaveBeenCalledWith({ expect(projectRepository.findOne).toHaveBeenCalledWith({
where: { id: projectId, type: 'team' }, where: { id: projectId, type: 'team' },
relations: { projectRelations: true }, relations: { projectRelations: { role: true } },
}); });
}); });
it('should throw if the role to be set is `project:personalOwner`', async () => { it('should throw if the role to be set is `project:personalOwner`', async () => {
await expect( await expect(
projectService.changeUserRoleInProject(projectId, 'user2', 'project:personalOwner'), projectService.changeUserRoleInProject(projectId, 'user2', PROJECT_OWNER_ROLE_SLUG),
).rejects.toThrow('Personal owner cannot be added to a team project.'); ).rejects.toThrow('Personal owner cannot be added to a team project.');
}); });
@@ -230,7 +232,7 @@ describe('ProjectService', () => {
expect(projectRepository.findOne).toHaveBeenCalledWith({ expect(projectRepository.findOne).toHaveBeenCalledWith({
where: { id: projectId, type: 'team' }, where: { id: projectId, type: 'team' },
relations: { projectRelations: true }, relations: { projectRelations: { role: true } },
}); });
}); });
}); });

View File

@@ -12,7 +12,15 @@ import {
SharedWorkflowRepository, SharedWorkflowRepository,
} from '@n8n/db'; } from '@n8n/db';
import { Container, Service } from '@n8n/di'; import { Container, Service } from '@n8n/di';
import { hasGlobalScope, rolesWithScope, type Scope, type ProjectRole } from '@n8n/permissions'; import {
hasGlobalScope,
rolesWithScope,
type Scope,
type ProjectRole,
CustomRole,
PROJECT_OWNER_ROLE_SLUG,
PROJECT_ADMIN_ROLE_SLUG,
} from '@n8n/permissions';
// eslint-disable-next-line n8n-local-rules/misplaced-n8n-typeorm-import // eslint-disable-next-line n8n-local-rules/misplaced-n8n-typeorm-import
import type { FindOptionsWhere, EntityManager } from '@n8n/typeorm'; import type { FindOptionsWhere, EntityManager } from '@n8n/typeorm';
// eslint-disable-next-line n8n-local-rules/misplaced-n8n-typeorm-import // eslint-disable-next-line n8n-local-rules/misplaced-n8n-typeorm-import
@@ -26,8 +34,6 @@ import { NotFoundError } from '@/errors/response-errors/not-found.error';
import { CacheService } from './cache/cache.service'; import { CacheService } from './cache/cache.service';
import { RoleService } from './role.service'; import { RoleService } from './role.service';
type Relation = Pick<ProjectRelation, 'userId' | 'role'>;
export class TeamProjectOverQuotaError extends UserError { export class TeamProjectOverQuotaError extends UserError {
constructor(limit: number) { constructor(limit: number) {
super( super(
@@ -37,7 +43,7 @@ export class TeamProjectOverQuotaError extends UserError {
} }
export class UnlicensedProjectRoleError extends UserError { export class UnlicensedProjectRoleError extends UserError {
constructor(role: ProjectRole) { constructor(role: ProjectRole | CustomRole) {
super(`Your instance is not licensed to use role "${role}".`); super(`Your instance is not licensed to use role "${role}".`);
} }
} }
@@ -71,14 +77,12 @@ export class ProjectService {
) {} ) {}
private get workflowService() { private get workflowService() {
// eslint-disable-next-line import-x/no-cycle
return import('@/workflows/workflow.service').then(({ WorkflowService }) => return import('@/workflows/workflow.service').then(({ WorkflowService }) =>
Container.get(WorkflowService), Container.get(WorkflowService),
); );
} }
private get credentialsService() { private get credentialsService() {
// eslint-disable-next-line import-x/no-cycle
return import('@/credentials/credentials.service').then(({ CredentialsService }) => return import('@/credentials/credentials.service').then(({ CredentialsService }) =>
Container.get(CredentialsService), Container.get(CredentialsService),
); );
@@ -262,7 +266,7 @@ export class ProjectService {
async getProjectRelationsForUser(user: User): Promise<ProjectRelation[]> { async getProjectRelationsForUser(user: User): Promise<ProjectRelation[]> {
return await this.projectRelationRepository.find({ return await this.projectRelationRepository.find({
where: { userId: user.id }, where: { userId: user.id },
relations: ['project'], relations: ['project', 'role'],
}); });
} }
@@ -292,7 +296,10 @@ export class ProjectService {
* Throws if you the project is a personal project. * Throws if you the project is a personal project.
* Throws if the relations contain `project:personalOwner`. * Throws if the relations contain `project:personalOwner`.
*/ */
async addUsersToProject(projectId: string, relations: Relation[]) { async addUsersToProject(
projectId: string,
relations: Array<{ userId: string; role: ProjectRole | CustomRole }>,
) {
const project = await this.getTeamProjectWithRelations(projectId); const project = await this.getTeamProjectWithRelations(projectId);
this.checkRolesLicensed(project, relations); this.checkRolesLicensed(project, relations);
@@ -300,31 +307,38 @@ export class ProjectService {
throw new ForbiddenError("Can't add users to personal projects."); throw new ForbiddenError("Can't add users to personal projects.");
} }
if (relations.some((r) => r.role === 'project:personalOwner')) { if (relations.some((r) => r.role === PROJECT_OWNER_ROLE_SLUG)) {
throw new ForbiddenError("Can't add a personalOwner to a team project."); throw new ForbiddenError("Can't add a personalOwner to a team project.");
} }
await this.projectRelationRepository.save( await this.projectRelationRepository.save(
relations.map((relation) => ({ projectId, ...relation })), relations.map((relation) => ({
projectId,
userId: relation.userId,
role: { slug: relation.role },
})),
); );
} }
private async getTeamProjectWithRelations(projectId: string) { private async getTeamProjectWithRelations(projectId: string) {
const project = await this.projectRepository.findOne({ const project = await this.projectRepository.findOne({
where: { id: projectId, type: 'team' }, where: { id: projectId, type: 'team' },
relations: { projectRelations: true }, relations: { projectRelations: { role: true } },
}); });
ProjectNotFoundError.isDefinedAndNotNull(project, projectId); ProjectNotFoundError.isDefinedAndNotNull(project, projectId);
return project; return project;
} }
/** 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(project: Project, relations: Relation[]) { private checkRolesLicensed(
project: Project,
relations: Array<{ role: ProjectRole | CustomRole; 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);
// We don't throw an error if the user already exists with that role so // We don't throw an error if the user already exists with that role so
// existing projects continue working as is. // existing projects continue working as is.
if (existing?.role !== role && !this.roleService.isRoleLicensed(role)) { if (existing?.role?.slug !== role && !this.roleService.isRoleLicensed(role)) {
throw new UnlicensedProjectRoleError(role); throw new UnlicensedProjectRoleError(role);
} }
} }
@@ -332,7 +346,7 @@ export class ProjectService {
private isUserProjectOwner(project: Project, userId: string) { private isUserProjectOwner(project: Project, userId: string) {
return project.projectRelations.some( return project.projectRelations.some(
(pr) => pr.userId === userId && pr.role === 'project:personalOwner', (pr) => pr.userId === userId && pr.role.slug === PROJECT_OWNER_ROLE_SLUG,
); );
} }
@@ -348,7 +362,7 @@ export class ProjectService {
} }
async changeUserRoleInProject(projectId: string, userId: string, role: ProjectRole) { async changeUserRoleInProject(projectId: string, userId: string, role: ProjectRole) {
if (role === 'project:personalOwner') { 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.');
} }
@@ -360,7 +374,7 @@ export class ProjectService {
throw new ProjectNotFoundError(projectId); throw new ProjectNotFoundError(projectId);
} }
await this.projectRelationRepository.update({ projectId, userId }, { role }); await this.projectRelationRepository.update({ projectId, userId }, { role: { slug: role } });
} }
async clearCredentialCanUseExternalSecretsCache(projectId: string) { async clearCredentialCanUseExternalSecretsCache(projectId: string) {
@@ -385,7 +399,7 @@ export class ProjectService {
async addManyRelations( async addManyRelations(
em: EntityManager, em: EntityManager,
project: Project, project: Project,
relations: Array<{ userId: string; role: ProjectRole }>, relations: Array<{ userId: string; role: ProjectRole | CustomRole }>,
) { ) {
await em.insert( await em.insert(
ProjectRelation, ProjectRelation,
@@ -394,7 +408,7 @@ export class ProjectService {
this.projectRelationRepository.create({ this.projectRelationRepository.create({
projectId: project.id, projectId: project.id,
userId: v.userId, userId: v.userId,
role: v.role, role: { slug: v.role },
}), }),
), ),
); );
@@ -434,12 +448,16 @@ export class ProjectService {
* Throws if you the project is a personal project. * Throws if you the project is a personal project.
* Throws if the relations contain `project:personalOwner`. * Throws if the relations contain `project:personalOwner`.
*/ */
async addUser(projectId: string, { userId, role }: Relation, trx?: EntityManager) { async addUser(
projectId: string,
{ userId, role }: { userId: string; role: ProjectRole | CustomRole },
trx?: EntityManager,
) {
trx = trx ?? this.projectRelationRepository.manager; trx = trx ?? this.projectRelationRepository.manager;
return await trx.save(ProjectRelation, { return await trx.save(ProjectRelation, {
projectId, projectId,
userId, userId,
role, role: { slug: role },
}); });
} }
@@ -454,7 +472,7 @@ export class ProjectService {
async getProjectRelations(projectId: string): Promise<ProjectRelation[]> { async getProjectRelations(projectId: string): Promise<ProjectRelation[]> {
return await this.projectRelationRepository.find({ return await this.projectRelationRepository.find({
where: { projectId }, where: { projectId },
relations: { user: true }, relations: { user: true, role: true },
}); });
} }
@@ -463,7 +481,7 @@ export class ProjectService {
where: { where: {
projectRelations: { projectRelations: {
userId, userId,
role: In(['project:personalOwner', 'project:admin']), role: In([PROJECT_OWNER_ROLE_SLUG, PROJECT_ADMIN_ROLE_SLUG]),
}, },
}, },
}); });

View File

@@ -8,7 +8,7 @@ import type {
ProjectRelation, ProjectRelation,
} from '@n8n/db'; } from '@n8n/db';
import { Service } from '@n8n/di'; import { Service } from '@n8n/di';
import type { AllRoleTypes, Scope } from '@n8n/permissions'; import type { CustomRole, ProjectRole, Scope } from '@n8n/permissions';
import { ALL_ROLES, combineScopes, getAuthPrincipalScopes, getRoleScopes } from '@n8n/permissions'; import { ALL_ROLES, combineScopes, getAuthPrincipalScopes, getRoleScopes } from '@n8n/permissions';
import { UnexpectedError } from 'n8n-workflow'; import { UnexpectedError } from 'n8n-workflow';
@@ -97,7 +97,7 @@ export class RoleService {
); );
let projectScopes: Scope[] = []; let projectScopes: Scope[] = [];
if (pr) { if (pr) {
projectScopes = getRoleScopes(pr.role); projectScopes = pr.role.scopes.map((s) => s.slug);
} }
const resourceMask = getRoleScopes(sharedEntity.role); const resourceMask = getRoleScopes(sharedEntity.role);
const mergedScopes = combineScopes( const mergedScopes = combineScopes(
@@ -112,7 +112,7 @@ export class RoleService {
return [...scopesSet].sort(); return [...scopesSet].sort();
} }
isRoleLicensed(role: AllRoleTypes) { isRoleLicensed(role: ProjectRole | CustomRole) {
// TODO: move this info into FrontendSettings // TODO: move this info into FrontendSettings
switch (role) { switch (role) {
case 'project:admin': case 'project:admin':
@@ -124,6 +124,7 @@ export class RoleService {
case 'global:admin': case 'global:admin':
return this.license.isAdvancedPermissionsLicensed(); return this.license.isAdvancedPermissionsLicensed();
default: default:
// TODO: handle custom roles licensing
return true; return true;
} }
} }

View File

@@ -7,6 +7,7 @@ import {
type ProjectRole, type ProjectRole,
type WorkflowSharingRole, type WorkflowSharingRole,
type Scope, type Scope,
PROJECT_OWNER_ROLE_SLUG,
} from '@n8n/permissions'; } from '@n8n/permissions';
// eslint-disable-next-line n8n-local-rules/misplaced-n8n-typeorm-import // eslint-disable-next-line n8n-local-rules/misplaced-n8n-typeorm-import
import { In } from '@n8n/typeorm'; import { In } from '@n8n/typeorm';
@@ -74,7 +75,7 @@ export class WorkflowSharingService {
project: { project: {
projectRelations: { projectRelations: {
userId: user.id, userId: user.id,
role: 'project:personalOwner', role: { slug: PROJECT_OWNER_ROLE_SLUG },
}, },
}, },
}, },
@@ -115,7 +116,7 @@ export class WorkflowSharingService {
project: { project: {
projectRelations: { projectRelations: {
userId: user.id, userId: user.id,
role: 'project:personalOwner', role: { slug: PROJECT_OWNER_ROLE_SLUG },
}, },
}, },
}, },

View File

@@ -23,6 +23,8 @@ import omit from 'lodash/omit';
import type { IWorkflowBase, WorkflowId } from 'n8n-workflow'; import type { IWorkflowBase, WorkflowId } from 'n8n-workflow';
import { NodeOperationError, PROJECT_ROOT, UserError, WorkflowActivationError } from 'n8n-workflow'; import { NodeOperationError, PROJECT_ROOT, UserError, WorkflowActivationError } from 'n8n-workflow';
import { WorkflowFinderService } from './workflow-finder.service';
import { ActiveWorkflowManager } from '@/active-workflow-manager'; import { ActiveWorkflowManager } from '@/active-workflow-manager';
import { CredentialsFinderService } from '@/credentials/credentials-finder.service'; import { CredentialsFinderService } from '@/credentials/credentials-finder.service';
import { CredentialsService } from '@/credentials/credentials.service'; import { CredentialsService } from '@/credentials/credentials.service';
@@ -34,8 +36,6 @@ import { FolderService } from '@/services/folder.service';
import { OwnershipService } from '@/services/ownership.service'; import { OwnershipService } from '@/services/ownership.service';
import { ProjectService } from '@/services/project.service.ee'; import { ProjectService } from '@/services/project.service.ee';
import { WorkflowFinderService } from './workflow-finder.service';
@Service() @Service()
export class EnterpriseWorkflowService { export class EnterpriseWorkflowService {
constructor( constructor(
@@ -74,7 +74,7 @@ export class EnterpriseWorkflowService {
); );
const newSharedWorkflows = projects const newSharedWorkflows = projects
// We filter by role === 'project:personalOwner' above and there should // We filter by role === PROJECT_OWNER_ROLE_SLUG above and there should
// always only be one owner. // always only be one owner.
.map((project) => .map((project) =>
this.sharedWorkflowRepository.create({ this.sharedWorkflowRepository.create({

View File

@@ -30,6 +30,7 @@ import {
Query, Query,
RestController, RestController,
} from '@n8n/decorators'; } from '@n8n/decorators';
import { PROJECT_OWNER_ROLE_SLUG } from '@n8n/permissions';
// eslint-disable-next-line n8n-local-rules/misplaced-n8n-typeorm-import // eslint-disable-next-line n8n-local-rules/misplaced-n8n-typeorm-import
import { In, type FindOptionsRelations } from '@n8n/typeorm'; import { In, type FindOptionsRelations } from '@n8n/typeorm';
import axios from 'axios'; import axios from 'axios';
@@ -37,6 +38,14 @@ import express from 'express';
import { UnexpectedError } from 'n8n-workflow'; import { UnexpectedError } from 'n8n-workflow';
import { v4 as uuid } from 'uuid'; import { v4 as uuid } from 'uuid';
import { WorkflowExecutionService } from './workflow-execution.service';
import { WorkflowFinderService } from './workflow-finder.service';
import { WorkflowHistoryService } from './workflow-history.ee/workflow-history.service.ee';
import { WorkflowRequest } from './workflow.request';
import { WorkflowService } from './workflow.service';
import { EnterpriseWorkflowService } from './workflow.service.ee';
import { CredentialsService } from '../credentials/credentials.service';
import { BadRequestError } from '@/errors/response-errors/bad-request.error'; import { BadRequestError } from '@/errors/response-errors/bad-request.error';
import { ForbiddenError } from '@/errors/response-errors/forbidden.error'; import { ForbiddenError } from '@/errors/response-errors/forbidden.error';
import { InternalServerError } from '@/errors/response-errors/internal-server.error'; import { InternalServerError } from '@/errors/response-errors/internal-server.error';
@@ -56,14 +65,6 @@ import { UserManagementMailer } from '@/user-management/email';
import * as utils from '@/utils'; import * as utils from '@/utils';
import * as WorkflowHelpers from '@/workflow-helpers'; import * as WorkflowHelpers from '@/workflow-helpers';
import { WorkflowExecutionService } from './workflow-execution.service';
import { WorkflowFinderService } from './workflow-finder.service';
import { WorkflowHistoryService } from './workflow-history.ee/workflow-history.service.ee';
import { WorkflowRequest } from './workflow.request';
import { WorkflowService } from './workflow.service';
import { EnterpriseWorkflowService } from './workflow.service.ee';
import { CredentialsService } from '../credentials/credentials.service';
@RestController('/workflows') @RestController('/workflows')
export class WorkflowsController { export class WorkflowsController {
constructor( constructor(
@@ -535,7 +536,7 @@ export class WorkflowsController {
const projectsRelations = await this.projectRelationRepository.findBy({ const projectsRelations = await this.projectRelationRepository.findBy({
projectId: In(newShareeIds), projectId: In(newShareeIds),
role: 'project:personalOwner', role: { slug: PROJECT_OWNER_ROLE_SLUG },
}); });
await this.mailer.notifyWorkflowShared({ await this.mailer.notifyWorkflowShared({

View File

@@ -328,7 +328,6 @@ describe('Member', () => {
.post('/api-keys') .post('/api-keys')
.send({ label: 'My API Key', expiresAt: null, scopes: ['workflow:create'] }); .send({ label: 'My API Key', expiresAt: null, scopes: ['workflow:create'] });
console.log(newApiKeyResponse.body);
expect(newApiKeyResponse.statusCode).toBe(200); expect(newApiKeyResponse.statusCode).toBe(200);
expect(newApiKeyResponse.body.data.apiKey).toBeDefined(); expect(newApiKeyResponse.body.data.apiKey).toBeDefined();
expect(newApiKeyResponse.body.data.apiKey).not.toBeNull(); expect(newApiKeyResponse.body.data.apiKey).not.toBeNull();

View File

@@ -13,13 +13,9 @@ import {
UserRepository, UserRepository,
} from '@n8n/db'; } from '@n8n/db';
import { Container } from '@n8n/di'; import { Container } from '@n8n/di';
import { PROJECT_OWNER_ROLE_SLUG } from '@n8n/permissions';
import { Not } from '@n8n/typeorm'; import { Not } from '@n8n/typeorm';
import { EventService } from '@/events/event.service';
import { ExternalHooks } from '@/external-hooks';
import { PasswordUtility } from '@/services/password.utility';
import { UserManagementMailer } from '@/user-management/email';
import { import {
assertReturnedUserProps, assertReturnedUserProps,
assertStoredUserProps, assertStoredUserProps,
@@ -29,6 +25,11 @@ import { createMember, createOwner, createUserShell } from '../../shared/db/user
import * as utils from '../../shared/utils'; import * as utils from '../../shared/utils';
import type { UserInvitationResult } from '../../shared/utils/users'; import type { UserInvitationResult } from '../../shared/utils/users';
import { EventService } from '@/events/event.service';
import { ExternalHooks } from '@/external-hooks';
import { PasswordUtility } from '@/services/password.utility';
import { UserManagementMailer } from '@/user-management/email';
describe('InvitationController', () => { describe('InvitationController', () => {
const mailer = mockInstance(UserManagementMailer); const mailer = mockInstance(UserManagementMailer);
const externalHooks = mockInstance(ExternalHooks); const externalHooks = mockInstance(ExternalHooks);
@@ -296,7 +297,7 @@ describe('InvitationController', () => {
const projectRelation = await projectRelationRepository.findOneOrFail({ const projectRelation = await projectRelationRepository.findOneOrFail({
where: { where: {
userId: storedUser.id, userId: storedUser.id,
role: 'project:personalOwner', role: { slug: PROJECT_OWNER_ROLE_SLUG },
project: { project: {
type: 'personal', type: 'personal',
}, },

View File

@@ -20,12 +20,14 @@ import {
SharedWorkflowRepository, SharedWorkflowRepository,
} from '@n8n/db'; } from '@n8n/db';
import { Container } from '@n8n/di'; import { Container } from '@n8n/di';
import { getRoleScopes, type GlobalRole, type ProjectRole, type Scope } from '@n8n/permissions'; import {
getRoleScopes,
PROJECT_OWNER_ROLE_SLUG,
type GlobalRole,
type ProjectRole,
type Scope,
} from '@n8n/permissions';
import { EntityNotFoundError } from '@n8n/typeorm'; import { EntityNotFoundError } from '@n8n/typeorm';
import { ActiveWorkflowManager } from '@/active-workflow-manager';
import { getWorkflowById } from '@/public-api/v1/handlers/workflows/workflows.service';
import { CacheService } from '@/services/cache/cache.service';
import { createFolder } from '@test-integration/db/folders'; import { createFolder } from '@test-integration/db/folders';
import { import {
@@ -36,6 +38,10 @@ import {
import { createMember, createOwner, createUser } from './shared/db/users'; import { createMember, createOwner, createUser } from './shared/db/users';
import * as utils from './shared/utils/'; import * as utils from './shared/utils/';
import { ActiveWorkflowManager } from '@/active-workflow-manager';
import { getWorkflowById } from '@/public-api/v1/handlers/workflows/workflows.service';
import { CacheService } from '@/services/cache/cache.service';
const testServer = utils.setupTestServer({ const testServer = utils.setupTestServer({
endpointGroups: ['project'], endpointGroups: ['project'],
enabledFeatures: [ enabledFeatures: [
@@ -193,7 +199,7 @@ describe('GET /projects/my-projects', () => {
[ [
personalProject1, personalProject1,
{ {
role: 'project:personalOwner', role: PROJECT_OWNER_ROLE_SLUG,
scopes: ['project:list', 'project:read', 'credential:create'], scopes: ['project:list', 'project:read', 'credential:create'],
}, },
], ],
@@ -270,7 +276,7 @@ describe('GET /projects/my-projects', () => {
[ [
ownerProject, ownerProject,
{ {
role: 'project:personalOwner', role: PROJECT_OWNER_ROLE_SLUG,
scopes: [ scopes: [
'project:list', 'project:list',
'project:create', 'project:create',
@@ -596,15 +602,15 @@ describe('PATCH /projects/:projectId', () => {
expect(tp1Relations.find((p) => p.userId === testUser1.id)).not.toBeUndefined(); expect(tp1Relations.find((p) => p.userId === testUser1.id)).not.toBeUndefined();
expect(tp1Relations.find((p) => p.userId === testUser2.id)).toBeUndefined(); expect(tp1Relations.find((p) => p.userId === testUser2.id)).toBeUndefined();
expect(tp1Relations.find((p) => p.userId === testUser1.id)?.role).toBe('project:admin'); expect(tp1Relations.find((p) => p.userId === testUser1.id)?.role.slug).toBe('project:admin');
expect(tp1Relations.find((p) => p.userId === testUser3.id)?.role).toBe('project:editor'); expect(tp1Relations.find((p) => p.userId === testUser3.id)?.role.slug).toBe('project:editor');
expect(tp1Relations.find((p) => p.userId === ownerUser.id)?.role).toBe('project:viewer'); expect(tp1Relations.find((p) => p.userId === ownerUser.id)?.role.slug).toBe('project:viewer');
// Check we haven't modified the other team project // Check we haven't modified the other team project
expect(tp2Relations.find((p) => p.userId === testUser2.id)).not.toBeUndefined(); expect(tp2Relations.find((p) => p.userId === testUser2.id)).not.toBeUndefined();
expect(tp2Relations.find((p) => p.userId === testUser1.id)).toBeUndefined(); expect(tp2Relations.find((p) => p.userId === testUser1.id)).toBeUndefined();
expect(tp2Relations.find((p) => p.userId === testUser2.id)?.role).toBe('project:editor'); expect(tp2Relations.find((p) => p.userId === testUser2.id)?.role.slug).toBe('project:editor');
expect(tp2Relations.find((p) => p.userId === ownerUser.id)?.role).toBe('project:editor'); expect(tp2Relations.find((p) => p.userId === ownerUser.id)?.role.slug).toBe('project:editor');
}); });
test.each([['project:viewer'], ['project:editor']] as const)( test.each([['project:viewer'], ['project:editor']] as const)(
@@ -656,8 +662,14 @@ describe('PATCH /projects/:projectId', () => {
expect(tp1Relations.length).toBe(2); expect(tp1Relations.length).toBe(2);
expect(tp1Relations).toMatchObject( expect(tp1Relations).toMatchObject(
expect.arrayContaining([ expect.arrayContaining([
expect.objectContaining({ userId: actor.id, role }), expect.objectContaining({
expect.objectContaining({ userId: projectEditor.id, role: 'project:editor' }), userId: actor.id,
role: expect.objectContaining({ slug: role }),
}),
expect.objectContaining({
userId: projectEditor.id,
role: expect.objectContaining({ slug: 'project:editor' }),
}),
]), ]),
); );
}, },
@@ -692,7 +704,10 @@ describe('PATCH /projects/:projectId', () => {
expect(tpRelations.length).toBe(1); expect(tpRelations.length).toBe(1);
expect(tpRelations).toMatchObject( expect(tpRelations).toMatchObject(
expect.arrayContaining([ expect.arrayContaining([
expect.objectContaining({ userId: projectAdmin.id, role: 'project:admin' }), expect.objectContaining({
userId: projectAdmin.id,
role: expect.objectContaining({ slug: 'project:admin' }),
}),
]), ]),
); );
}, },
@@ -730,9 +745,9 @@ describe('PATCH /projects/:projectId', () => {
expect(tpRelations.find((p) => p.userId === testUser1.id)).not.toBeUndefined(); expect(tpRelations.find((p) => p.userId === testUser1.id)).not.toBeUndefined();
expect(tpRelations.find((p) => p.userId === testUser2.id)).not.toBeUndefined(); expect(tpRelations.find((p) => p.userId === testUser2.id)).not.toBeUndefined();
expect(tpRelations.find((p) => p.userId === testUser1.id)?.role).toBe('project:admin'); expect(tpRelations.find((p) => p.userId === testUser1.id)?.role?.slug).toBe('project:admin');
expect(tpRelations.find((p) => p.userId === testUser2.id)?.role).toBe('project:admin'); expect(tpRelations.find((p) => p.userId === testUser2.id)?.role?.slug).toBe('project:admin');
expect(tpRelations.find((p) => p.userId === testUser3.id)?.role).toBe('project:admin'); expect(tpRelations.find((p) => p.userId === testUser3.id)?.role?.slug).toBe('project:admin');
}); });
test("should edit a relation of a project when changing a user's role to an licensed role but unlicensed roles are present", async () => { test("should edit a relation of a project when changing a user's role to an licensed role but unlicensed roles are present", async () => {
@@ -768,9 +783,9 @@ describe('PATCH /projects/:projectId', () => {
expect(tpRelations.find((p) => p.userId === testUser1.id)).not.toBeUndefined(); expect(tpRelations.find((p) => p.userId === testUser1.id)).not.toBeUndefined();
expect(tpRelations.find((p) => p.userId === testUser2.id)).not.toBeUndefined(); expect(tpRelations.find((p) => p.userId === testUser2.id)).not.toBeUndefined();
expect(tpRelations.find((p) => p.userId === testUser3.id)).not.toBeUndefined(); expect(tpRelations.find((p) => p.userId === testUser3.id)).not.toBeUndefined();
expect(tpRelations.find((p) => p.userId === testUser1.id)?.role).toBe('project:viewer'); expect(tpRelations.find((p) => p.userId === testUser1.id)?.role?.slug).toBe('project:viewer');
expect(tpRelations.find((p) => p.userId === testUser2.id)?.role).toBe('project:admin'); expect(tpRelations.find((p) => p.userId === testUser2.id)?.role?.slug).toBe('project:admin');
expect(tpRelations.find((p) => p.userId === testUser3.id)?.role).toBe('project:admin'); expect(tpRelations.find((p) => p.userId === testUser3.id)?.role?.slug).toBe('project:admin');
}); });
test('should not add or remove users from a personal project', async () => { test('should not add or remove users from a personal project', async () => {
@@ -782,7 +797,7 @@ describe('PATCH /projects/:projectId', () => {
const resp = await memberAgent.patch(`/projects/${personalProject.id}`).send({ const resp = await memberAgent.patch(`/projects/${personalProject.id}`).send({
relations: [ relations: [
{ userId: testUser1.id, role: 'project:personalOwner' }, { userId: testUser1.id, role: PROJECT_OWNER_ROLE_SLUG },
{ userId: testUser2.id, role: 'project:admin' }, { userId: testUser2.id, role: 'project:admin' },
] as Array<{ ] as Array<{
userId: string; userId: string;

View File

@@ -61,7 +61,7 @@ describe('ProjectService', () => {
expect(relations[0]).toMatchObject({ expect(relations[0]).toMatchObject({
projectId: project.id, projectId: project.id,
userId: user.id, userId: user.id,
role: 'project:admin', role: { slug: 'project:admin' },
}); });
}); });
@@ -82,7 +82,7 @@ describe('ProjectService', () => {
expect(relations[0]).toMatchObject({ expect(relations[0]).toMatchObject({
projectId: project.id, projectId: project.id,
userId: user.id, userId: user.id,
role: 'project:editor', role: { slug: 'project:editor' },
}); });
}); });
}); });
@@ -103,7 +103,7 @@ describe('ProjectService', () => {
expect(relations[0]).toMatchObject({ expect(relations[0]).toMatchObject({
projectId: project.id, projectId: project.id,
userId: user.id, userId: user.id,
role: 'project:admin', role: { slug: 'project:admin' },
}); });
}); });
}); });

View File

@@ -564,23 +564,20 @@ describe('Projects in Public API', () => {
expect(projectAfter.length).toEqual(3); expect(projectAfter.length).toEqual(3);
const adminRelation = projectAfter.find( const adminRelation = projectAfter.find(
(relation) => relation.userId === owner.id && relation.role === 'project:admin', (relation) => relation.userId === owner.id && relation.role.slug === 'project:admin',
);
expect(adminRelation).toEqual(
expect.objectContaining({ userId: owner.id, role: 'project:admin' }),
); );
expect(adminRelation!.userId).toBe(owner.id);
expect(adminRelation!.role.slug).toBe('project:admin');
const viewerRelation = projectAfter.find( const viewerRelation = projectAfter.find(
(relation) => relation.userId === member.id && relation.role === 'project:viewer', (relation) => relation.userId === member.id && relation.role.slug === 'project:viewer',
);
expect(viewerRelation).toEqual(
expect.objectContaining({ userId: member.id, role: 'project:viewer' }),
); );
expect(viewerRelation!.userId).toBe(member.id);
expect(viewerRelation!.role.slug).toBe('project:viewer');
const editorRelation = projectAfter.find( const editorRelation = projectAfter.find(
(relation) => relation.userId === member2.id && relation.role === 'project:editor', (relation) => relation.userId === member2.id && relation.role.slug === 'project:editor',
);
expect(editorRelation).toEqual(
expect.objectContaining({ userId: member2.id, role: 'project:editor' }),
); );
expect(editorRelation!.userId).toBe(member2.id);
expect(editorRelation!.role.slug).toBe('project:editor');
}); });
it('should reject with 400 if license does not include user role', async () => { it('should reject with 400 if license does not include user role', async () => {
@@ -797,8 +794,12 @@ describe('Projects in Public API', () => {
expect(response.status).toBe(204); expect(response.status).toBe(204);
expect(projectBefore.length).toEqual(2); expect(projectBefore.length).toEqual(2);
expect(projectBefore.find((p) => p.role === 'project:admin')?.userId).toEqual(owner.id); expect(projectBefore.find((p) => p.role.slug === 'project:admin')?.userId).toEqual(
expect(projectBefore.find((p) => p.role === 'project:viewer')?.userId).toEqual(member.id); owner.id,
);
expect(projectBefore.find((p) => p.role.slug === 'project:viewer')?.userId).toEqual(
member.id,
);
expect(projectAfter.length).toEqual(1); expect(projectAfter.length).toEqual(1);
expect(projectAfter[0].userId).toEqual(owner.id); expect(projectAfter[0].userId).toEqual(owner.id);

View File

@@ -1,4 +1,4 @@
import { getRoleScopes } from '@n8n/permissions'; import { getRoleScopes, PROJECT_OWNER_ROLE_SLUG } from '@n8n/permissions';
import type { import type {
GlobalRole, GlobalRole,
ProjectRole, ProjectRole,
@@ -76,8 +76,8 @@ beforeAll(async () => {
expectedProjectRoles = [ expectedProjectRoles = [
{ {
name: 'Project Owner', name: 'Project Owner',
role: 'project:personalOwner', role: PROJECT_OWNER_ROLE_SLUG,
scopes: getRoleScopes('project:personalOwner'), scopes: getRoleScopes(PROJECT_OWNER_ROLE_SLUG),
licensed: true, licensed: true,
description: 'Project Owner', description: 'Project Owner',
}, },

View File

@@ -1,12 +1,12 @@
import { testDb } from '@n8n/backend-test-utils'; import { testDb } from '@n8n/backend-test-utils';
import { ProjectRelationRepository, ProjectRepository } from '@n8n/db'; import { ProjectRelationRepository, ProjectRepository } from '@n8n/db';
import { Container } from '@n8n/di'; import { Container } from '@n8n/di';
import type { ProjectRole, Scope } from '@n8n/permissions'; import { PROJECT_OWNER_ROLE_SLUG, type ProjectRole, type Scope } from '@n8n/permissions';
import { ProjectService } from '@/services/project.service.ee';
import { createMember } from '../shared/db/users'; import { createMember } from '../shared/db/users';
import { ProjectService } from '@/services/project.service.ee';
let projectRepository: ProjectRepository; let projectRepository: ProjectRepository;
let projectService: ProjectService; let projectService: ProjectService;
let projectRelationRepository: ProjectRelationRepository; let projectRelationRepository: ProjectRelationRepository;
@@ -33,7 +33,7 @@ describe('ProjectService', () => {
'project:viewer', 'project:viewer',
'project:admin', 'project:admin',
'project:editor', 'project:editor',
'project:personalOwner', PROJECT_OWNER_ROLE_SLUG,
] as ProjectRole[])( ] as ProjectRole[])(
'creates a relation between the user and the project using the role %s', 'creates a relation between the user and the project using the role %s',
async (role) => { async (role) => {
@@ -57,7 +57,7 @@ describe('ProjectService', () => {
// ASSERT // ASSERT
// //
await projectRelationRepository.findOneOrFail({ await projectRelationRepository.findOneOrFail({
where: { userId: member.id, projectId: project.id, role }, where: { userId: member.id, projectId: project.id, role: { slug: role } },
}); });
}, },
); );
@@ -76,7 +76,7 @@ describe('ProjectService', () => {
await projectService.addUser(project.id, { userId: member.id, role: 'project:viewer' }); await projectService.addUser(project.id, { userId: member.id, role: 'project:viewer' });
await projectRelationRepository.findOneOrFail({ await projectRelationRepository.findOneOrFail({
where: { userId: member.id, projectId: project.id, role: 'project:viewer' }, where: { userId: member.id, projectId: project.id, role: { slug: 'project:viewer' } },
}); });
// //
@@ -89,10 +89,11 @@ describe('ProjectService', () => {
// //
const relationships = await projectRelationRepository.find({ const relationships = await projectRelationRepository.find({
where: { userId: member.id, projectId: project.id }, where: { userId: member.id, projectId: project.id },
relations: { role: true },
}); });
expect(relationships).toHaveLength(1); expect(relationships).toHaveLength(1);
expect(relationships[0]).toHaveProperty('role', 'project:admin'); expect(relationships[0]).toHaveProperty('role.slug', 'project:admin');
}); });
}); });
@@ -202,7 +203,7 @@ describe('ProjectService', () => {
describe('deleteUserFromProject', () => { describe('deleteUserFromProject', () => {
it('should not allow project owner to be removed from the project', async () => { it('should not allow project owner to be removed from the project', async () => {
const role = 'project:personalOwner'; const role = PROJECT_OWNER_ROLE_SLUG;
const user = await createMember(); const user = await createMember();
const project = await projectRepository.save( const project = await projectRepository.save(
@@ -233,7 +234,7 @@ describe('ProjectService', () => {
await projectService.deleteUserFromProject(project.id, user.id); await projectService.deleteUserFromProject(project.id, user.id);
const relations = await projectRelationRepository.findOne({ const relations = await projectRelationRepository.findOne({
where: { userId: user.id, projectId: project.id, role }, where: { userId: user.id, projectId: project.id, role: { slug: role } },
}); });
expect(relations).toBeNull(); expect(relations).toBeNull();

View File

@@ -1092,7 +1092,7 @@ describe('DELETE /users/:id', () => {
id: teamProject.id, id: teamProject.id,
projectRelations: { projectRelations: {
userId: transferee.id, userId: transferee.id,
role: 'project:editor', role: { slug: 'project:editor' },
}, },
}), }),
).resolves.not.toBeNull(), ).resolves.not.toBeNull(),