From f7577903945e7737ade17ab30e54a3b3b243c939 Mon Sep 17 00:00:00 2001 From: Guillaume Jacquart Date: Thu, 28 Aug 2025 11:00:31 +0200 Subject: [PATCH] feat(core): Rebuild project roles to load from the database (#17909) --- .../change-user-role-in-project.dto.test.ts | 4 +- .../change-user-role-in-project.dto.ts | 4 +- .../api-types/src/schemas/project.schema.ts | 4 +- .../backend-test-utils/src/db/projects.ts | 16 ++--- packages/@n8n/db/src/constants.ts | 42 ++++++++++++- .../@n8n/db/src/entities/project-relation.ts | 9 +-- packages/@n8n/db/src/entities/role.ts | 6 +- ...53244168-LinkRoleToProjectRelationTable.ts | 46 ++++++++++++++ .../@n8n/db/src/migrations/mysqldb/index.ts | 2 + .../db/src/migrations/postgresdb/index.ts | 2 + .../@n8n/db/src/migrations/sqlite/index.ts | 2 + .../project-relation.repository.ts | 7 ++- .../db/src/repositories/project.repository.ts | 12 +++- .../shared-credentials.repository.ts | 2 +- .../shared-workflow.repository.ts | 6 +- .../db/src/repositories/user.repository.ts | 27 ++++---- .../workflow-statistics.repository.ts | 3 +- .../db/src/services/auth.roles.service.ts | 12 ++-- .../permissions/src/__tests__/schemas.test.ts | 31 ++++++++-- packages/@n8n/permissions/src/constants.ee.ts | 5 ++ packages/@n8n/permissions/src/index.ts | 2 +- .../@n8n/permissions/src/roles/all-roles.ts | 14 +++-- packages/@n8n/permissions/src/schemas.ee.ts | 8 ++- packages/@n8n/permissions/src/types.ee.ts | 1 + .../commands/__tests__/execute-batch.test.ts | 3 +- .../src/commands/__tests__/execute.test.ts | 7 ++- packages/cli/src/commands/base-command.ts | 5 +- .../cli/src/commands/import/credentials.ts | 7 ++- packages/cli/src/commands/import/workflow.ts | 7 ++- packages/cli/src/commands/start.ts | 8 +-- .../cli/src/controllers/project.controller.ts | 29 +++++---- .../cli/src/controllers/users.controller.ts | 2 +- packages/cli/src/credentials-helper.ts | 12 +++- .../src/credentials/credentials.controller.ts | 11 ++-- .../src/credentials/credentials.service.ts | 9 ++- .../source-control-export.service.test.ts | 6 +- .../source-control-import.service.ee.test.ts | 4 +- .../source-control-export.service.ee.ts | 11 ++-- .../source-control-import.service.ee.ts | 37 ++++++----- .../source-control-scoped.service.ts | 22 ++++--- .../events/relays/telemetry.event-relay.ts | 9 +-- .../src/executions/executions.controller.ts | 16 ++--- .../data-store-aggregate.service.test.ts | 12 ++-- .../__tests__/check-access.test.ts | 17 +++-- .../cli/src/permissions.ee/check-access.ts | 30 +++++---- .../handlers/workflows/workflows.service.ts | 4 +- packages/cli/src/requests.ts | 14 ++++- .../credentials-finder.service.test.ts | 24 ++++--- .../__tests__/ownership.service.test.ts | 8 ++- .../__tests__/project.service.ee.test.ts | 36 ++++++----- .../cli/src/services/project.service.ee.ts | 62 ++++++++++++------- packages/cli/src/services/role.service.ts | 7 ++- .../src/workflows/workflow-sharing.service.ts | 5 +- .../cli/src/workflows/workflow.service.ee.ts | 6 +- .../cli/src/workflows/workflows.controller.ts | 19 +++--- .../cli/test/integration/api-keys.api.test.ts | 1 - .../invitation.controller.integration.test.ts | 13 ++-- .../cli/test/integration/project.api.test.ts | 59 +++++++++++------- .../project.service.integration.test.ts | 6 +- .../integration/public-api/projects.test.ts | 29 ++++----- .../cli/test/integration/role.api.test.ts | 6 +- .../services/project.service.test.ts | 19 +++--- .../cli/test/integration/users.api.test.ts | 2 +- 63 files changed, 546 insertions(+), 305 deletions(-) create mode 100644 packages/@n8n/db/src/migrations/common/1753953244168-LinkRoleToProjectRelationTable.ts diff --git a/packages/@n8n/api-types/src/dto/project/__tests__/change-user-role-in-project.dto.test.ts b/packages/@n8n/api-types/src/dto/project/__tests__/change-user-role-in-project.dto.test.ts index e215aee546..27c7a938f2 100644 --- a/packages/@n8n/api-types/src/dto/project/__tests__/change-user-role-in-project.dto.test.ts +++ b/packages/@n8n/api-types/src/dto/project/__tests__/change-user-role-in-project.dto.test.ts @@ -1,3 +1,5 @@ +import { PROJECT_OWNER_ROLE_SLUG } from '@n8n/permissions'; + import { ChangeUserRoleInProject } from '../change-user-role-in-project.dto'; describe('ChangeUserRoleInProject', () => { @@ -38,7 +40,7 @@ describe('ChangeUserRoleInProject', () => { }, { name: 'personal owner role', - request: { role: 'project:personalOwner' }, + request: { role: PROJECT_OWNER_ROLE_SLUG }, expectedErrorPath: ['role'], }, ])('should reject $name', ({ request, expectedErrorPath }) => { diff --git a/packages/@n8n/api-types/src/dto/project/change-user-role-in-project.dto.ts b/packages/@n8n/api-types/src/dto/project/change-user-role-in-project.dto.ts index 17550855a4..ab541ecb8e 100644 --- a/packages/@n8n/api-types/src/dto/project/change-user-role-in-project.dto.ts +++ b/packages/@n8n/api-types/src/dto/project/change-user-role-in-project.dto.ts @@ -1,6 +1,6 @@ -import { projectRoleSchema } from '@n8n/permissions'; +import { teamRoleSchema } from '@n8n/permissions'; import { Z } from 'zod-class'; export class ChangeUserRoleInProject extends Z.class({ - role: projectRoleSchema.exclude(['project:personalOwner']), + role: teamRoleSchema, }) {} diff --git a/packages/@n8n/api-types/src/schemas/project.schema.ts b/packages/@n8n/api-types/src/schemas/project.schema.ts index 8f15173815..55545e7271 100644 --- a/packages/@n8n/api-types/src/schemas/project.schema.ts +++ b/packages/@n8n/api-types/src/schemas/project.schema.ts @@ -1,4 +1,4 @@ -import { projectRoleSchema } from '@n8n/permissions'; +import { teamRoleSchema } from '@n8n/permissions'; import { z } from 'zod'; 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({ userId: z.string().min(1), - role: projectRoleSchema.exclude(['project:personalOwner']), + role: teamRoleSchema, }); export type ProjectRelation = z.infer; diff --git a/packages/@n8n/backend-test-utils/src/db/projects.ts b/packages/@n8n/backend-test-utils/src/db/projects.ts index d81c55fa3e..7da5b2863f 100644 --- a/packages/@n8n/backend-test-utils/src/db/projects.ts +++ b/packages/@n8n/backend-test-utils/src/db/projects.ts @@ -1,17 +1,17 @@ import type { Project, User, ProjectRelation } from '@n8n/db'; import { ProjectRelationRepository, ProjectRepository } from '@n8n/db'; 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'; -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); await projectRelationRepository.save( projectRelationRepository.create({ projectId: project.id, userId: user.id, - role, + role: { slug: role }, }), ); }; @@ -41,7 +41,7 @@ export const getPersonalProject = async (user: User): Promise => { where: { projectRelations: { userId: user.id, - role: 'project:personalOwner', + role: { slug: PROJECT_OWNER_ROLE_SLUG }, }, type: 'personal', }, @@ -61,19 +61,20 @@ export const getProjectRelations = async ({ }: Partial): Promise => { return await Container.get(ProjectRelationRepository).find({ where: { projectId, userId, role }, + relations: { role: true }, }); }; export const getProjectRoleForUser = async ( projectId: string, userId: string, -): Promise => { +): Promise => { return ( await Container.get(ProjectRelationRepository).findOne({ - select: ['role'], where: { projectId, userId }, + relations: { role: true }, }) - )?.role; + )?.role?.slug; }; export const getAllProjectRelations = async ({ @@ -81,5 +82,6 @@ export const getAllProjectRelations = async ({ }: Partial): Promise => { return await Container.get(ProjectRelationRepository).find({ where: { projectId }, + relations: { role: true }, }); }; diff --git a/packages/@n8n/db/src/constants.ts b/packages/@n8n/db/src/constants.ts index 6520148711..99f0e12416 100644 --- a/packages/@n8n/db/src/constants.ts +++ b/packages/@n8n/db/src/constants.ts @@ -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'; @@ -16,15 +25,44 @@ export function buildInRoleToRoleObject(role: GlobalRole): Role { systemRole: true, roleType: 'global', 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_ADMIN_ROLE = buildInRoleToRoleObject('global:admin'); 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 = { 'global:owner': GLOBAL_OWNER_ROLE, 'global:admin': GLOBAL_ADMIN_ROLE, 'global:member': GLOBAL_MEMBER_ROLE, }; + +export const PROJECT_ROLES: Record = { + [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, +}; diff --git a/packages/@n8n/db/src/entities/project-relation.ts b/packages/@n8n/db/src/entities/project-relation.ts index c3b5c6de79..dd2e316f90 100644 --- a/packages/@n8n/db/src/entities/project-relation.ts +++ b/packages/@n8n/db/src/entities/project-relation.ts @@ -1,14 +1,15 @@ -import { ProjectRole } from '@n8n/permissions'; -import { Column, Entity, ManyToOne, PrimaryColumn } from '@n8n/typeorm'; +import { Entity, JoinColumn, ManyToOne, PrimaryColumn } from '@n8n/typeorm'; import { WithTimestamps } from './abstract-entity'; import { Project } from './project'; +import { Role } from './role'; import { User } from './user'; @Entity() export class ProjectRelation extends WithTimestamps { - @Column({ type: 'varchar' }) - role: ProjectRole; + @ManyToOne('Role', 'projectRelations') + @JoinColumn({ name: 'role', referencedColumnName: 'slug' }) + role: Role; @ManyToOne('User', 'projectRelations') user: User; diff --git a/packages/@n8n/db/src/entities/role.ts b/packages/@n8n/db/src/entities/role.ts index 5bea216275..7db4d1bb15 100644 --- a/packages/@n8n/db/src/entities/role.ts +++ b/packages/@n8n/db/src/entities/role.ts @@ -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'; @Entity({ @@ -45,6 +46,9 @@ export class Role { */ roleType: 'global' | 'project' | 'workflow' | 'credential'; + @OneToMany('ProjectRelation', 'role') + projectRelations: ProjectRelation[]; + @ManyToMany(() => Scope, { eager: true, }) diff --git a/packages/@n8n/db/src/migrations/common/1753953244168-LinkRoleToProjectRelationTable.ts b/packages/@n8n/db/src/migrations/common/1753953244168-LinkRoleToProjectRelationTable.ts new file mode 100644 index 0000000000..8281fde1cf --- /dev/null +++ b/packages/@n8n/db/src/migrations/common/1753953244168-LinkRoleToProjectRelationTable.ts @@ -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']); + } +} diff --git a/packages/@n8n/db/src/migrations/mysqldb/index.ts b/packages/@n8n/db/src/migrations/mysqldb/index.ts index c3a7c4a46e..b5028d6665 100644 --- a/packages/@n8n/db/src/migrations/mysqldb/index.ts +++ b/packages/@n8n/db/src/migrations/mysqldb/index.ts @@ -1,4 +1,5 @@ import { AddMfaColumns1690000000030 } from './../common/1690000000040-AddMfaColumns'; +import { LinkRoleToProjectRelationTable1753953244168 } from './../common/1753953244168-LinkRoleToProjectRelationTable'; import { InitialMigration1588157391238 } from './1588157391238-InitialMigration'; import { WebhookModel1592447867632 } from './1592447867632-WebhookModel'; import { CreateIndexStoppedAt1594902918301 } from './1594902918301-CreateIndexStoppedAt'; @@ -197,4 +198,5 @@ export const mysqlMigrations: Migration[] = [ CreateDataStoreTables1754475614601, RemoveOldRoleColumn1750252139170, ReplaceDataStoreTablesWithDataTables1754475614602, + LinkRoleToProjectRelationTable1753953244168, ]; diff --git a/packages/@n8n/db/src/migrations/postgresdb/index.ts b/packages/@n8n/db/src/migrations/postgresdb/index.ts index afb29c46e4..79db0086c8 100644 --- a/packages/@n8n/db/src/migrations/postgresdb/index.ts +++ b/packages/@n8n/db/src/migrations/postgresdb/index.ts @@ -1,5 +1,6 @@ import { AddMfaColumns1690000000030 } from './../common/1690000000040-AddMfaColumns'; import { AddInputsOutputsToTestCaseExecution1752669793000 } from './../common/1752669793000-AddInputsOutputsToTestCaseExecution'; +import { LinkRoleToProjectRelationTable1753953244168 } from './../common/1753953244168-LinkRoleToProjectRelationTable'; import { InitialMigration1587669153312 } from './1587669153312-InitialMigration'; import { WebhookModel1589476000887 } from './1589476000887-WebhookModel'; import { CreateIndexStoppedAt1594828256133 } from './1594828256133-CreateIndexStoppedAt'; @@ -195,4 +196,5 @@ export const postgresMigrations: Migration[] = [ CreateDataStoreTables1754475614601, RemoveOldRoleColumn1750252139170, ReplaceDataStoreTablesWithDataTables1754475614602, + LinkRoleToProjectRelationTable1753953244168, ]; diff --git a/packages/@n8n/db/src/migrations/sqlite/index.ts b/packages/@n8n/db/src/migrations/sqlite/index.ts index 7babeee464..d095abc9d7 100644 --- a/packages/@n8n/db/src/migrations/sqlite/index.ts +++ b/packages/@n8n/db/src/migrations/sqlite/index.ts @@ -93,6 +93,7 @@ import { AddInputsOutputsToTestCaseExecution1752669793000 } from '../common/1752 import { CreateDataStoreTables1754475614601 } from '../common/1754475614601-CreateDataStoreTables'; import { ReplaceDataStoreTablesWithDataTables1754475614602 } from '../common/1754475614602-ReplaceDataStoreTablesWithDataTables'; import type { Migration } from '../migration-types'; +import { LinkRoleToProjectRelationTable1753953244168 } from './../common/1753953244168-LinkRoleToProjectRelationTable'; const sqliteMigrations: Migration[] = [ InitialMigration1588102412422, @@ -189,6 +190,7 @@ const sqliteMigrations: Migration[] = [ CreateDataStoreTables1754475614601, RemoveOldRoleColumn1750252139170, ReplaceDataStoreTablesWithDataTables1754475614602, + LinkRoleToProjectRelationTable1753953244168, ]; export { sqliteMigrations }; diff --git a/packages/@n8n/db/src/repositories/project-relation.repository.ts b/packages/@n8n/db/src/repositories/project-relation.repository.ts index 9ed236c0a0..88be579043 100644 --- a/packages/@n8n/db/src/repositories/project-relation.repository.ts +++ b/packages/@n8n/db/src/repositories/project-relation.repository.ts @@ -1,5 +1,5 @@ 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 { ProjectRelation } from '../entities'; @@ -14,7 +14,7 @@ export class ProjectRelationRepository extends Repository { return await this.find({ where: { projectId: In(projectIds), - role: 'project:personalOwner', + role: { slug: PROJECT_OWNER_ROLE_SLUG }, }, relations: { user: { @@ -28,7 +28,7 @@ export class ProjectRelationRepository extends Repository { const projectRelations = await this.find({ where: { userId: In(userIds), - role: 'project:personalOwner', + role: { slug: PROJECT_OWNER_ROLE_SLUG }, }, }); @@ -73,6 +73,7 @@ export class ProjectRelationRepository extends Repository { where: { userId, }, + relations: { role: true }, }); } } diff --git a/packages/@n8n/db/src/repositories/project.repository.ts b/packages/@n8n/db/src/repositories/project.repository.ts index d1f8af9580..d04e998524 100644 --- a/packages/@n8n/db/src/repositories/project.repository.ts +++ b/packages/@n8n/db/src/repositories/project.repository.ts @@ -1,4 +1,5 @@ import { Service } from '@n8n/di'; +import { PROJECT_OWNER_ROLE_SLUG } from '@n8n/permissions'; import type { EntityManager } from '@n8n/typeorm'; import { DataSource, Repository } from '@n8n/typeorm'; @@ -14,7 +15,11 @@ export class ProjectRepository extends Repository { const em = entityManager ?? this.manager; 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 { const em = entityManager ?? this.manager; return await em.findOneOrFail(Project, { - where: { type: 'personal', projectRelations: { userId, role: 'project:personalOwner' } }, + where: { + type: 'personal', + projectRelations: { userId, role: { slug: PROJECT_OWNER_ROLE_SLUG } }, + }, }); } diff --git a/packages/@n8n/db/src/repositories/shared-credentials.repository.ts b/packages/@n8n/db/src/repositories/shared-credentials.repository.ts index 8f3d11e7bd..75a321ca8b 100644 --- a/packages/@n8n/db/src/repositories/shared-credentials.repository.ts +++ b/packages/@n8n/db/src/repositories/shared-credentials.repository.ts @@ -120,7 +120,7 @@ export class SharedCredentialsRepository extends Repository { project: { projectRelations: { userId: In(userIds), - role: In(projectRoles), + role: { slug: In(projectRoles) }, }, }, }, diff --git a/packages/@n8n/db/src/repositories/shared-workflow.repository.ts b/packages/@n8n/db/src/repositories/shared-workflow.repository.ts index 3c4d8b9bf5..3311ef9aa4 100644 --- a/packages/@n8n/db/src/repositories/shared-workflow.repository.ts +++ b/packages/@n8n/db/src/repositories/shared-workflow.repository.ts @@ -1,5 +1,5 @@ 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 type { EntityManager, FindManyOptions, FindOptionsWhere } from '@n8n/typeorm'; @@ -28,7 +28,7 @@ export class SharedWorkflowRepository extends Repository { role: 'workflow:owner', workflowId: In(workflowIds), }, - relations: { project: { projectRelations: { user: true } } }, + relations: { project: { projectRelations: { user: true, role: true } } }, }); } @@ -46,7 +46,7 @@ export class SharedWorkflowRepository extends Repository { }, where: { workflowId, - project: { projectRelations: { role: 'project:personalOwner', userId } }, + project: { projectRelations: { role: { slug: PROJECT_OWNER_ROLE_SLUG }, userId } }, }, }); diff --git a/packages/@n8n/db/src/repositories/user.repository.ts b/packages/@n8n/db/src/repositories/user.repository.ts index 74f72d7314..9ac5808e43 100644 --- a/packages/@n8n/db/src/repositories/user.repository.ts +++ b/packages/@n8n/db/src/repositories/user.repository.ts @@ -1,5 +1,6 @@ import type { UsersListFilterDto } from '@n8n/api-types'; import { Service } from '@n8n/di'; +import { PROJECT_OWNER_ROLE_SLUG } from '@n8n/permissions'; import type { DeepPartial, EntityManager, SelectQueryBuilder } from '@n8n/typeorm'; import { Brackets, DataSource, In, IsNull, Not, Repository } from '@n8n/typeorm'; @@ -106,8 +107,8 @@ export class UserRepository extends Repository { await entityManager.save( entityManager.create(ProjectRelation, { projectId: savedProject.id, - userId: userWithRole.id, - role: 'project:personalOwner', + userId: savedUser.id, + role: { slug: PROJECT_OWNER_ROLE_SLUG }, }), ); return { user: userWithRole, project: savedProject }; @@ -129,7 +130,7 @@ export class UserRepository extends Repository { return await this.findOne({ where: { projectRelations: { - role: 'project:personalOwner', + role: { slug: PROJECT_OWNER_ROLE_SLUG }, project: { sharedWorkflows: { workflowId, role: 'workflow:owner' } }, }, }, @@ -146,7 +147,7 @@ export class UserRepository extends Repository { return await this.findOne({ where: { projectRelations: { - role: 'project:personalOwner', + role: { slug: PROJECT_OWNER_ROLE_SLUG }, projectId, }, }, @@ -232,15 +233,15 @@ export class UserRepository extends Repository { expand: UsersListFilterDto['expand'], ): SelectQueryBuilder { if (expand?.includes('projectRelations')) { - queryBuilder.leftJoinAndSelect( - 'user.projectRelations', - 'projectRelations', - 'projectRelations.role <> :projectRole', - { - projectRole: 'project:personalOwner', // Exclude personal project relations - }, - ); - queryBuilder.leftJoinAndSelect('projectRelations.project', 'project'); + queryBuilder + .leftJoinAndSelect( + 'user.projectRelations', + 'projectRelations', + 'projectRelations.role <> :projectRole', + { projectRole: PROJECT_OWNER_ROLE_SLUG }, + ) + .leftJoinAndSelect('projectRelations.project', 'project') + .leftJoinAndSelect('projectRelations.role', 'projectRole'); } return queryBuilder; diff --git a/packages/@n8n/db/src/repositories/workflow-statistics.repository.ts b/packages/@n8n/db/src/repositories/workflow-statistics.repository.ts index bf6a367ad9..26b7e1c5c1 100644 --- a/packages/@n8n/db/src/repositories/workflow-statistics.repository.ts +++ b/packages/@n8n/db/src/repositories/workflow-statistics.repository.ts @@ -1,5 +1,6 @@ import { GlobalConfig } from '@n8n/config'; import { Service } from '@n8n/di'; +import { PROJECT_OWNER_ROLE_SLUG } from '@n8n/permissions'; import { DataSource, MoreThanOrEqual, QueryFailedError, Repository } from '@n8n/typeorm'; import { WorkflowStatistics } from '../entities'; @@ -122,7 +123,7 @@ export class WorkflowStatisticsRepository extends Repository workflow: { shared: { role: 'workflow:owner', - project: { projectRelations: { userId, role: 'project:personalOwner' } }, + project: { projectRelations: { userId, role: { slug: PROJECT_OWNER_ROLE_SLUG } } }, }, active: true, }, diff --git a/packages/@n8n/db/src/services/auth.roles.service.ts b/packages/@n8n/db/src/services/auth.roles.service.ts index e5c08aec60..1f99761635 100644 --- a/packages/@n8n/db/src/services/auth.roles.service.ts +++ b/packages/@n8n/db/src/services/auth.roles.service.ts @@ -52,9 +52,9 @@ export class AuthRolesService { }).filter((scope) => scope !== null); if (scopesToUpdate.length > 0) { - this.logger.info(`Updating ${scopesToUpdate.length} scopes...`); + this.logger.debug(`Updating ${scopesToUpdate.length} scopes...`); await this.scopeRepository.save(scopesToUpdate); - this.logger.info('Scopes updated successfully.'); + this.logger.debug('Scopes updated successfully.'); } else { this.logger.debug('No scopes to update.'); } @@ -118,9 +118,9 @@ export class AuthRolesService { }) .filter((role) => role !== null); 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); - this.logger.info(`${roleNamespace} roles updated successfully.`); + this.logger.debug(`${roleNamespace} roles updated successfully.`); } else { this.logger.debug(`No ${roleNamespace} roles to update.`); } @@ -128,9 +128,9 @@ export class AuthRolesService { } async init() { - this.logger.info('Initializing AuthRolesService...'); + this.logger.debug('Initializing AuthRolesService...'); await this.syncScopes(); await this.syncRoles(); - this.logger.info('AuthRolesService initialized successfully.'); + this.logger.debug('AuthRolesService initialized successfully.'); } } diff --git a/packages/@n8n/permissions/src/__tests__/schemas.test.ts b/packages/@n8n/permissions/src/__tests__/schemas.test.ts index 1bfb097ec8..1add35ced7 100644 --- a/packages/@n8n/permissions/src/__tests__/schemas.test.ts +++ b/packages/@n8n/permissions/src/__tests__/schemas.test.ts @@ -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 { roleNamespaceSchema, globalRoleSchema, @@ -53,10 +60,26 @@ describe('assignableGlobalRoleSchema', () => { describe('projectRoleSchema', () => { 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:editor', value: 'project:editor', expected: true }, - { name: 'valid role: project:viewer', value: 'project:viewer', expected: true }, + { + name: `valid role: ${PROJECT_OWNER_ROLE_SLUG}`, + value: PROJECT_OWNER_ROLE_SLUG, + 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 }, ])('should validate $name', ({ value, expected }) => { const result = projectRoleSchema.safeParse(value); diff --git a/packages/@n8n/permissions/src/constants.ee.ts b/packages/@n8n/permissions/src/constants.ee.ts index 83db301ce4..be565285be 100644 --- a/packages/@n8n/permissions/src/constants.ee.ts +++ b/packages/@n8n/permissions/src/constants.ee.ts @@ -43,3 +43,8 @@ export const API_KEY_RESOURCES = { sourceControl: ['pull'] as const, workflowTags: ['update', 'list'] 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'; diff --git a/packages/@n8n/permissions/src/index.ts b/packages/@n8n/permissions/src/index.ts index fdcc7bb264..979f396c76 100644 --- a/packages/@n8n/permissions/src/index.ts +++ b/packages/@n8n/permissions/src/index.ts @@ -6,7 +6,7 @@ export * from './scope-information'; export * from './roles/role-maps.ee'; export * from './roles/all-roles'; -export { projectRoleSchema } from './schemas.ee'; +export { projectRoleSchema, teamRoleSchema } from './schemas.ee'; export { hasScope } from './utilities/has-scope.ee'; export { hasGlobalScope } from './utilities/has-global-scope.ee'; diff --git a/packages/@n8n/permissions/src/roles/all-roles.ts b/packages/@n8n/permissions/src/roles/all-roles.ts index a14de59ebb..b70b54cb08 100644 --- a/packages/@n8n/permissions/src/roles/all-roles.ts +++ b/packages/@n8n/permissions/src/roles/all-roles.ts @@ -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 { CREDENTIALS_SHARING_SCOPE_MAP, GLOBAL_SCOPE_MAP, @@ -11,10 +17,10 @@ const ROLE_NAMES: Record = { 'global:owner': 'Owner', 'global:admin': 'Admin', 'global:member': 'Member', - 'project:personalOwner': 'Project Owner', - 'project:admin': 'Project Admin', - 'project:editor': 'Project Editor', - 'project:viewer': 'Project Viewer', + [PROJECT_OWNER_ROLE_SLUG]: 'Project Owner', + [PROJECT_ADMIN_ROLE_SLUG]: 'Project Admin', + [PROJECT_EDITOR_ROLE_SLUG]: 'Project Editor', + [PROJECT_VIEWER_ROLE_SLUG]: 'Project Viewer', 'credential:user': 'Credential User', 'credential:owner': 'Credential Owner', 'workflow:owner': 'Workflow Owner', diff --git a/packages/@n8n/permissions/src/schemas.ee.ts b/packages/@n8n/permissions/src/schemas.ee.ts index 8fd21af361..776db6abc7 100644 --- a/packages/@n8n/permissions/src/schemas.ee.ts +++ b/packages/@n8n/permissions/src/schemas.ee.ts @@ -1,5 +1,7 @@ import { z } from 'zod'; +import { PROJECT_OWNER_ROLE_SLUG } from './constants.ee'; + export const roleNamespaceSchema = z.enum(['global', 'project', 'credential', 'workflow']); 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 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']); diff --git a/packages/@n8n/permissions/src/types.ee.ts b/packages/@n8n/permissions/src/types.ee.ts index 34bc542c78..fe9b4bb38f 100644 --- a/packages/@n8n/permissions/src/types.ee.ts +++ b/packages/@n8n/permissions/src/types.ee.ts @@ -58,6 +58,7 @@ export type CredentialSharingRole = z.infer; export type WorkflowSharingRole = z.infer; export type TeamProjectRole = z.infer; export type ProjectRole = z.infer; +export type CustomRole = string; /** Union of all possible role types in the system */ export type AllRoleTypes = GlobalRole | ProjectRole | WorkflowSharingRole | CredentialSharingRole; diff --git a/packages/cli/src/commands/__tests__/execute-batch.test.ts b/packages/cli/src/commands/__tests__/execute-batch.test.ts index a52bdd3093..86626f448d 100644 --- a/packages/cli/src/commands/__tests__/execute-batch.test.ts +++ b/packages/cli/src/commands/__tests__/execute-batch.test.ts @@ -1,7 +1,7 @@ import { mockInstance } from '@n8n/backend-test-utils'; import { GlobalConfig } from '@n8n/config'; 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 { type SelectQueryBuilder } from '@n8n/typeorm'; import { mock } from 'jest-mock-extended'; @@ -39,6 +39,7 @@ mockInstance(CommunityPackagesService); const dbConnection = mockInstance(DbConnection); dbConnection.init.mockResolvedValue(undefined); dbConnection.migrate.mockResolvedValue(undefined); +mockInstance(AuthRolesService); test('should start a task runner when task runners are enabled', async () => { // arrange diff --git a/packages/cli/src/commands/__tests__/execute.test.ts b/packages/cli/src/commands/__tests__/execute.test.ts index 30896c4b22..abc50ab898 100644 --- a/packages/cli/src/commands/__tests__/execute.test.ts +++ b/packages/cli/src/commands/__tests__/execute.test.ts @@ -1,11 +1,13 @@ import { mockInstance } from '@n8n/backend-test-utils'; import { GlobalConfig } from '@n8n/config'; 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 { mock } from 'jest-mock-extended'; import type { IRun } from 'n8n-workflow'; +import { Execute } from '../execute'; + import { ActiveExecutions } from '@/active-executions'; import { DeprecationService } from '@/deprecation/deprecation.service'; 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 { WorkflowRunner } from '@/workflow-runner'; -import { Execute } from '../execute'; - const taskRunnerModule = mockInstance(TaskRunnerModule); const workflowRepository = mockInstance(WorkflowRepository); const ownershipService = mockInstance(OwnershipService); @@ -38,6 +38,7 @@ mockInstance(CommunityPackagesService); const dbConnection = mockInstance(DbConnection); dbConnection.init.mockResolvedValue(undefined); dbConnection.migrate.mockResolvedValue(undefined); +mockInstance(AuthRolesService); test('should start a task runner when task runners are enabled', async () => { // arrange diff --git a/packages/cli/src/commands/base-command.ts b/packages/cli/src/commands/base-command.ts index 0b52098eeb..b75d9684e6 100644 --- a/packages/cli/src/commands/base-command.ts +++ b/packages/cli/src/commands/base-command.ts @@ -9,7 +9,7 @@ import { } from '@n8n/backend-common'; import { GlobalConfig } from '@n8n/config'; import { LICENSE_FEATURES } from '@n8n/constants'; -import { DbConnection } from '@n8n/db'; +import { AuthRolesService, DbConnection } from '@n8n/db'; import { Container } from '@n8n/di'; import { BinaryDataConfig, @@ -121,6 +121,9 @@ export abstract class BaseCommand { 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(); if (process.env.EXECUTIONS_PROCESS === 'own') process.exit(-1); diff --git a/packages/cli/src/commands/import/credentials.ts b/packages/cli/src/commands/import/credentials.ts index 673bafe476..b41e64c094 100644 --- a/packages/cli/src/commands/import/credentials.ts +++ b/packages/cli/src/commands/import/credentials.ts @@ -8,6 +8,7 @@ import { } from '@n8n/db'; import { Command } from '@n8n/decorators'; import { Container } from '@n8n/di'; +import { PROJECT_OWNER_ROLE_SLUG } from '@n8n/permissions'; // eslint-disable-next-line n8n-local-rules/misplaced-n8n-typeorm-import import type { EntityManager } from '@n8n/typeorm'; import glob from 'fast-glob'; @@ -17,10 +18,10 @@ import type { ICredentialsEncrypted } from 'n8n-workflow'; import { jsonParse, UserError } from 'n8n-workflow'; import { z } from 'zod'; -import { UM_FIX_INSTRUCTION } from '@/constants'; - import { BaseCommand } from '../base-command'; +import { UM_FIX_INSTRUCTION } from '@/constants'; + const flagsSchema = z.object({ input: z .string() @@ -239,7 +240,7 @@ export class ImportCredentialsCommand extends BaseCommand> { await super.init(); - await Container.get(AuthRolesService).init(); - this.activeWorkflowManager = Container.get(ActiveWorkflowManager); const isMultiMainEnabled = diff --git a/packages/cli/src/controllers/project.controller.ts b/packages/cli/src/controllers/project.controller.ts index e873856031..a91cef1566 100644 --- a/packages/cli/src/controllers/project.controller.ts +++ b/packages/cli/src/controllers/project.controller.ts @@ -14,12 +14,7 @@ import { Param, Query, } from '@n8n/decorators'; -import { - combineScopes, - getAuthPrincipalScopes, - getRoleScopes, - hasGlobalScope, -} from '@n8n/permissions'; +import { combineScopes, getAuthPrincipalScopes, hasGlobalScope } from '@n8n/permissions'; import type { Scope } from '@n8n/permissions'; // eslint-disable-next-line n8n-local-rules/misplaced-n8n-typeorm-import import { In, Not } from '@n8n/typeorm'; @@ -69,13 +64,18 @@ export class ProjectController { uiContext: payload.uiContext, }); + const relations = await this.projectsService.getProjectRelations(project.id); + return { ...project, role: 'project:admin', scopes: [ ...combineScopes({ 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) { const result: ProjectRequest.GetMyProjectsResponse[number] = Object.assign( this.projectRepository.create(pr.project), - { role: pr.role, scopes: [] }, + { role: pr.role.slug, scopes: [] }, ); if (result.scopes) { result.scopes.push( ...combineScopes({ 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) { throw new NotFoundError('Could not find a personal project for this user'); } + + const relations = await this.projectsService.getProjectRelations(project.id); const scopes: Scope[] = [ ...combineScopes({ global: getAuthPrincipalScopes(req.user), - project: getRoleScopes('project:personalOwner'), + project: + relations + .find((pr) => pr.userId === req.user.id) + ?.role.scopes.map((scope) => scope.slug) ?? [], }), ]; return { @@ -191,12 +196,12 @@ export class ProjectController { email: r.user.email, firstName: r.user.firstName, lastName: r.user.lastName, - role: r.role, + role: r.role.slug, })), scopes: [ ...combineScopes({ global: getAuthPrincipalScopes(req.user), - ...(myRelation ? { project: getRoleScopes(myRelation.role) } : {}), + ...(myRelation ? { project: myRelation.role.scopes.map((scope) => scope.slug) } : {}), }), ], }; diff --git a/packages/cli/src/controllers/users.controller.ts b/packages/cli/src/controllers/users.controller.ts index 0b8fe925dc..1942c75072 100644 --- a/packages/cli/src/controllers/users.controller.ts +++ b/packages/cli/src/controllers/users.controller.ts @@ -124,7 +124,7 @@ export class UsersController { ...user, projectRelations: u.projectRelations?.map((pr) => ({ id: pr.projectId, - role: pr.role, // normalize role for frontend + role: pr.role.slug, // normalize role for frontend name: pr.project.name, })), }; diff --git a/packages/cli/src/credentials-helper.ts b/packages/cli/src/credentials-helper.ts index 45609a3daf..74f9239478 100644 --- a/packages/cli/src/credentials-helper.ts +++ b/packages/cli/src/credentials-helper.ts @@ -3,8 +3,14 @@ /* eslint-disable @typescript-eslint/no-unsafe-assignment */ /* eslint-disable @typescript-eslint/no-unsafe-return */ 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 { PROJECT_ADMIN_ROLE_SLUG, PROJECT_OWNER_ROLE_SLUG } from '@n8n/permissions'; // eslint-disable-next-line n8n-local-rules/misplaced-n8n-typeorm-import import { EntityNotFoundError, In } from '@n8n/typeorm'; import { Credentials, getAdditionalKeys } from 'n8n-core'; @@ -496,9 +502,9 @@ export class CredentialsHelper extends ICredentialsHelper { role: 'credential:owner', project: { projectRelations: { - role: In(['project:personalOwner', 'project:admin']), + role: { slug: In([PROJECT_OWNER_ROLE_SLUG, PROJECT_ADMIN_ROLE_SLUG]) }, user: { - role: In(['global:owner', 'global:admin']), + role: { slug: In([GLOBAL_OWNER_ROLE.slug, GLOBAL_ADMIN_ROLE.slug]) }, }, }, }, diff --git a/packages/cli/src/credentials/credentials.controller.ts b/packages/cli/src/credentials/credentials.controller.ts index 53dab5bba9..709a8d85fc 100644 --- a/packages/cli/src/credentials/credentials.controller.ts +++ b/packages/cli/src/credentials/credentials.controller.ts @@ -25,12 +25,17 @@ import { Param, Query, } from '@n8n/decorators'; +import { PROJECT_OWNER_ROLE_SLUG } from '@n8n/permissions'; // eslint-disable-next-line n8n-local-rules/misplaced-n8n-typeorm-import import { In } from '@n8n/typeorm'; import { deepCopy } from 'n8n-workflow'; import type { ICredentialDataDecryptedObject } from 'n8n-workflow'; 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 { ForbiddenError } from '@/errors/response-errors/forbidden.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 * as utils from '@/utils'; -import { CredentialsFinderService } from './credentials-finder.service'; -import { CredentialsService } from './credentials.service'; -import { EnterpriseCredentialsService } from './credentials.service.ee'; - @RestController('/credentials') export class CredentialsController { constructor( @@ -364,7 +365,7 @@ export class CredentialsController { const projectsRelations = await this.projectRelationRepository.findBy({ projectId: In(newShareeIds), - role: 'project:personalOwner', + role: { slug: PROJECT_OWNER_ROLE_SLUG }, }); await this.userManagementMailer.notifyCredentialsShared({ diff --git a/packages/cli/src/credentials/credentials.service.ts b/packages/cli/src/credentials/credentials.service.ts index 5b8ea9fea3..1f01c2f5ce 100644 --- a/packages/cli/src/credentials/credentials.service.ts +++ b/packages/cli/src/credentials/credentials.service.ts @@ -10,7 +10,7 @@ import { UserRepository, } from '@n8n/db'; 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 import { In, @@ -27,6 +27,8 @@ import type { } 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 { CredentialTypes } from '@/credential-types'; import { createCredentialsFromCredentialsEntity } from '@/credentials-helper'; @@ -38,12 +40,9 @@ import { userHasScopes } from '@/permissions.ee/check-access'; import type { CredentialRequest, ListQuery } from '@/requests'; import { CredentialsTester } from '@/services/credentials-tester.service'; import { OwnershipService } from '@/services/ownership.service'; -// eslint-disable-next-line import-x/no-cycle import { ProjectService } from '@/services/project.service.ee'; import { RoleService } from '@/services/role.service'; -import { CredentialsFinderService } from './credentials-finder.service'; - export type CredentialsGetSharedOptions = | { allowGlobalScope: true; globalScope: Scope } | { allowGlobalScope: false }; @@ -300,7 +299,7 @@ export class CredentialsService { role: 'credential:owner', project: { projectRelations: { - role: 'project:personalOwner', + role: { slug: PROJECT_OWNER_ROLE_SLUG }, userId: user.id, }, }, diff --git a/packages/cli/src/environments.ee/source-control/__tests__/source-control-export.service.test.ts b/packages/cli/src/environments.ee/source-control/__tests__/source-control-export.service.test.ts index 039674a0a2..0ad2df5bf3 100644 --- a/packages/cli/src/environments.ee/source-control/__tests__/source-control-export.service.test.ts +++ b/packages/cli/src/environments.ee/source-control/__tests__/source-control-export.service.test.ts @@ -1,5 +1,5 @@ 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 { SharedCredentials, SharedWorkflow, @@ -83,7 +83,7 @@ describe('SourceControlExportService', () => { type: 'personal', projectRelations: [ { - role: 'project:personalOwner', + role: PROJECT_OWNER_ROLE, user: mock({ email: 'user@example.com' }), }, ], @@ -268,7 +268,7 @@ describe('SourceControlExportService', () => { mock({ project: mock({ type: 'personal', - projectRelations: [{ role: 'project:personalOwner', user: mock() }], + projectRelations: [{ role: PROJECT_OWNER_ROLE, user: mock() }], }), workflow: mock(), }), diff --git a/packages/cli/src/environments.ee/source-control/__tests__/source-control-import.service.ee.test.ts b/packages/cli/src/environments.ee/source-control/__tests__/source-control-import.service.ee.test.ts index b4c170fdc8..b0e5b36f40 100644 --- a/packages/cli/src/environments.ee/source-control/__tests__/source-control-import.service.ee.test.ts +++ b/packages/cli/src/environments.ee/source-control/__tests__/source-control-import.service.ee.test.ts @@ -80,7 +80,7 @@ describe('SourceControlImportService', () => { }; fsReadFile.mockResolvedValue(JSON.stringify(mockWorkflowData)); - sourceControlScopedService.getAdminProjectsFromContext.mockResolvedValueOnce([]); + sourceControlScopedService.getAuthorizedProjectsFromContext.mockResolvedValueOnce([]); const result = await service.getRemoteVersionIdsFromFiles(globalAdminContext); expect(fsReadFile).toHaveBeenCalledWith(mockWorkflowFile, { encoding: 'utf8' }); @@ -312,7 +312,7 @@ describe('SourceControlImportService', () => { ], }; - sourceControlScopedService.getAdminProjectsFromContext.mockResolvedValue([ + sourceControlScopedService.getAuthorizedProjectsFromContext.mockResolvedValue([ Object.assign(new Project(), { id: 'project1', }), diff --git a/packages/cli/src/environments.ee/source-control/source-control-export.service.ee.ts b/packages/cli/src/environments.ee/source-control/source-control-export.service.ee.ts index cc2be240c1..e8646e0e38 100644 --- a/packages/cli/src/environments.ee/source-control/source-control-export.service.ee.ts +++ b/packages/cli/src/environments.ee/source-control/source-control-export.service.ee.ts @@ -10,6 +10,7 @@ import { WorkflowRepository, } from '@n8n/db'; import { Service } from '@n8n/di'; +import { PROJECT_OWNER_ROLE_SLUG } from '@n8n/permissions'; // eslint-disable-next-line n8n-local-rules/misplaced-n8n-typeorm-import import { In } from '@n8n/typeorm'; 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 path from 'path'; -import { formatWorkflow } from '@/workflows/workflow.formatter'; - import { SOURCE_CONTROL_CREDENTIAL_EXPORT_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 { VariablesService } from '../variables/variables.service.ee'; +import { formatWorkflow } from '@/workflows/workflow.formatter'; + @Service() export class SourceControlExportService { private gitFolder: string; @@ -145,7 +146,7 @@ export class SourceControlExportService { if (project.type === 'personal') { const ownerRelation = project.projectRelations.find( - (pr) => pr.role === 'project:personalOwner', + (pr) => pr.role.slug === PROJECT_OWNER_ROLE_SLUG, ); if (!ownerRelation) { throw new UnexpectedError( @@ -251,7 +252,7 @@ export class SourceControlExportService { } const allowedProjects = - await this.sourceControlScopedService.getAdminProjectsFromContext(context); + await this.sourceControlScopedService.getAuthorizedProjectsFromContext(context); const fileName = getFoldersPath(this.gitFolder); @@ -409,7 +410,7 @@ export class SourceControlExportService { let owner: RemoteResourceOwner | null = null; if (sharing.project.type === 'personal') { const ownerRelation = sharing.project.projectRelations.find( - (pr) => pr.role === 'project:personalOwner', + (pr) => pr.role.slug === PROJECT_OWNER_ROLE_SLUG, ); if (ownerRelation) { owner = { diff --git a/packages/cli/src/environments.ee/source-control/source-control-import.service.ee.ts b/packages/cli/src/environments.ee/source-control/source-control-import.service.ee.ts index 632e89a26e..1e2a461b92 100644 --- a/packages/cli/src/environments.ee/source-control/source-control-import.service.ee.ts +++ b/packages/cli/src/environments.ee/source-control/source-control-import.service.ee.ts @@ -15,6 +15,7 @@ import { UserRepository, } from '@n8n/db'; import { Service } from '@n8n/di'; +import { PROJECT_OWNER_ROLE_SLUG } from '@n8n/permissions'; // eslint-disable-next-line n8n-local-rules/misplaced-n8n-typeorm-import import { In } from '@n8n/typeorm'; 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 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 { SOURCE_CONTROL_CREDENTIAL_EXPORT_FOLDER, 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 { 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 = ( owner: RemoteResourceOwner, accessibleProjects: Project[], @@ -59,7 +60,7 @@ const findOwnerProject = ( if (typeof owner === 'string') { return accessibleProjects.find((project) => 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.type === 'personal' && 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') { const personalEmail = remoteOwnerProject.projectRelations?.find( - (r) => r.role === 'project:personalOwner', + (r) => r.role.slug === PROJECT_OWNER_ROLE_SLUG, )?.user?.email; if (personalEmail) { @@ -148,7 +149,7 @@ export class SourceControlImportService { }); const accessibleProjects = - await this.sourceControlScopedService.getAdminProjectsFromContext(context); + await this.sourceControlScopedService.getAuthorizedProjectsFromContext(context); const remoteWorkflowsRead = await Promise.all( remoteWorkflowFiles.map(async (file) => { @@ -251,7 +252,9 @@ export class SourceControlImportService { name: true, type: true, projectRelations: { - role: true, + role: { + slug: true, + }, user: { email: true, }, @@ -300,7 +303,7 @@ export class SourceControlImportService { }); const accessibleProjects = - await this.sourceControlScopedService.getAdminProjectsFromContext(context); + await this.sourceControlScopedService.getAuthorizedProjectsFromContext(context); const remoteCredentialFilesRead = await Promise.all( remoteCredentialFiles.map(async (file) => { @@ -368,7 +371,9 @@ export class SourceControlImportService { name: true, type: true, projectRelations: { - role: true, + role: { + slug: true, + }, user: { email: true, }, @@ -426,7 +431,7 @@ export class SourceControlImportService { }); const accessibleProjects = - await this.sourceControlScopedService.getAdminProjectsFromContext(context); + await this.sourceControlScopedService.getAuthorizedProjectsFromContext(context); mappedFolders.folders = mappedFolders.folders.filter( (folder) => diff --git a/packages/cli/src/environments.ee/source-control/source-control-scoped.service.ts b/packages/cli/src/environments.ee/source-control/source-control-scoped.service.ts index 457ef9b9e6..a9dcf2dd6b 100644 --- a/packages/cli/src/environments.ee/source-control/source-control-scoped.service.ts +++ b/packages/cli/src/environments.ee/source-control/source-control-scoped.service.ts @@ -29,20 +29,21 @@ export class SourceControlScopedService { } const ctx = new SourceControlContext(req.user); - const projectsWithAdminAccess = await this.getAdminProjectsFromContext(ctx); + const projectsWithAdminAccess = await this.getAuthorizedProjectsFromContext(ctx); if (projectsWithAdminAccess?.length === 0) { throw new ForbiddenError('You are not allowed to push changes'); } } - async getAdminProjectsFromContext(context: SourceControlContext): Promise { + async getAuthorizedProjectsFromContext(context: SourceControlContext): Promise { if (context.hasAccessToAllProjects()) { // In case the user is a global admin or owner, we don't need a filter return await this.projectRepository.find({ relations: { projectRelations: { user: true, + role: true, }, }, }); @@ -52,6 +53,7 @@ export class SourceControlScopedService { relations: { projectRelations: { user: true, + role: true, }, }, select: { @@ -59,7 +61,7 @@ export class SourceControlScopedService { name: true, type: true, }, - where: this.getAdminProjectsByContextFilter(context), + where: this.getProjectsWithPushScopeByContextFilter(context), }); } @@ -85,7 +87,7 @@ export class SourceControlScopedService { }); } - getAdminProjectsByContextFilter( + getProjectsWithPushScopeByContextFilter( context: SourceControlContext, ): FindOptionsWhere | undefined { if (context.hasAccessToAllProjects()) { @@ -96,7 +98,11 @@ export class SourceControlScopedService { return { type: 'team', projectRelations: { - role: 'project:admin', + role: { + scopes: { + slug: 'sourceControl:push', + }, + }, 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 // that the user is an admin off return { - homeProject: this.getAdminProjectsByContextFilter(context), + homeProject: this.getProjectsWithPushScopeByContextFilter(context), }; } @@ -130,7 +136,7 @@ export class SourceControlScopedService { return { shared: { role: 'workflow:owner', - project: this.getAdminProjectsByContextFilter(context), + project: this.getProjectsWithPushScopeByContextFilter(context), }, }; } @@ -148,7 +154,7 @@ export class SourceControlScopedService { return { shared: { role: 'credential:owner', - project: this.getAdminProjectsByContextFilter(context), + project: this.getProjectsWithPushScopeByContextFilter(context), }, }; } diff --git a/packages/cli/src/events/relays/telemetry.event-relay.ts b/packages/cli/src/events/relays/telemetry.event-relay.ts index 0f0e159a68..59a72d2770 100644 --- a/packages/cli/src/events/relays/telemetry.event-relay.ts +++ b/packages/cli/src/events/relays/telemetry.event-relay.ts @@ -6,6 +6,7 @@ import { WorkflowRepository, } from '@n8n/db'; import { Service } from '@n8n/di'; +import { PROJECT_OWNER_ROLE_SLUG } from '@n8n/permissions'; import { snakeCase } from 'change-case'; import { BinaryDataConfig, InstanceSettings } from 'n8n-core'; import type { ExecutionStatus, INodesGraphResult, ITelemetryTrackProperties } from 'n8n-workflow'; @@ -13,6 +14,9 @@ import { TelemetryHelpers } from 'n8n-workflow'; import os from 'node:os'; import { get as pslGet } from 'psl'; +import { EventRelay } from './event-relay'; +import { Telemetry } from '../../telemetry'; + import config from '@/config'; import { N8N_VERSION } from '@/constants'; import { EventService } from '@/events/event.service'; @@ -22,9 +26,6 @@ import type { IExecutionTrackProperties } from '@/interfaces'; import { License } from '@/license'; import { NodeTypes } from '@/node-types'; -import { EventRelay } from './event-relay'; -import { Telemetry } from '../../telemetry'; - @Service() export class TelemetryEventRelay extends EventRelay { constructor( @@ -604,7 +605,7 @@ export class TelemetryEventRelay extends EventRelay { projectId: workflowOwner.id, }); - if (projectRole && projectRole !== 'project:personalOwner') { + if (projectRole && projectRole?.slug !== PROJECT_OWNER_ROLE_SLUG) { userRole = 'member'; } } diff --git a/packages/cli/src/executions/executions.controller.ts b/packages/cli/src/executions/executions.controller.ts index 7bc336cd2e..382fcc1718 100644 --- a/packages/cli/src/executions/executions.controller.ts +++ b/packages/cli/src/executions/executions.controller.ts @@ -1,12 +1,6 @@ import type { User, ExecutionSummaries } from '@n8n/db'; import { Get, Patch, Post, RestController } from '@n8n/decorators'; -import 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 { PROJECT_OWNER_ROLE_SLUG, type Scope } from '@n8n/permissions'; import { ExecutionService } from './execution.service'; import { EnterpriseExecutionsService } from './execution.service.ee'; @@ -14,6 +8,12 @@ import { ExecutionRequest } from './execution.types'; import { parseRangeQuery } from './parse-range-query.middleware'; 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') export class ExecutionsController { constructor( @@ -29,7 +29,7 @@ export class ExecutionsController { } else { return await this.workflowSharingService.getSharedWorkflowIds(user, { workflowRoles: ['workflow:owner'], - projectRoles: ['project:personalOwner'], + projectRoles: [PROJECT_OWNER_ROLE_SLUG], }); } } diff --git a/packages/cli/src/modules/data-table/__tests__/data-store-aggregate.service.test.ts b/packages/cli/src/modules/data-table/__tests__/data-store-aggregate.service.test.ts index 7a6c60f9c7..a0f13d4774 100644 --- a/packages/cli/src/modules/data-table/__tests__/data-store-aggregate.service.test.ts +++ b/packages/cli/src/modules/data-table/__tests__/data-store-aggregate.service.test.ts @@ -1,10 +1,12 @@ import { createTeamProject, testDb, testModules } from '@n8n/backend-test-utils'; import { + type Role, GLOBAL_MEMBER_ROLE, GLOBAL_OWNER_ROLE, ProjectRelationRepository, type Project, type User, + PROJECT_ADMIN_ROLE, } from '@n8n/db'; import { Container } from '@n8n/di'; import type { EntityManager } from '@n8n/typeorm'; @@ -71,7 +73,7 @@ describe('dataStoreAggregate', () => { { userId: user.id, projectId: project1.id, - role: 'project:admin', + role: PROJECT_ADMIN_ROLE, user, project: project1, createdAt: new Date(), @@ -81,7 +83,7 @@ describe('dataStoreAggregate', () => { { userId: user.id, projectId: project2.id, - role: 'project:viewer', + role: { slug: 'project:viewer' } as Role, user, project: project2, createdAt: new Date(), @@ -147,7 +149,7 @@ describe('dataStoreAggregate', () => { { userId: user.id, projectId: project1.id, - role: 'project:admin', + role: PROJECT_ADMIN_ROLE, user, project: project1, createdAt: new Date(), @@ -157,7 +159,7 @@ describe('dataStoreAggregate', () => { { userId: user.id, projectId: project2.id, - role: 'project:viewer', + role: { slug: 'project:viewer' } as Role, user, project: project2, createdAt: new Date(), @@ -196,7 +198,7 @@ describe('dataStoreAggregate', () => { { userId: user.id, projectId: project1.id, - role: 'project:admin', + role: PROJECT_ADMIN_ROLE, user, project: project1, createdAt: new Date(), diff --git a/packages/cli/src/permissions.ee/__tests__/check-access.test.ts b/packages/cli/src/permissions.ee/__tests__/check-access.test.ts index d60620a69d..5eb14e023f 100644 --- a/packages/cli/src/permissions.ee/__tests__/check-access.test.ts +++ b/packages/cli/src/permissions.ee/__tests__/check-access.test.ts @@ -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( ProjectRepository, mock({ - find: jest.fn().mockResolvedValue([ - { - id: 'projectId', - projectRelations: [{ userId: 'userId', role: 'project:admin' }], - }, - ]), + createQueryBuilder: jest.fn().mockReturnValue(mockQueryBuilder), }), ); }); diff --git a/packages/cli/src/permissions.ee/check-access.ts b/packages/cli/src/permissions.ee/check-access.ts index 545952efb3..465775404c 100644 --- a/packages/cli/src/permissions.ee/check-access.ts +++ b/packages/cli/src/permissions.ee/check-access.ts @@ -2,8 +2,6 @@ import type { User } from '@n8n/db'; import { ProjectRepository, SharedCredentialsRepository, SharedWorkflowRepository } from '@n8n/db'; import { Container } from '@n8n/di'; 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 { NotFoundError } from '@/errors/response-errors/not-found.error'; @@ -30,21 +28,21 @@ export async function userHasScopes( if (globalOnly) return false; - // Find which project roles are defined to contain the required scopes. - // Then find projects having this user and having those project roles. - - const projectRoles = rolesWithScope('project', scopes); + // Find which projects the user has access to with the required scopes. + // This is done by finding the projects where the user has a role with at least the required scopes const userProjectIds = ( - await Container.get(ProjectRepository).find({ - where: { - projectRelations: { - userId: user.id, - role: In(projectRoles), - }, - }, - select: ['id'], - }) - ).map((p) => p.id); + await Container.get(ProjectRepository) + .createQueryBuilder('project') + .innerJoin('project.projectRelations', 'relation') + .innerJoin('relation.role', 'role') + .innerJoin('role.scopes', 'scope') + .where('relation.userId = :userId', { userId: user.id }) + .andWhere('scope.slug IN (:...scopes)', { scopes }) + .groupBy('project.id') + .having('COUNT(DISTINCT scope.slug) = :scopeCount', { scopeCount: scopes.length }) + .select(['project.id AS id']) + .getRawMany() + ).map((row: { id: string }) => row.id); // Find which resource roles are defined to contain the required scopes. // Then find at least one of the above qualifying projects having one of diff --git a/packages/cli/src/public-api/v1/handlers/workflows/workflows.service.ts b/packages/cli/src/public-api/v1/handlers/workflows/workflows.service.ts index f396d3ef50..bc562d5ceb 100644 --- a/packages/cli/src/public-api/v1/handlers/workflows/workflows.service.ts +++ b/packages/cli/src/public-api/v1/handlers/workflows/workflows.service.ts @@ -9,7 +9,7 @@ import { WorkflowRepository, } from '@n8n/db'; 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 { License } from '@/license'; @@ -32,7 +32,7 @@ export async function getSharedWorkflowIds( } else { return await Container.get(WorkflowSharingService).getSharedWorkflowIds(user, { workflowRoles: ['workflow:owner'], - projectRoles: ['project:personalOwner'], + projectRoles: [PROJECT_OWNER_ROLE_SLUG], projectId, }); } diff --git a/packages/cli/src/requests.ts b/packages/cli/src/requests.ts index 7d64f06f26..2621d28508 100644 --- a/packages/cli/src/requests.ts +++ b/packages/cli/src/requests.ts @@ -8,7 +8,13 @@ import type { ListQueryDb, WorkflowHistory, } from '@n8n/db'; -import type { AssignableGlobalRole, ProjectRole, Scope } from '@n8n/permissions'; +import type { + AssignableGlobalRole, + CustomRole, + GlobalRole, + ProjectRole, + Scope, +} from '@n8n/permissions'; import type { ICredentialDataDecryptedObject, INodeCredentialTestRequest, @@ -268,14 +274,16 @@ export declare namespace ActiveWorkflowRequest { // ---------------------------------- export declare namespace ProjectRequest { - type GetMyProjectsResponse = Array; + type GetMyProjectsResponse = Array< + Project & { role: ProjectRole | GlobalRole | CustomRole; scopes?: Scope[] } + >; type ProjectRelationResponse = { id: string; email: string; firstName: string; lastName: string; - role: ProjectRole; + role: ProjectRole | CustomRole; }; type ProjectWithRelations = { id: string; diff --git a/packages/cli/src/services/__tests__/credentials-finder.service.test.ts b/packages/cli/src/services/__tests__/credentials-finder.service.test.ts index 18c5cb1e0c..4eace74bf7 100644 --- a/packages/cli/src/services/__tests__/credentials-finder.service.test.ts +++ b/packages/cli/src/services/__tests__/credentials-finder.service.test.ts @@ -1,11 +1,17 @@ import { GLOBAL_MEMBER_ROLE, GLOBAL_OWNER_ROLE, SharedCredentials } from '@n8n/db'; import type { CredentialsEntity, User } from '@n8n/db'; 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 { mockEntityManager } from '@test/mocking'; import { mock } from 'jest-mock-extended'; import { CredentialsFinderService } from '@/credentials/credentials-finder.service'; -import { mockEntityManager } from '@test/mocking'; describe('CredentialsFinderService', () => { const entityManager = mockEntityManager(SharedCredentials); @@ -56,10 +62,10 @@ describe('CredentialsFinderService', () => { project: { projectRelations: { role: In([ - 'project:admin', - 'project:personalOwner', - 'project:editor', - 'project:viewer', + PROJECT_ADMIN_ROLE_SLUG, + PROJECT_OWNER_ROLE_SLUG, + PROJECT_EDITOR_ROLE_SLUG, + PROJECT_VIEWER_ROLE_SLUG, ]), userId: member.id, }, @@ -84,10 +90,10 @@ describe('CredentialsFinderService', () => { project: { projectRelations: { role: In([ - 'project:admin', - 'project:personalOwner', - 'project:editor', - 'project:viewer', + PROJECT_ADMIN_ROLE_SLUG, + PROJECT_OWNER_ROLE_SLUG, + PROJECT_EDITOR_ROLE_SLUG, + PROJECT_VIEWER_ROLE_SLUG, ]), userId: member.id, }, diff --git a/packages/cli/src/services/__tests__/ownership.service.test.ts b/packages/cli/src/services/__tests__/ownership.service.test.ts index 3702f7fd0a..824e2756d1 100644 --- a/packages/cli/src/services/__tests__/ownership.service.test.ts +++ b/packages/cli/src/services/__tests__/ownership.service.test.ts @@ -10,7 +10,9 @@ import { SharedWorkflowRepository, UserRepository, GLOBAL_OWNER_ROLE, + PROJECT_OWNER_ROLE, } from '@n8n/db'; +import { PROJECT_OWNER_ROLE_SLUG } from '@n8n/permissions'; import { v4 as uuid } from 'uuid'; import { OwnershipService } from '@/services/ownership.service'; @@ -64,7 +66,7 @@ describe('OwnershipService', () => { const owner = new User(); owner.role = GLOBAL_OWNER_ROLE; const projectRelation = new ProjectRelation(); - projectRelation.role = 'project:personalOwner'; + projectRelation.role = PROJECT_OWNER_ROLE; (projectRelation.project = project), (projectRelation.user = owner); projectRelationRepository.getPersonalProjectOwners.mockResolvedValueOnce([projectRelation]); @@ -92,7 +94,7 @@ describe('OwnershipService', () => { owner.id = uuid(); owner.role = GLOBAL_OWNER_ROLE; const projectRelation = new ProjectRelation(); - projectRelation.role = 'project:personalOwner'; + projectRelation.role = { slug: PROJECT_OWNER_ROLE_SLUG } as any; (projectRelation.project = project), (projectRelation.user = owner); cacheService.getHashValue.mockResolvedValueOnce(owner); @@ -116,7 +118,7 @@ describe('OwnershipService', () => { mockOwner.role = GLOBAL_OWNER_ROLE; const projectRelation = Object.assign(new ProjectRelation(), { - role: 'project:personalOwner', + role: PROJECT_OWNER_ROLE_SLUG, project: mockProject, user: mockOwner, }); diff --git a/packages/cli/src/services/__tests__/project.service.ee.test.ts b/packages/cli/src/services/__tests__/project.service.ee.test.ts index e3d08f86f0..e898a9cac7 100644 --- a/packages/cli/src/services/__tests__/project.service.ee.test.ts +++ b/packages/cli/src/services/__tests__/project.service.ee.test.ts @@ -1,12 +1,14 @@ import type { ProjectRelation } from '@n8n/api-types'; import type { DatabaseConfig } from '@n8n/config'; -import type { - Project, - ProjectRepository, - SharedCredentialsRepository, - ProjectRelationRepository, - SharedCredentials, +import { + type Project, + type ProjectRepository, + type SharedCredentialsRepository, + type ProjectRelationRepository, + type SharedCredentials, + PROJECT_ADMIN_ROLE, } from '@n8n/db'; +import { PROJECT_OWNER_ROLE_SLUG } from '@n8n/permissions'; import type { EntityManager } from '@n8n/typeorm'; import { mock } from 'jest-mock-extended'; @@ -58,7 +60,7 @@ describe('ProjectService', () => { // ACT & ASSERT await expect( 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."); }); @@ -100,7 +102,7 @@ describe('ProjectService', () => { expect(projectRepository.findOne).toHaveBeenCalledWith({ where: { id: projectId, type: 'team' }, - relations: { projectRelations: true }, + relations: { projectRelations: { role: true } }, }); expect(manager.delete).toHaveBeenCalled(); @@ -139,7 +141,7 @@ describe('ProjectService', () => { mock({ id: projectId, type: 'team', - projectRelations: [{ userId: 'user1', role: 'project:admin' }], + projectRelations: [{ userId: 'user1', role: PROJECT_ADMIN_ROLE }], }), ); roleService.isRoleLicensed.mockReturnValue(false); @@ -156,9 +158,9 @@ describe('ProjectService', () => { describe('changeUserRoleInProject', () => { const projectId = '12345'; - const mockRelations: ProjectRelation[] = [ - { userId: 'user1', role: 'project:admin' }, - { userId: 'user2', role: 'project:viewer' }, + const mockRelations = [ + { userId: 'user1', role: { slug: 'project:admin' } }, + { userId: 'user2', role: { slug: 'project:viewer' } }, ]; beforeEach(() => { @@ -185,12 +187,12 @@ describe('ProjectService', () => { expect(projectRepository.findOne).toHaveBeenCalledWith({ where: { id: projectId, type: 'team' }, - relations: { projectRelations: true }, + relations: { projectRelations: { role: true } }, }); expect(projectRelationRepository.update).toHaveBeenCalledWith( { projectId, userId: 'user2' }, - { role: 'project:admin' }, + { role: { slug: 'project:admin' } }, ); }); @@ -210,13 +212,13 @@ describe('ProjectService', () => { expect(projectRepository.findOne).toHaveBeenCalledWith({ 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 () => { 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.'); }); @@ -230,7 +232,7 @@ describe('ProjectService', () => { expect(projectRepository.findOne).toHaveBeenCalledWith({ where: { id: projectId, type: 'team' }, - relations: { projectRelations: true }, + relations: { projectRelations: { role: true } }, }); }); }); diff --git a/packages/cli/src/services/project.service.ee.ts b/packages/cli/src/services/project.service.ee.ts index 8e019a2fcc..ea014e7123 100644 --- a/packages/cli/src/services/project.service.ee.ts +++ b/packages/cli/src/services/project.service.ee.ts @@ -12,7 +12,15 @@ import { SharedWorkflowRepository, } from '@n8n/db'; 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 import type { FindOptionsWhere, EntityManager } from '@n8n/typeorm'; // 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 { RoleService } from './role.service'; -type Relation = Pick; - export class TeamProjectOverQuotaError extends UserError { constructor(limit: number) { super( @@ -37,7 +43,7 @@ export class TeamProjectOverQuotaError extends UserError { } export class UnlicensedProjectRoleError extends UserError { - constructor(role: ProjectRole) { + constructor(role: ProjectRole | CustomRole) { super(`Your instance is not licensed to use role "${role}".`); } } @@ -71,14 +77,12 @@ export class ProjectService { ) {} private get workflowService() { - // eslint-disable-next-line import-x/no-cycle return import('@/workflows/workflow.service').then(({ WorkflowService }) => Container.get(WorkflowService), ); } private get credentialsService() { - // eslint-disable-next-line import-x/no-cycle return import('@/credentials/credentials.service').then(({ CredentialsService }) => Container.get(CredentialsService), ); @@ -262,7 +266,7 @@ export class ProjectService { async getProjectRelationsForUser(user: User): Promise { return await this.projectRelationRepository.find({ 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 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); this.checkRolesLicensed(project, relations); @@ -300,31 +307,38 @@ export class ProjectService { 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."); } 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) { const project = await this.projectRepository.findOne({ where: { id: projectId, type: 'team' }, - relations: { projectRelations: true }, + relations: { projectRelations: { role: true } }, }); ProjectNotFoundError.isDefinedAndNotNull(project, projectId); return project; } /** Check to see if the instance is licensed to use all roles provided */ - private checkRolesLicensed(project: Project, relations: Relation[]) { + private checkRolesLicensed( + project: Project, + relations: Array<{ role: ProjectRole | CustomRole; userId: string }>, + ) { for (const { role, userId } of relations) { const existing = project.projectRelations.find((pr) => pr.userId === userId); // We don't throw an error if the user already exists with that role so // existing projects continue working as is. - if (existing?.role !== role && !this.roleService.isRoleLicensed(role)) { + if (existing?.role?.slug !== role && !this.roleService.isRoleLicensed(role)) { throw new UnlicensedProjectRoleError(role); } } @@ -332,7 +346,7 @@ export class ProjectService { private isUserProjectOwner(project: Project, userId: string) { 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) { - if (role === 'project:personalOwner') { + if (role === PROJECT_OWNER_ROLE_SLUG) { throw new ForbiddenError('Personal owner cannot be added to a team project.'); } @@ -360,7 +374,7 @@ export class ProjectService { throw new ProjectNotFoundError(projectId); } - await this.projectRelationRepository.update({ projectId, userId }, { role }); + await this.projectRelationRepository.update({ projectId, userId }, { role: { slug: role } }); } async clearCredentialCanUseExternalSecretsCache(projectId: string) { @@ -385,7 +399,7 @@ export class ProjectService { async addManyRelations( em: EntityManager, project: Project, - relations: Array<{ userId: string; role: ProjectRole }>, + relations: Array<{ userId: string; role: ProjectRole | CustomRole }>, ) { await em.insert( ProjectRelation, @@ -394,7 +408,7 @@ export class ProjectService { this.projectRelationRepository.create({ projectId: project.id, 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 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; return await trx.save(ProjectRelation, { projectId, userId, - role, + role: { slug: role }, }); } @@ -454,7 +472,7 @@ export class ProjectService { async getProjectRelations(projectId: string): Promise { return await this.projectRelationRepository.find({ where: { projectId }, - relations: { user: true }, + relations: { user: true, role: true }, }); } @@ -463,7 +481,7 @@ export class ProjectService { where: { projectRelations: { userId, - role: In(['project:personalOwner', 'project:admin']), + role: In([PROJECT_OWNER_ROLE_SLUG, PROJECT_ADMIN_ROLE_SLUG]), }, }, }); diff --git a/packages/cli/src/services/role.service.ts b/packages/cli/src/services/role.service.ts index f86aff0902..b636bfd4b3 100644 --- a/packages/cli/src/services/role.service.ts +++ b/packages/cli/src/services/role.service.ts @@ -8,7 +8,7 @@ import type { ProjectRelation, } from '@n8n/db'; 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 { UnexpectedError } from 'n8n-workflow'; @@ -97,7 +97,7 @@ export class RoleService { ); let projectScopes: Scope[] = []; if (pr) { - projectScopes = getRoleScopes(pr.role); + projectScopes = pr.role.scopes.map((s) => s.slug); } const resourceMask = getRoleScopes(sharedEntity.role); const mergedScopes = combineScopes( @@ -112,7 +112,7 @@ export class RoleService { return [...scopesSet].sort(); } - isRoleLicensed(role: AllRoleTypes) { + isRoleLicensed(role: ProjectRole | CustomRole) { // TODO: move this info into FrontendSettings switch (role) { case 'project:admin': @@ -124,6 +124,7 @@ export class RoleService { case 'global:admin': return this.license.isAdvancedPermissionsLicensed(); default: + // TODO: handle custom roles licensing return true; } } diff --git a/packages/cli/src/workflows/workflow-sharing.service.ts b/packages/cli/src/workflows/workflow-sharing.service.ts index 126162bab1..1ce3767757 100644 --- a/packages/cli/src/workflows/workflow-sharing.service.ts +++ b/packages/cli/src/workflows/workflow-sharing.service.ts @@ -7,6 +7,7 @@ import { type ProjectRole, type WorkflowSharingRole, type Scope, + PROJECT_OWNER_ROLE_SLUG, } from '@n8n/permissions'; // eslint-disable-next-line n8n-local-rules/misplaced-n8n-typeorm-import import { In } from '@n8n/typeorm'; @@ -74,7 +75,7 @@ export class WorkflowSharingService { project: { projectRelations: { userId: user.id, - role: 'project:personalOwner', + role: { slug: PROJECT_OWNER_ROLE_SLUG }, }, }, }, @@ -115,7 +116,7 @@ export class WorkflowSharingService { project: { projectRelations: { userId: user.id, - role: 'project:personalOwner', + role: { slug: PROJECT_OWNER_ROLE_SLUG }, }, }, }, diff --git a/packages/cli/src/workflows/workflow.service.ee.ts b/packages/cli/src/workflows/workflow.service.ee.ts index d8d229cc2f..c8085cbe55 100644 --- a/packages/cli/src/workflows/workflow.service.ee.ts +++ b/packages/cli/src/workflows/workflow.service.ee.ts @@ -23,6 +23,8 @@ import omit from 'lodash/omit'; import type { IWorkflowBase, WorkflowId } 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 { CredentialsFinderService } from '@/credentials/credentials-finder.service'; import { CredentialsService } from '@/credentials/credentials.service'; @@ -34,8 +36,6 @@ import { FolderService } from '@/services/folder.service'; import { OwnershipService } from '@/services/ownership.service'; import { ProjectService } from '@/services/project.service.ee'; -import { WorkflowFinderService } from './workflow-finder.service'; - @Service() export class EnterpriseWorkflowService { constructor( @@ -74,7 +74,7 @@ export class EnterpriseWorkflowService { ); 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. .map((project) => this.sharedWorkflowRepository.create({ diff --git a/packages/cli/src/workflows/workflows.controller.ts b/packages/cli/src/workflows/workflows.controller.ts index 3cfc129921..85abdbcfe8 100644 --- a/packages/cli/src/workflows/workflows.controller.ts +++ b/packages/cli/src/workflows/workflows.controller.ts @@ -30,6 +30,7 @@ import { Query, RestController, } from '@n8n/decorators'; +import { PROJECT_OWNER_ROLE_SLUG } from '@n8n/permissions'; // eslint-disable-next-line n8n-local-rules/misplaced-n8n-typeorm-import import { In, type FindOptionsRelations } from '@n8n/typeorm'; import axios from 'axios'; @@ -37,6 +38,14 @@ import express from 'express'; import { UnexpectedError } from 'n8n-workflow'; 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 { ForbiddenError } from '@/errors/response-errors/forbidden.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 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') export class WorkflowsController { constructor( @@ -535,7 +536,7 @@ export class WorkflowsController { const projectsRelations = await this.projectRelationRepository.findBy({ projectId: In(newShareeIds), - role: 'project:personalOwner', + role: { slug: PROJECT_OWNER_ROLE_SLUG }, }); await this.mailer.notifyWorkflowShared({ diff --git a/packages/cli/test/integration/api-keys.api.test.ts b/packages/cli/test/integration/api-keys.api.test.ts index 573c78308b..f80ea618c1 100644 --- a/packages/cli/test/integration/api-keys.api.test.ts +++ b/packages/cli/test/integration/api-keys.api.test.ts @@ -328,7 +328,6 @@ describe('Member', () => { .post('/api-keys') .send({ label: 'My API Key', expiresAt: null, scopes: ['workflow:create'] }); - console.log(newApiKeyResponse.body); expect(newApiKeyResponse.statusCode).toBe(200); expect(newApiKeyResponse.body.data.apiKey).toBeDefined(); expect(newApiKeyResponse.body.data.apiKey).not.toBeNull(); diff --git a/packages/cli/test/integration/controllers/invitation/invitation.controller.integration.test.ts b/packages/cli/test/integration/controllers/invitation/invitation.controller.integration.test.ts index 0101a847bb..ad05897c68 100644 --- a/packages/cli/test/integration/controllers/invitation/invitation.controller.integration.test.ts +++ b/packages/cli/test/integration/controllers/invitation/invitation.controller.integration.test.ts @@ -13,13 +13,9 @@ import { UserRepository, } from '@n8n/db'; import { Container } from '@n8n/di'; +import { PROJECT_OWNER_ROLE_SLUG } from '@n8n/permissions'; 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 { assertReturnedUserProps, assertStoredUserProps, @@ -29,6 +25,11 @@ import { createMember, createOwner, createUserShell } from '../../shared/db/user import * as utils from '../../shared/utils'; 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', () => { const mailer = mockInstance(UserManagementMailer); const externalHooks = mockInstance(ExternalHooks); @@ -296,7 +297,7 @@ describe('InvitationController', () => { const projectRelation = await projectRelationRepository.findOneOrFail({ where: { userId: storedUser.id, - role: 'project:personalOwner', + role: { slug: PROJECT_OWNER_ROLE_SLUG }, project: { type: 'personal', }, diff --git a/packages/cli/test/integration/project.api.test.ts b/packages/cli/test/integration/project.api.test.ts index a0bc1d4797..5b4a08c6c8 100644 --- a/packages/cli/test/integration/project.api.test.ts +++ b/packages/cli/test/integration/project.api.test.ts @@ -20,12 +20,14 @@ import { SharedWorkflowRepository, } from '@n8n/db'; 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 { 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 { @@ -36,6 +38,10 @@ import { import { createMember, createOwner, createUser } from './shared/db/users'; 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({ endpointGroups: ['project'], enabledFeatures: [ @@ -193,7 +199,7 @@ describe('GET /projects/my-projects', () => { [ personalProject1, { - role: 'project:personalOwner', + role: PROJECT_OWNER_ROLE_SLUG, scopes: ['project:list', 'project:read', 'credential:create'], }, ], @@ -270,7 +276,7 @@ describe('GET /projects/my-projects', () => { [ ownerProject, { - role: 'project:personalOwner', + role: PROJECT_OWNER_ROLE_SLUG, scopes: [ 'project:list', '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 === testUser2.id)).toBeUndefined(); - expect(tp1Relations.find((p) => p.userId === testUser1.id)?.role).toBe('project:admin'); - expect(tp1Relations.find((p) => p.userId === testUser3.id)?.role).toBe('project:editor'); - expect(tp1Relations.find((p) => p.userId === ownerUser.id)?.role).toBe('project:viewer'); + expect(tp1Relations.find((p) => p.userId === testUser1.id)?.role.slug).toBe('project:admin'); + expect(tp1Relations.find((p) => p.userId === testUser3.id)?.role.slug).toBe('project:editor'); + expect(tp1Relations.find((p) => p.userId === ownerUser.id)?.role.slug).toBe('project:viewer'); // 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 === testUser1.id)).toBeUndefined(); - expect(tp2Relations.find((p) => p.userId === testUser2.id)?.role).toBe('project:editor'); - expect(tp2Relations.find((p) => p.userId === ownerUser.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.slug).toBe('project:editor'); }); test.each([['project:viewer'], ['project:editor']] as const)( @@ -656,8 +662,14 @@ describe('PATCH /projects/:projectId', () => { expect(tp1Relations.length).toBe(2); expect(tp1Relations).toMatchObject( expect.arrayContaining([ - expect.objectContaining({ userId: actor.id, role }), - expect.objectContaining({ userId: projectEditor.id, role: 'project:editor' }), + expect.objectContaining({ + 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).toMatchObject( 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 === testUser2.id)).not.toBeUndefined(); - expect(tpRelations.find((p) => p.userId === testUser1.id)?.role).toBe('project:admin'); - expect(tpRelations.find((p) => p.userId === testUser2.id)?.role).toBe('project:admin'); - expect(tpRelations.find((p) => p.userId === testUser3.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?.slug).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 () => { @@ -768,9 +783,9 @@ describe('PATCH /projects/:projectId', () => { 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 === testUser3.id)).not.toBeUndefined(); - expect(tpRelations.find((p) => p.userId === testUser1.id)?.role).toBe('project:viewer'); - expect(tpRelations.find((p) => p.userId === testUser2.id)?.role).toBe('project:admin'); - expect(tpRelations.find((p) => p.userId === testUser3.id)?.role).toBe('project:admin'); + expect(tpRelations.find((p) => p.userId === testUser1.id)?.role?.slug).toBe('project:viewer'); + expect(tpRelations.find((p) => p.userId === testUser2.id)?.role?.slug).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 () => { @@ -782,7 +797,7 @@ describe('PATCH /projects/:projectId', () => { const resp = await memberAgent.patch(`/projects/${personalProject.id}`).send({ relations: [ - { userId: testUser1.id, role: 'project:personalOwner' }, + { userId: testUser1.id, role: PROJECT_OWNER_ROLE_SLUG }, { userId: testUser2.id, role: 'project:admin' }, ] as Array<{ userId: string; diff --git a/packages/cli/test/integration/project.service.integration.test.ts b/packages/cli/test/integration/project.service.integration.test.ts index 2208f3030b..a12b48725c 100644 --- a/packages/cli/test/integration/project.service.integration.test.ts +++ b/packages/cli/test/integration/project.service.integration.test.ts @@ -61,7 +61,7 @@ describe('ProjectService', () => { expect(relations[0]).toMatchObject({ projectId: project.id, userId: user.id, - role: 'project:admin', + role: { slug: 'project:admin' }, }); }); @@ -82,7 +82,7 @@ describe('ProjectService', () => { expect(relations[0]).toMatchObject({ projectId: project.id, userId: user.id, - role: 'project:editor', + role: { slug: 'project:editor' }, }); }); }); @@ -103,7 +103,7 @@ describe('ProjectService', () => { expect(relations[0]).toMatchObject({ projectId: project.id, userId: user.id, - role: 'project:admin', + role: { slug: 'project:admin' }, }); }); }); diff --git a/packages/cli/test/integration/public-api/projects.test.ts b/packages/cli/test/integration/public-api/projects.test.ts index 7b106829a0..199f7c1eae 100644 --- a/packages/cli/test/integration/public-api/projects.test.ts +++ b/packages/cli/test/integration/public-api/projects.test.ts @@ -564,23 +564,20 @@ describe('Projects in Public API', () => { expect(projectAfter.length).toEqual(3); const adminRelation = projectAfter.find( - (relation) => relation.userId === owner.id && relation.role === 'project:admin', - ); - expect(adminRelation).toEqual( - expect.objectContaining({ userId: owner.id, role: 'project:admin' }), + (relation) => relation.userId === owner.id && relation.role.slug === 'project:admin', ); + expect(adminRelation!.userId).toBe(owner.id); + expect(adminRelation!.role.slug).toBe('project:admin'); const viewerRelation = projectAfter.find( - (relation) => relation.userId === member.id && relation.role === 'project:viewer', - ); - expect(viewerRelation).toEqual( - expect.objectContaining({ userId: member.id, role: 'project:viewer' }), + (relation) => relation.userId === member.id && relation.role.slug === 'project:viewer', ); + expect(viewerRelation!.userId).toBe(member.id); + expect(viewerRelation!.role.slug).toBe('project:viewer'); const editorRelation = projectAfter.find( - (relation) => relation.userId === member2.id && relation.role === 'project:editor', - ); - expect(editorRelation).toEqual( - expect.objectContaining({ userId: member2.id, role: 'project:editor' }), + (relation) => relation.userId === member2.id && relation.role.slug === '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 () => { @@ -797,8 +794,12 @@ describe('Projects in Public API', () => { expect(response.status).toBe(204); expect(projectBefore.length).toEqual(2); - expect(projectBefore.find((p) => p.role === 'project:admin')?.userId).toEqual(owner.id); - expect(projectBefore.find((p) => p.role === 'project:viewer')?.userId).toEqual(member.id); + expect(projectBefore.find((p) => p.role.slug === 'project:admin')?.userId).toEqual( + owner.id, + ); + expect(projectBefore.find((p) => p.role.slug === 'project:viewer')?.userId).toEqual( + member.id, + ); expect(projectAfter.length).toEqual(1); expect(projectAfter[0].userId).toEqual(owner.id); diff --git a/packages/cli/test/integration/role.api.test.ts b/packages/cli/test/integration/role.api.test.ts index 3e6343feeb..606017ff93 100644 --- a/packages/cli/test/integration/role.api.test.ts +++ b/packages/cli/test/integration/role.api.test.ts @@ -1,4 +1,4 @@ -import { getRoleScopes } from '@n8n/permissions'; +import { getRoleScopes, PROJECT_OWNER_ROLE_SLUG } from '@n8n/permissions'; import type { GlobalRole, ProjectRole, @@ -76,8 +76,8 @@ beforeAll(async () => { expectedProjectRoles = [ { name: 'Project Owner', - role: 'project:personalOwner', - scopes: getRoleScopes('project:personalOwner'), + role: PROJECT_OWNER_ROLE_SLUG, + scopes: getRoleScopes(PROJECT_OWNER_ROLE_SLUG), licensed: true, description: 'Project Owner', }, diff --git a/packages/cli/test/integration/services/project.service.test.ts b/packages/cli/test/integration/services/project.service.test.ts index 69b817ae67..ce46330a33 100644 --- a/packages/cli/test/integration/services/project.service.test.ts +++ b/packages/cli/test/integration/services/project.service.test.ts @@ -1,12 +1,12 @@ import { testDb } from '@n8n/backend-test-utils'; import { ProjectRelationRepository, ProjectRepository } from '@n8n/db'; import { Container } from '@n8n/di'; -import type { ProjectRole, Scope } from '@n8n/permissions'; - -import { ProjectService } from '@/services/project.service.ee'; +import { PROJECT_OWNER_ROLE_SLUG, type ProjectRole, type Scope } from '@n8n/permissions'; import { createMember } from '../shared/db/users'; +import { ProjectService } from '@/services/project.service.ee'; + let projectRepository: ProjectRepository; let projectService: ProjectService; let projectRelationRepository: ProjectRelationRepository; @@ -33,7 +33,7 @@ describe('ProjectService', () => { 'project:viewer', 'project:admin', 'project:editor', - 'project:personalOwner', + PROJECT_OWNER_ROLE_SLUG, ] as ProjectRole[])( 'creates a relation between the user and the project using the role %s', async (role) => { @@ -57,7 +57,7 @@ describe('ProjectService', () => { // ASSERT // 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 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({ where: { userId: member.id, projectId: project.id }, + relations: { role: true }, }); 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', () => { 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 project = await projectRepository.save( @@ -233,7 +234,7 @@ describe('ProjectService', () => { await projectService.deleteUserFromProject(project.id, user.id); 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(); diff --git a/packages/cli/test/integration/users.api.test.ts b/packages/cli/test/integration/users.api.test.ts index 9569466f34..b041b36a25 100644 --- a/packages/cli/test/integration/users.api.test.ts +++ b/packages/cli/test/integration/users.api.test.ts @@ -1092,7 +1092,7 @@ describe('DELETE /users/:id', () => { id: teamProject.id, projectRelations: { userId: transferee.id, - role: 'project:editor', + role: { slug: 'project:editor' }, }, }), ).resolves.not.toBeNull(),