diff --git a/packages/@n8n/backend-test-utils/src/test-db.ts b/packages/@n8n/backend-test-utils/src/test-db.ts index 0f5798461a..1493c32fe1 100644 --- a/packages/@n8n/backend-test-utils/src/test-db.ts +++ b/packages/@n8n/backend-test-utils/src/test-db.ts @@ -1,6 +1,6 @@ import { GlobalConfig } from '@n8n/config'; import type { entities } from '@n8n/db'; -import { DbConnection, DbConnectionOptions } from '@n8n/db'; +import { AuthRolesService, DbConnection, DbConnectionOptions } from '@n8n/db'; import { Container } from '@n8n/di'; import type { DataSourceOptions } from '@n8n/typeorm'; import { DataSource as Connection } from '@n8n/typeorm'; @@ -50,6 +50,8 @@ export async function init() { const dbConnection = Container.get(DbConnection); await dbConnection.init(); await dbConnection.migrate(); + + await Container.get(AuthRolesService).init(); } export function isReady() { diff --git a/packages/@n8n/db/src/constants.ts b/packages/@n8n/db/src/constants.ts new file mode 100644 index 0000000000..6520148711 --- /dev/null +++ b/packages/@n8n/db/src/constants.ts @@ -0,0 +1,30 @@ +import { GLOBAL_SCOPE_MAP, type GlobalRole } from '@n8n/permissions'; + +import type { Role } from 'entities'; + +export function buildInRoleToRoleObject(role: GlobalRole): Role { + return { + slug: role, + displayName: role, + scopes: GLOBAL_SCOPE_MAP[role].map((scope) => { + return { + slug: scope, + displayName: scope, + description: null, + }; + }), + systemRole: true, + roleType: 'global', + description: `Built-in global role with ${role} permissions.`, + }; +} + +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 GLOBAL_ROLES: Record = { + 'global:owner': GLOBAL_OWNER_ROLE, + 'global:admin': GLOBAL_ADMIN_ROLE, + 'global:member': GLOBAL_MEMBER_ROLE, +}; diff --git a/packages/@n8n/db/src/entities/types-db.ts b/packages/@n8n/db/src/entities/types-db.ts index 7ac648655c..ed51b3d292 100644 --- a/packages/@n8n/db/src/entities/types-db.ts +++ b/packages/@n8n/db/src/entities/types-db.ts @@ -1,4 +1,4 @@ -import type { GlobalRole, Scope } from '@n8n/permissions'; +import type { Scope } from '@n8n/permissions'; import type { FindOperator } from '@n8n/typeorm'; import type express from 'express'; import type { @@ -105,7 +105,7 @@ export interface PublicUser { passwordResetToken?: string; createdAt: Date; isPending: boolean; - role?: GlobalRole; + role?: string; globalScopes?: Scope[]; signInType: AuthProviderType; disabled: boolean; diff --git a/packages/@n8n/db/src/entities/user.ts b/packages/@n8n/db/src/entities/user.ts index 6b103d4a20..f8c2fd4687 100644 --- a/packages/@n8n/db/src/entities/user.ts +++ b/packages/@n8n/db/src/entities/user.ts @@ -1,4 +1,4 @@ -import type { AuthPrincipal, GlobalRole } from '@n8n/permissions'; +import type { AuthPrincipal } from '@n8n/permissions'; import { AfterLoad, AfterUpdate, @@ -9,6 +9,8 @@ import { OneToMany, PrimaryGeneratedColumn, BeforeInsert, + JoinColumn, + ManyToOne, } from '@n8n/typeorm'; import type { IUser, IUserSettings } from 'n8n-workflow'; @@ -16,9 +18,11 @@ import { JsonColumn, WithTimestamps } from './abstract-entity'; import type { ApiKey } from './api-key'; import type { AuthIdentity } from './auth-identity'; import type { ProjectRelation } from './project-relation'; +import { Role } from './role'; import type { SharedCredentials } from './shared-credentials'; import type { SharedWorkflow } from './shared-workflow'; import type { IPersonalizationSurveyAnswers } from './types-db'; +import { GLOBAL_OWNER_ROLE } from '../constants'; import { isValidEmail } from '../utils/is-valid-email'; import { lowerCaser, objectRetriever } from '../utils/transformers'; @@ -53,8 +57,9 @@ export class User extends WithTimestamps implements IUser, AuthPrincipal { @JsonColumn({ nullable: true }) settings: IUserSettings | null; - @Column({ type: String }) - role: GlobalRole; + @ManyToOne(() => Role) + @JoinColumn({ name: 'roleSlug', referencedColumnName: 'slug' }) + role: Role; @OneToMany('AuthIdentity', 'user') authIdentities: AuthIdentity[]; @@ -108,7 +113,7 @@ export class User extends WithTimestamps implements IUser, AuthPrincipal { @AfterLoad() @AfterUpdate() computeIsPending(): void { - this.isPending = this.password === null && this.role !== 'global:owner'; + this.isPending = this.password === null && this.role?.slug !== GLOBAL_OWNER_ROLE.slug; } toJSON() { diff --git a/packages/@n8n/db/src/index.ts b/packages/@n8n/db/src/index.ts index b2b1bd9621..4090928a6c 100644 --- a/packages/@n8n/db/src/index.ts +++ b/packages/@n8n/db/src/index.ts @@ -16,6 +16,7 @@ export { separate } from './utils/separate'; export { sql } from './utils/sql'; export { idStringifier, lowerCaser, objectRetriever, sqlite } from './utils/transformers'; +export * from './constants'; export * from './entities'; export * from './entities/types-db'; export { NoXss } from './utils/validators/no-xss.validator'; diff --git a/packages/@n8n/db/src/migrations/common/1742918400000-AddScopesColumnToApiKeys.ts b/packages/@n8n/db/src/migrations/common/1742918400000-AddScopesColumnToApiKeys.ts index 94ed159710..7455bbf338 100644 --- a/packages/@n8n/db/src/migrations/common/1742918400000-AddScopesColumnToApiKeys.ts +++ b/packages/@n8n/db/src/migrations/common/1742918400000-AddScopesColumnToApiKeys.ts @@ -1,6 +1,7 @@ import type { GlobalRole } from '@n8n/permissions'; import { getApiKeyScopesForRole } from '@n8n/permissions'; +import { GLOBAL_ROLES } from '../../constants'; import { ApiKey } from '../../entities'; import type { MigrationContext, ReversibleMigration } from '../migration-types'; @@ -26,7 +27,10 @@ export class AddScopesColumnToApiKeys1742918400000 implements ReversibleMigratio ); for (const { id, role } of apiKeysWithRoles) { - const scopes = getApiKeyScopesForRole(role); + const dbRole = GLOBAL_ROLES[role]; + const scopes = getApiKeyScopesForRole({ + role: dbRole, + }); await queryRunner.manager.update(ApiKey, { id }, { scopes }); } } diff --git a/packages/@n8n/db/src/migrations/common/1750252139170-RemoveOldRoleColumn.ts b/packages/@n8n/db/src/migrations/common/1750252139170-RemoveOldRoleColumn.ts new file mode 100644 index 0000000000..1e9d9a5f9d --- /dev/null +++ b/packages/@n8n/db/src/migrations/common/1750252139170-RemoveOldRoleColumn.ts @@ -0,0 +1,48 @@ +import type { MigrationContext, ReversibleMigration } from '../migration-types'; + +/* + * This migration removes the old 'role' column from the 'user' table + * and ensures that all users have a valid role set in the 'roleSlug' column. + * It also ensures that the 'roleSlug' column is correctly populated with the + * values from the 'role' column before dropping it. + * This is a reversible migration, allowing the role column to be restored if needed. + */ +export class RemoveOldRoleColumn1750252139170 implements ReversibleMigration { + async up({ schemaBuilder: { dropColumns }, escape, runQuery }: MigrationContext) { + const roleTableName = escape.tableName('role'); + const userTableName = escape.tableName('user'); + const slugColumn = escape.columnName('slug'); + const roleColumn = escape.columnName('role'); + const roleSlugColumn = escape.columnName('roleSlug'); + + // Fallback to 'global:member' 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 ${userTableName} SET ${roleSlugColumn} = 'global:member', ${roleColumn} = 'global:member' WHERE NOT EXISTS (SELECT 1 FROM ${roleTableName} WHERE ${slugColumn} = ${roleColumn})`, + ); + + await runQuery( + `UPDATE ${userTableName} SET ${roleSlugColumn} = ${roleColumn} WHERE ${roleColumn} != ${roleSlugColumn}`, + ); + + await dropColumns('user', ['role']); + } + + async down({ schemaBuilder: { addColumns, column }, escape, runQuery }: MigrationContext) { + const userTableName = escape.tableName('user'); + const roleColumn = escape.columnName('role'); + const roleSlugColumn = escape.columnName('roleSlug'); + + await addColumns('user', [column('role').varchar(128).default("'global:member'").notNull]); + + await runQuery( + `UPDATE ${userTableName} SET ${roleColumn} = ${roleSlugColumn} WHERE ${roleSlugColumn} != ${roleColumn}`, + ); + + // Fallback to 'global:member' for users that do not have a correct role set + await runQuery( + `UPDATE ${userTableName} SET ${roleColumn} = 'global:member' WHERE NOT EXISTS (SELECT 1 FROM role WHERE slug = ${roleColumn})`, + ); + } +} diff --git a/packages/@n8n/db/src/migrations/mysqldb/index.ts b/packages/@n8n/db/src/migrations/mysqldb/index.ts index cedde7a623..c711a8a2c5 100644 --- a/packages/@n8n/db/src/migrations/mysqldb/index.ts +++ b/packages/@n8n/db/src/migrations/mysqldb/index.ts @@ -91,6 +91,7 @@ import { AddLastActiveAtColumnToUser1750252139166 } from '../common/175025213916 import { AddScopeTables1750252139166 } from '../common/1750252139166-AddScopeTables'; import { AddRolesTables1750252139167 } from '../common/1750252139167-AddRolesTables'; import { LinkRoleToUserTable1750252139168 } from '../common/1750252139168-LinkRoleToUserTable'; +import { RemoveOldRoleColumn1750252139170 } from '../common/1750252139170-RemoveOldRoleColumn'; import { AddInputsOutputsToTestCaseExecution1752669793000 } from '../common/1752669793000-AddInputsOutputsToTestCaseExecution'; import { CreateDataStoreTables1754475614601 } from '../common/1754475614601-CreateDataStoreTables'; import type { Migration } from '../migration-types'; @@ -193,4 +194,5 @@ export const mysqlMigrations: Migration[] = [ LinkRoleToUserTable1750252139168, AddInputsOutputsToTestCaseExecution1752669793000, CreateDataStoreTables1754475614601, + RemoveOldRoleColumn1750252139170, ]; diff --git a/packages/@n8n/db/src/migrations/postgresdb/index.ts b/packages/@n8n/db/src/migrations/postgresdb/index.ts index d85271a13c..125612d7a2 100644 --- a/packages/@n8n/db/src/migrations/postgresdb/index.ts +++ b/packages/@n8n/db/src/migrations/postgresdb/index.ts @@ -92,6 +92,7 @@ import { AddLastActiveAtColumnToUser1750252139166 } from '../common/175025213916 import { AddScopeTables1750252139166 } from '../common/1750252139166-AddScopeTables'; import { AddRolesTables1750252139167 } from '../common/1750252139167-AddRolesTables'; import { LinkRoleToUserTable1750252139168 } from '../common/1750252139168-LinkRoleToUserTable'; +import { RemoveOldRoleColumn1750252139170 } from '../common/1750252139170-RemoveOldRoleColumn'; import { CreateDataStoreTables1754475614601 } from '../common/1754475614601-CreateDataStoreTables'; import type { Migration } from '../migration-types'; @@ -191,4 +192,5 @@ export const postgresMigrations: Migration[] = [ LinkRoleToUserTable1750252139168, AddInputsOutputsToTestCaseExecution1752669793000, CreateDataStoreTables1754475614601, + RemoveOldRoleColumn1750252139170, ]; diff --git a/packages/@n8n/db/src/migrations/sqlite/index.ts b/packages/@n8n/db/src/migrations/sqlite/index.ts index f7a5d5e520..8b90716bc6 100644 --- a/packages/@n8n/db/src/migrations/sqlite/index.ts +++ b/packages/@n8n/db/src/migrations/sqlite/index.ts @@ -88,6 +88,7 @@ import { AddLastActiveAtColumnToUser1750252139166 } from '../common/175025213916 import { AddScopeTables1750252139166 } from '../common/1750252139166-AddScopeTables'; import { AddRolesTables1750252139167 } from '../common/1750252139167-AddRolesTables'; import { LinkRoleToUserTable1750252139168 } from '../common/1750252139168-LinkRoleToUserTable'; +import { RemoveOldRoleColumn1750252139170 } from '../common/1750252139170-RemoveOldRoleColumn'; import { AddInputsOutputsToTestCaseExecution1752669793000 } from '../common/1752669793000-AddInputsOutputsToTestCaseExecution'; import { CreateDataStoreTables1754475614601 } from '../common/1754475614601-CreateDataStoreTables'; import type { Migration } from '../migration-types'; @@ -185,6 +186,7 @@ const sqliteMigrations: Migration[] = [ LinkRoleToUserTable1750252139168, AddInputsOutputsToTestCaseExecution1752669793000, CreateDataStoreTables1754475614601, + RemoveOldRoleColumn1750252139170, ]; 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 27dc06931c..9ed236c0a0 100644 --- a/packages/@n8n/db/src/repositories/project-relation.repository.ts +++ b/packages/@n8n/db/src/repositories/project-relation.repository.ts @@ -16,7 +16,11 @@ export class ProjectRelationRepository extends Repository { projectId: In(projectIds), role: 'project:personalOwner', }, - relations: { user: true }, + relations: { + user: { + role: true, + }, + }, }); } diff --git a/packages/@n8n/db/src/repositories/user.repository.ts b/packages/@n8n/db/src/repositories/user.repository.ts index 328ff09b92..74f72d7314 100644 --- a/packages/@n8n/db/src/repositories/user.repository.ts +++ b/packages/@n8n/db/src/repositories/user.repository.ts @@ -55,19 +55,20 @@ export class UserRepository extends Repository { email, password: Not(IsNull()), }, - relations: ['authIdentities'], + relations: ['authIdentities', 'role'], }); } /** Counts the number of users in each role, e.g. `{ admin: 2, member: 6, owner: 1 }` */ async countUsersByRole() { + const escapedRoleSlug = this.manager.connection.driver.escape('roleSlug'); const rows = (await this.createQueryBuilder() - .select(['role', 'COUNT(role) as count']) - .groupBy('role') - .execute()) as Array<{ role: string; count: string }>; + .select([escapedRoleSlug, `COUNT(${escapedRoleSlug}) as count`]) + .groupBy(escapedRoleSlug) + .execute()) as Array<{ roleSlug: string; count: string }>; return rows.reduce( (acc, row) => { - acc[row.role] = parseInt(row.count, 10); + acc[row.roleSlug] = parseInt(row.count, 10); return acc; }, {} as Record, @@ -91,20 +92,25 @@ export class UserRepository extends Repository { const createInner = async (entityManager: EntityManager) => { const newUser = entityManager.create(User, user); const savedUser = await entityManager.save(newUser); + const userWithRole = await entityManager.findOne(User, { + where: { id: savedUser.id }, + relations: ['role'], + }); + if (!userWithRole) throw new Error('Failed to create user!'); const savedProject = await entityManager.save( entityManager.create(Project, { type: 'personal', - name: savedUser.createPersonalProjectName(), + name: userWithRole.createPersonalProjectName(), }), ); await entityManager.save( entityManager.create(ProjectRelation, { projectId: savedProject.id, - userId: savedUser.id, + userId: userWithRole.id, role: 'project:personalOwner', }), ); - return { user: savedUser, project: savedProject }; + return { user: userWithRole, project: savedProject }; }; if (transactionManager) { return await createInner(transactionManager); @@ -127,6 +133,7 @@ export class UserRepository extends Repository { project: { sharedWorkflows: { workflowId, role: 'workflow:owner' } }, }, }, + relations: ['role'], }); } @@ -143,6 +150,7 @@ export class UserRepository extends Repository { projectId, }, }, + relations: ['role'], }); } @@ -286,6 +294,8 @@ export class UserRepository extends Repository { this.applyUserListExpand(queryBuilder, expand); this.applyUserListPagination(queryBuilder, take, skip); this.applyUserListSort(queryBuilder, sortBy); + queryBuilder.leftJoinAndSelect('user.role', 'role'); + queryBuilder.leftJoinAndSelect('role.scopes', 'scopes'); return queryBuilder; } diff --git a/packages/@n8n/decorators/src/__tests__/redactable.test.ts b/packages/@n8n/decorators/src/__tests__/redactable.test.ts index 78331c6297..831af55d94 100644 --- a/packages/@n8n/decorators/src/__tests__/redactable.test.ts +++ b/packages/@n8n/decorators/src/__tests__/redactable.test.ts @@ -6,21 +6,39 @@ describe('Redactable Decorator', () => { class TestClass { @Redactable() methodWithUser(arg: { - user: { id: string; email?: string; firstName?: string; lastName?: string; role: string }; + user: { + id: string; + email?: string; + firstName?: string; + lastName?: string; + role: { slug: string }; + }; }) { return arg; } @Redactable('inviter') methodWithInviter(arg: { - inviter: { id: string; email?: string; firstName?: string; lastName?: string; role: string }; + inviter: { + id: string; + email?: string; + firstName?: string; + lastName?: string; + role: { slug: string }; + }; }) { return arg; } @Redactable('invitee') methodWithInvitee(arg: { - invitee: { id: string; email?: string; firstName?: string; lastName?: string; role: string }; + invitee: { + id: string; + email?: string; + firstName?: string; + lastName?: string; + role: { slug: string }; + }; }) { return arg; } @@ -29,7 +47,13 @@ describe('Redactable Decorator', () => { methodWithMultipleArgs( firstArg: { something: string }, secondArg: { - user: { id: string; email?: string; firstName?: string; lastName?: string; role: string }; + user: { + id: string; + email?: string; + firstName?: string; + lastName?: string; + role: { slug: string }; + }; }, ) { return { firstArg, secondArg }; @@ -69,7 +93,7 @@ describe('Redactable Decorator', () => { email: 'test@example.com', firstName: 'John', lastName: 'Doe', - role: 'admin', + role: { slug: 'admin' }, }, }; @@ -91,7 +115,7 @@ describe('Redactable Decorator', () => { email: 'test@example.com', firstName: 'John', lastName: 'Doe', - role: 'admin', + role: { slug: 'admin' }, }, }; @@ -113,7 +137,7 @@ describe('Redactable Decorator', () => { email: 'test@example.com', firstName: 'John', lastName: 'Doe', - role: 'admin', + role: { slug: 'admin' }, }, }; @@ -132,7 +156,7 @@ describe('Redactable Decorator', () => { const input = { user: { id: '123', - role: 'admin', + role: { slug: 'admin' }, }, }; @@ -153,7 +177,7 @@ describe('Redactable Decorator', () => { user: { id: '123', email: 'test@example.com', - role: 'admin', + role: { slug: 'admin' }, }, }; @@ -182,7 +206,7 @@ describe('Redactable Decorator', () => { user: { id: '123', email: 'test@example.com', - role: 'admin', + role: { slug: 'admin' }, }, }; diff --git a/packages/@n8n/decorators/src/redactable.ts b/packages/@n8n/decorators/src/redactable.ts index 9ef47cf1f2..3f54253a03 100644 --- a/packages/@n8n/decorators/src/redactable.ts +++ b/packages/@n8n/decorators/src/redactable.ts @@ -5,7 +5,9 @@ type UserLike = { email?: string; firstName?: string; lastName?: string; - role: string; + role: { + slug: string; + }; }; export class RedactableError extends UnexpectedError { @@ -22,7 +24,7 @@ function toRedactable(userLike: UserLike) { _email: userLike.email, _firstName: userLike.firstName, _lastName: userLike.lastName, - globalRole: userLike.role, + globalRole: userLike.role.slug, }; } diff --git a/packages/@n8n/permissions/src/__tests__/__snapshots__/scope-information.test.ts.snap b/packages/@n8n/permissions/src/__tests__/__snapshots__/scope-information.test.ts.snap index ff1374bb7f..6e49bb7940 100644 --- a/packages/@n8n/permissions/src/__tests__/__snapshots__/scope-information.test.ts.snap +++ b/packages/@n8n/permissions/src/__tests__/__snapshots__/scope-information.test.ts.snap @@ -95,6 +95,8 @@ exports[`Scope Information ensure scopes are defined correctly 1`] = ` "workflow:share", "workflow:execute", "workflow:move", + "workflow:activate", + "workflow:deactivate", "workflow:create", "workflow:read", "workflow:update", @@ -121,6 +123,14 @@ exports[`Scope Information ensure scopes are defined correctly 1`] = ` "dataStore:writeRow", "dataStore:listProject", "dataStore:*", + "execution:delete", + "execution:read", + "execution:list", + "execution:get", + "execution:*", + "workflowTags:update", + "workflowTags:list", + "workflowTags:*", "*", ] `; diff --git a/packages/@n8n/permissions/src/constants.ee.ts b/packages/@n8n/permissions/src/constants.ee.ts index c3dcac3b64..83db301ce4 100644 --- a/packages/@n8n/permissions/src/constants.ee.ts +++ b/packages/@n8n/permissions/src/constants.ee.ts @@ -22,11 +22,13 @@ export const RESOURCES = { user: ['resetPassword', 'changeRole', 'enforceMfa', ...DEFAULT_OPERATIONS] as const, variable: [...DEFAULT_OPERATIONS] as const, workersView: ['manage'] as const, - workflow: ['share', 'execute', 'move', ...DEFAULT_OPERATIONS] as const, + workflow: ['share', 'execute', 'move', 'activate', 'deactivate', ...DEFAULT_OPERATIONS] as const, folder: [...DEFAULT_OPERATIONS, 'move'] as const, insights: ['list'] as const, oidc: ['manage'] as const, dataStore: [...DEFAULT_OPERATIONS, 'readRow', 'writeRow', 'listProject'] as const, + execution: ['delete', 'read', 'list', 'get'] as const, + workflowTags: ['update', 'list'] as const, } as const; export const API_KEY_RESOURCES = { diff --git a/packages/@n8n/permissions/src/index.ts b/packages/@n8n/permissions/src/index.ts index f8b8fb6d86..fdcc7bb264 100644 --- a/packages/@n8n/permissions/src/index.ts +++ b/packages/@n8n/permissions/src/index.ts @@ -13,7 +13,7 @@ export { hasGlobalScope } from './utilities/has-global-scope.ee'; export { combineScopes } from './utilities/combine-scopes.ee'; export { rolesWithScope } from './utilities/roles-with-scope.ee'; export { getGlobalScopes } from './utilities/get-global-scopes.ee'; -export { getRoleScopes } from './utilities/get-role-scopes.ee'; +export { getRoleScopes, getAuthPrincipalScopes } from './utilities/get-role-scopes.ee'; export { getResourcePermissions } from './utilities/get-resource-permissions.ee'; export type { PermissionsRecord } from './utilities/get-resource-permissions.ee'; export * from './public-api-permissions.ee'; diff --git a/packages/@n8n/permissions/src/public-api-permissions.ee.ts b/packages/@n8n/permissions/src/public-api-permissions.ee.ts index fd6c62c537..47e5a454af 100644 --- a/packages/@n8n/permissions/src/public-api-permissions.ee.ts +++ b/packages/@n8n/permissions/src/public-api-permissions.ee.ts @@ -1,4 +1,4 @@ -import type { ApiKeyScope, GlobalRole } from './types.ee'; +import { isApiKeyScope, type ApiKeyScope, type AuthPrincipal, type GlobalRole } from './types.ee'; export const OWNER_API_KEY_SCOPES: ApiKeyScope[] = [ 'user:read', @@ -65,14 +65,46 @@ export const MEMBER_API_KEY_SCOPES: ApiKeyScope[] = [ 'credential:delete', ]; +/** + * This is a bit of a mess, because we are handing out scopes in API keys that are only + * valid for the personal project, which is enforced in the public API, because the workflows, + * execution endpoints are limited to the personal project. + * This is a temporary solution until we have a better way to handle personal projects and API key scopes! + */ +export const API_KEY_SCOPES_FOR_IMPLICIT_PERSONAL_PROJECT: ApiKeyScope[] = [ + 'workflowTags:update', + 'workflowTags:list', + 'workflow:create', + 'workflow:read', + 'workflow:update', + 'workflow:delete', + 'workflow:list', + 'workflow:move', + 'workflow:activate', + 'workflow:deactivate', + 'execution:delete', + 'execution:read', + 'execution:list', + 'credential:create', + 'credential:move', + 'credential:delete', +]; + const MAP_ROLE_SCOPES: Record = { 'global:owner': OWNER_API_KEY_SCOPES, 'global:admin': ADMIN_API_KEY_SCOPES, 'global:member': MEMBER_API_KEY_SCOPES, }; -export const getApiKeyScopesForRole = (role: GlobalRole) => { - return MAP_ROLE_SCOPES[role]; +export const getApiKeyScopesForRole = (user: AuthPrincipal) => { + return [ + ...new Set( + user.role.scopes + .map((scope) => scope.slug) + .concat(API_KEY_SCOPES_FOR_IMPLICIT_PERSONAL_PROJECT) + .filter(isApiKeyScope), + ), + ]; }; export const getOwnerOnlyApiKeyScopes = () => { diff --git a/packages/@n8n/permissions/src/scope-information.ts b/packages/@n8n/permissions/src/scope-information.ts index fc457fd133..95b729992f 100644 --- a/packages/@n8n/permissions/src/scope-information.ts +++ b/packages/@n8n/permissions/src/scope-information.ts @@ -1,5 +1,5 @@ -import { RESOURCES } from './constants.ee'; -import type { Scope, ScopeInformation } from './types.ee'; +import { API_KEY_RESOURCES, RESOURCES } from './constants.ee'; +import type { ApiKeyScope, Scope, ScopeInformation } from './types.ee'; function buildResourceScopes() { const resourceScopes = Object.entries(RESOURCES).flatMap(([resource, operations]) => [ @@ -11,8 +11,19 @@ function buildResourceScopes() { return resourceScopes; } +function buildApiKeyScopes() { + const apiKeyScopes = Object.entries(API_KEY_RESOURCES).flatMap(([resource, operations]) => [ + ...operations.map((op) => `${resource}:${op}` as const), + ]) as ApiKeyScope[]; + + return new Set(apiKeyScopes); +} + export const ALL_SCOPES = buildResourceScopes(); +// Keep the type of Scope[] to ensure that ApiKeyScopes are a subset of Scopes! +export const ALL_API_KEY_SCOPES = buildApiKeyScopes(); + export const scopeInformation: Partial> = { 'annotationTag:create': { displayName: 'Create Annotation Tag', diff --git a/packages/@n8n/permissions/src/types.ee.ts b/packages/@n8n/permissions/src/types.ee.ts index 5e2ae1c022..34bc542c78 100644 --- a/packages/@n8n/permissions/src/types.ee.ts +++ b/packages/@n8n/permissions/src/types.ee.ts @@ -10,6 +10,7 @@ import type { teamRoleSchema, workflowSharingRoleSchema, } from './schemas.ee'; +import { ALL_API_KEY_SCOPES } from './scope-information'; export type ScopeInformation = { displayName: string; @@ -76,12 +77,21 @@ export type AllRolesMap = { workflow: Array>; }; +export type DbScope = { + slug: Scope; +}; + +export type DbRole = { + slug: string; + scopes: DbScope[]; +}; + /** * Represents an authenticated entity in the system that can have specific permissions via a role. * @property role - The global role this principal has */ export type AuthPrincipal = { - role: GlobalRole; + role: DbRole; }; // #region Public API @@ -101,4 +111,9 @@ type AllApiKeyScopesObject = { export type ApiKeyScope = AllApiKeyScopesObject[PublicApiKeyResources]; +export function isApiKeyScope(scope: Scope): scope is ApiKeyScope { + // We are casting with as for runtime type checking + return ALL_API_KEY_SCOPES.has(scope as ApiKeyScope); +} + // #endregion diff --git a/packages/@n8n/permissions/src/utilities/__tests__/get-global-scopes.test.ts b/packages/@n8n/permissions/src/utilities/__tests__/get-global-scopes.test.ts index a56f1aa499..979eaef45b 100644 --- a/packages/@n8n/permissions/src/utilities/__tests__/get-global-scopes.test.ts +++ b/packages/@n8n/permissions/src/utilities/__tests__/get-global-scopes.test.ts @@ -1,19 +1,19 @@ import { GLOBAL_SCOPE_MAP } from '../../roles/role-maps.ee'; -import type { GlobalRole } from '../../types.ee'; import { getGlobalScopes } from '../get-global-scopes.ee'; +import { createAuthPrinicipal } from './utils'; describe('getGlobalScopes', () => { test.each(['global:owner', 'global:admin', 'global:member'] as const)( 'should return correct scopes for %s', (role) => { - const scopes = getGlobalScopes({ role }); + const scopes = getGlobalScopes(createAuthPrinicipal(role)); expect(scopes).toEqual(GLOBAL_SCOPE_MAP[role]); }, ); test('should return empty array for non-existent role', () => { - const scopes = getGlobalScopes({ role: 'non:existent' as GlobalRole }); + const scopes = getGlobalScopes(createAuthPrinicipal('non:existent')); expect(scopes).toEqual([]); }); diff --git a/packages/@n8n/permissions/src/utilities/__tests__/get-resource-permissions.test.ts b/packages/@n8n/permissions/src/utilities/__tests__/get-resource-permissions.test.ts index 2df39ccd31..aef291dfff 100644 --- a/packages/@n8n/permissions/src/utilities/__tests__/get-resource-permissions.test.ts +++ b/packages/@n8n/permissions/src/utilities/__tests__/get-resource-permissions.test.ts @@ -15,6 +15,7 @@ describe('permissions', () => { externalSecretsProvider: {}, externalSecret: {}, eventBusDestination: {}, + execution: {}, ldap: {}, license: {}, logStreaming: {}, @@ -29,6 +30,7 @@ describe('permissions', () => { variable: {}, workersView: {}, workflow: {}, + workflowTags: {}, folder: {}, insights: {}, dataStore: {}, @@ -130,6 +132,8 @@ describe('permissions', () => { list: true, }, dataStore: {}, + execution: {}, + workflowTags: {}, }; expect(getResourcePermissions(scopes)).toEqual(permissionRecord); diff --git a/packages/@n8n/permissions/src/utilities/__tests__/has-global-scope.test.ts b/packages/@n8n/permissions/src/utilities/__tests__/has-global-scope.test.ts index 138e802641..d177053715 100644 --- a/packages/@n8n/permissions/src/utilities/__tests__/has-global-scope.test.ts +++ b/packages/@n8n/permissions/src/utilities/__tests__/has-global-scope.test.ts @@ -1,5 +1,6 @@ import type { GlobalRole, Scope } from '../../types.ee'; import { hasGlobalScope } from '../has-global-scope.ee'; +import { createAuthPrinicipal } from './utils'; describe('hasGlobalScope', () => { describe('single scope checks', () => { @@ -11,7 +12,7 @@ describe('hasGlobalScope', () => { ] as Array<{ role: GlobalRole; scope: Scope; expected: boolean }>)( '$role with $scope -> $expected', ({ role, scope, expected }) => { - expect(hasGlobalScope({ role }, scope)).toBe(expected); + expect(hasGlobalScope(createAuthPrinicipal(role), scope)).toBe(expected); }, ); }); @@ -19,7 +20,7 @@ describe('hasGlobalScope', () => { describe('multiple scopes', () => { test('oneOf mode (default)', () => { expect( - hasGlobalScope({ role: 'global:member' }, [ + hasGlobalScope(createAuthPrinicipal('global:member'), [ 'tag:create', 'user:list', // a member cannot create users @@ -31,7 +32,7 @@ describe('hasGlobalScope', () => { test('allOf mode', () => { expect( hasGlobalScope( - { role: 'global:member' }, + createAuthPrinicipal('global:member'), [ 'tag:create', 'user:list', @@ -45,6 +46,6 @@ describe('hasGlobalScope', () => { }); test('edge cases', () => { - expect(hasGlobalScope({ role: 'global:owner' }, [])).toBe(false); + expect(hasGlobalScope(createAuthPrinicipal('global:owner'), [])).toBe(false); }); }); diff --git a/packages/@n8n/permissions/src/utilities/__tests__/utils.ts b/packages/@n8n/permissions/src/utilities/__tests__/utils.ts new file mode 100644 index 0000000000..d2290e4291 --- /dev/null +++ b/packages/@n8n/permissions/src/utilities/__tests__/utils.ts @@ -0,0 +1,40 @@ +import { GLOBAL_SCOPE_MAP } from '@/roles/role-maps.ee'; +import { globalRoleSchema } from '@/schemas.ee'; +import type { AuthPrincipal, GlobalRole, Scope } from '@/types.ee'; + +function createBuildInAuthPrinicipal(role: GlobalRole): AuthPrincipal { + return { + role: { + slug: role, + scopes: + GLOBAL_SCOPE_MAP[role].map((scope) => { + return { + slug: scope, + }; + }) || [], + }, + }; +} + +export function createAuthPrinicipal(role: string, scopes: Scope[] = []): AuthPrincipal { + try { + const isGlobalRole = globalRoleSchema.parse(role); + if (isGlobalRole) { + return createBuildInAuthPrinicipal(isGlobalRole); + } + } catch (error) { + // If the role is not a valid global role, we proceed + // to create a custom role with the provided scopes. + } + return { + role: { + slug: role, + scopes: + scopes.map((scope) => { + return { + slug: scope, + }; + }) || [], + }, + }; +} diff --git a/packages/@n8n/permissions/src/utilities/get-global-scopes.ee.ts b/packages/@n8n/permissions/src/utilities/get-global-scopes.ee.ts index aaa443fe34..18934a070a 100644 --- a/packages/@n8n/permissions/src/utilities/get-global-scopes.ee.ts +++ b/packages/@n8n/permissions/src/utilities/get-global-scopes.ee.ts @@ -1,4 +1,3 @@ -import { GLOBAL_SCOPE_MAP } from '../roles/role-maps.ee'; import type { AuthPrincipal } from '../types.ee'; /** @@ -6,4 +5,5 @@ import type { AuthPrincipal } from '../types.ee'; * @param principal - Contains the role to look up * @returns Array of scopes for the role, or empty array if not found */ -export const getGlobalScopes = (principal: AuthPrincipal) => GLOBAL_SCOPE_MAP[principal.role] ?? []; +export const getGlobalScopes = (principal: AuthPrincipal) => + principal.role.scopes.map((scope) => scope.slug) ?? []; diff --git a/packages/@n8n/permissions/src/utilities/get-role-scopes.ee.ts b/packages/@n8n/permissions/src/utilities/get-role-scopes.ee.ts index 384bfaa43e..aed73eb2cf 100644 --- a/packages/@n8n/permissions/src/utilities/get-role-scopes.ee.ts +++ b/packages/@n8n/permissions/src/utilities/get-role-scopes.ee.ts @@ -1,5 +1,5 @@ import { ALL_ROLE_MAPS } from '../roles/role-maps.ee'; -import type { AllRoleTypes, Resource, Scope } from '../types.ee'; +import type { AllRoleTypes, AuthPrincipal, Resource, Scope } from '../types.ee'; export const COMBINED_ROLE_MAP = Object.fromEntries( Object.values(ALL_ROLE_MAPS).flatMap((o: Record) => Object.entries(o)), @@ -10,6 +10,8 @@ export const COMBINED_ROLE_MAP = Object.fromEntries( * @param role - The role to look up * @param filters - Optional resources to filter scopes by * @returns Array of matching scopes + * + * @deprecated Use the 'getRoleScopes' from the AuthRolesService instead. */ export function getRoleScopes(role: AllRoleTypes, filters?: Resource[]): Scope[] { let scopes = COMBINED_ROLE_MAP[role]; @@ -18,3 +20,22 @@ export function getRoleScopes(role: AllRoleTypes, filters?: Resource[]): Scope[] } return scopes; } + +/** + * Gets scopes for an auth principal, optionally filtered by resource types. + * @param user - The auth principal to search scopes for + * @param filters - Optional resources to filter scopes by + * @returns Array of matching scopes + */ +export function getAuthPrincipalScopes(user: AuthPrincipal, filters?: Resource[]): Scope[] { + if (!user.role) { + const e = new Error('AuthPrincipal does not have a role defined'); + console.error('AuthPrincipal does not have a role defined', e); + throw e; + } + let scopes = user.role.scopes.map((s) => s.slug); + if (filters) { + scopes = scopes.filter((s) => filters.includes(s.split(':')[0] as Resource)); + } + return scopes; +} diff --git a/packages/@n8n/permissions/src/utilities/has-global-scope.ee.ts b/packages/@n8n/permissions/src/utilities/has-global-scope.ee.ts index 462a9ef97a..43fdf6460d 100644 --- a/packages/@n8n/permissions/src/utilities/has-global-scope.ee.ts +++ b/packages/@n8n/permissions/src/utilities/has-global-scope.ee.ts @@ -1,6 +1,6 @@ -import { getGlobalScopes } from './get-global-scopes.ee'; import { hasScope } from './has-scope.ee'; import type { AuthPrincipal, Scope, ScopeOptions } from '../types.ee'; +import { getAuthPrincipalScopes } from './get-role-scopes.ee'; /** * Checks if an auth-principal has specified global scope(s). @@ -12,6 +12,6 @@ export const hasGlobalScope = ( scope: Scope | Scope[], scopeOptions?: ScopeOptions, ): boolean => { - const global = getGlobalScopes(principal); + const global = getAuthPrincipalScopes(principal); return hasScope(scope, { global }, undefined, scopeOptions); }; diff --git a/packages/cli/src/__tests__/workflow-runner.test.ts b/packages/cli/src/__tests__/workflow-runner.test.ts index ff378cdac2..95810b806e 100644 --- a/packages/cli/src/__tests__/workflow-runner.test.ts +++ b/packages/cli/src/__tests__/workflow-runner.test.ts @@ -1,5 +1,5 @@ import { testDb, createWorkflow, mockInstance } from '@n8n/backend-test-utils'; -import type { User, ExecutionEntity } from '@n8n/db'; +import { type User, type ExecutionEntity, GLOBAL_OWNER_ROLE } from '@n8n/db'; import { Container, Service } from '@n8n/di'; import type { Response } from 'express'; import { mock } from 'jest-mock-extended'; @@ -41,7 +41,7 @@ setupTestServer({ endpointGroups: [] }); mockInstance(Telemetry); beforeAll(async () => { - owner = await createUser({ role: 'global:owner' }); + owner = await createUser({ role: GLOBAL_OWNER_ROLE }); runner = Container.get(WorkflowRunner); }); diff --git a/packages/cli/src/auth/auth.service.ts b/packages/cli/src/auth/auth.service.ts index 873bc0ce6e..3a38f1dd1f 100644 --- a/packages/cli/src/auth/auth.service.ts +++ b/packages/cli/src/auth/auth.service.ts @@ -2,7 +2,7 @@ import { Logger } from '@n8n/backend-common'; import { GlobalConfig } from '@n8n/config'; import { Time } from '@n8n/constants'; import type { AuthenticatedRequest, User } from '@n8n/db'; -import { InvalidAuthTokenRepository, UserRepository } from '@n8n/db'; +import { GLOBAL_OWNER_ROLE, InvalidAuthTokenRepository, UserRepository } from '@n8n/db'; import { Service } from '@n8n/di'; import { createHash } from 'crypto'; import type { NextFunction, Response } from 'express'; @@ -134,7 +134,7 @@ export class AuthService { const isWithinUsersLimit = this.license.isWithinUsersLimit(); if ( config.getEnv('userManagement.isInstanceOwnerSetUp') && - user.role !== 'global:owner' && + user.role.slug !== GLOBAL_OWNER_ROLE.slug && !isWithinUsersLimit ) { throw new ForbiddenError(RESPONSE_ERROR_MESSAGES.USERS_QUOTA_REACHED); @@ -174,6 +174,7 @@ export class AuthService { // TODO: Use an in-memory ttl-cache to cache the User object for upto a minute const user = await this.userRepository.findOne({ where: { id: jwtPayload.id }, + relations: ['role'], }); if ( @@ -237,7 +238,7 @@ export class AuthService { const user = await this.userRepository.findOne({ where: { id: decodedToken.sub }, - relations: ['authIdentities'], + relations: ['authIdentities', 'role'], }); if (!user) { diff --git a/packages/cli/src/auth/methods/email.ts b/packages/cli/src/auth/methods/email.ts index 81b8af35a2..9c4216dfcc 100644 --- a/packages/cli/src/auth/methods/email.ts +++ b/packages/cli/src/auth/methods/email.ts @@ -13,7 +13,7 @@ export const handleEmailLogin = async ( ): Promise => { const user = await Container.get(UserRepository).findOne({ where: { email }, - relations: ['authIdentities'], + relations: ['authIdentities', 'role'], }); if (user?.password && (await Container.get(PasswordUtility).compare(password, user.password))) { diff --git a/packages/cli/src/commands/import/credentials.ts b/packages/cli/src/commands/import/credentials.ts index 12c68110df..673bafe476 100644 --- a/packages/cli/src/commands/import/credentials.ts +++ b/packages/cli/src/commands/import/credentials.ts @@ -1,4 +1,11 @@ -import { CredentialsEntity, Project, User, SharedCredentials, ProjectRepository } from '@n8n/db'; +import { + CredentialsEntity, + Project, + User, + SharedCredentials, + ProjectRepository, + GLOBAL_OWNER_ROLE, +} from '@n8n/db'; import { Command } from '@n8n/decorators'; import { Container } from '@n8n/di'; // eslint-disable-next-line n8n-local-rules/misplaced-n8n-typeorm-import @@ -253,7 +260,11 @@ export class ImportCredentialsCommand extends BaseCommand> { } private async getOwner() { - const owner = await Container.get(UserRepository).findOneBy({ role: 'global:owner' }); + const owner = await Container.get(UserRepository).findOne({ + where: { role: { slug: GLOBAL_OWNER_ROLE.slug } }, + relations: ['role'], + }); if (!owner) { throw new UserError(`Failed to find owner. ${UM_FIX_INSTRUCTION}`); } diff --git a/packages/cli/src/commands/user-management/reset.ts b/packages/cli/src/commands/user-management/reset.ts index 69667fc565..cddd2027d8 100644 --- a/packages/cli/src/commands/user-management/reset.ts +++ b/packages/cli/src/commands/user-management/reset.ts @@ -7,6 +7,7 @@ import { SharedCredentialsRepository, SharedWorkflowRepository, UserRepository, + GLOBAL_OWNER_ROLE, } from '@n8n/db'; import { Command } from '@n8n/decorators'; import { Container } from '@n8n/di'; @@ -61,7 +62,9 @@ export class Reset extends BaseCommand { } async getInstanceOwner(): Promise { - const owner = await Container.get(UserRepository).findOneBy({ role: 'global:owner' }); + const owner = await Container.get(UserRepository).findOneBy({ + role: { slug: GLOBAL_OWNER_ROLE.slug }, + }); if (owner) return owner; @@ -71,7 +74,9 @@ export class Reset extends BaseCommand { await Container.get(UserRepository).save(user); - return await Container.get(UserRepository).findOneByOrFail({ role: 'global:owner' }); + return await Container.get(UserRepository).findOneByOrFail({ + role: { slug: GLOBAL_OWNER_ROLE.slug }, + }); } async catch(error: Error): Promise { diff --git a/packages/cli/src/controllers/__tests__/api-keys.controller.test.ts b/packages/cli/src/controllers/__tests__/api-keys.controller.test.ts index e7e457a0f0..ae1044ab3f 100644 --- a/packages/cli/src/controllers/__tests__/api-keys.controller.test.ts +++ b/packages/cli/src/controllers/__tests__/api-keys.controller.test.ts @@ -129,7 +129,7 @@ describe('ApiKeysController', () => { id: '123', password: 'password', authIdentities: [], - role: 'global:member', + role: { slug: 'global:member' }, mfaEnabled: false, }); diff --git a/packages/cli/src/controllers/__tests__/auth.controller.test.ts b/packages/cli/src/controllers/__tests__/auth.controller.test.ts index 18a2ecb696..1478e6741b 100644 --- a/packages/cli/src/controllers/__tests__/auth.controller.test.ts +++ b/packages/cli/src/controllers/__tests__/auth.controller.test.ts @@ -47,7 +47,7 @@ describe('AuthController', () => { const member = mock({ id: '123', - role: 'global:member', + role: { slug: 'global:member' }, mfaEnabled: false, }); diff --git a/packages/cli/src/controllers/__tests__/me.controller.test.ts b/packages/cli/src/controllers/__tests__/me.controller.test.ts index 47e05b1461..95c56af9e7 100644 --- a/packages/cli/src/controllers/__tests__/me.controller.test.ts +++ b/packages/cli/src/controllers/__tests__/me.controller.test.ts @@ -1,7 +1,7 @@ import { UserUpdateRequestDto } from '@n8n/api-types'; import { mockInstance } from '@n8n/backend-test-utils'; import type { AuthenticatedRequest, User, PublicUser } from '@n8n/db'; -import { InvalidAuthTokenRepository, UserRepository } from '@n8n/db'; +import { GLOBAL_OWNER_ROLE, InvalidAuthTokenRepository, UserRepository } from '@n8n/db'; import { Container } from '@n8n/di'; import type { Response } from 'express'; import { mock, anyObject } from 'jest-mock-extended'; @@ -38,7 +38,7 @@ describe('MeController', () => { email: 'valid@email.com', password: 'password', authIdentities: [], - role: 'global:owner', + role: GLOBAL_OWNER_ROLE, mfaEnabled: false, }); const payload = new UserUpdateRequestDto({ @@ -88,7 +88,7 @@ describe('MeController', () => { id: '123', password: 'password', authIdentities: [], - role: 'global:owner', + role: GLOBAL_OWNER_ROLE, mfaEnabled: false, }); const req = mock({ user }); @@ -115,7 +115,7 @@ describe('MeController', () => { email: 'valid@email.com', password: 'password', authIdentities: [], - role: 'global:owner', + role: GLOBAL_OWNER_ROLE, mfaEnabled: true, }); const req = mock({ user, browserId }); @@ -139,7 +139,7 @@ describe('MeController', () => { email: 'valid@email.com', password: 'password', authIdentities: [], - role: 'global:owner', + role: GLOBAL_OWNER_ROLE, mfaEnabled: true, }); const req = mock({ user, browserId }); @@ -165,7 +165,7 @@ describe('MeController', () => { email: 'valid@email.com', password: 'password', authIdentities: [], - role: 'global:owner', + role: GLOBAL_OWNER_ROLE, mfaEnabled: true, mfaSecret: 'secret', }); diff --git a/packages/cli/src/controllers/__tests__/owner.controller.test.ts b/packages/cli/src/controllers/__tests__/owner.controller.test.ts index 0f8b982fe0..ebabd8cb69 100644 --- a/packages/cli/src/controllers/__tests__/owner.controller.test.ts +++ b/packages/cli/src/controllers/__tests__/owner.controller.test.ts @@ -1,11 +1,12 @@ import type { DismissBannerRequestDto, OwnerSetupRequestDto } from '@n8n/api-types'; import type { Logger } from '@n8n/backend-common'; -import type { - AuthenticatedRequest, - User, - PublicUser, - SettingsRepository, - UserRepository, +import { + type AuthenticatedRequest, + type User, + type PublicUser, + type SettingsRepository, + type UserRepository, + GLOBAL_OWNER_ROLE, } from '@n8n/db'; import type { Response } from 'express'; import { mock } from 'jest-mock-extended'; @@ -65,7 +66,7 @@ describe('OwnerController', () => { it('should setup the instance owner successfully', async () => { const user = mock({ id: 'userId', - role: 'global:owner', + role: GLOBAL_OWNER_ROLE, authIdentities: [], }); const browserId = 'test-browser-id'; @@ -85,7 +86,8 @@ describe('OwnerController', () => { const result = await controller.setupOwner(req, res, payload); expect(userRepository.findOneOrFail).toHaveBeenCalledWith({ - where: { role: 'global:owner' }, + where: { role: { slug: GLOBAL_OWNER_ROLE.slug } }, + relations: ['role'], }); expect(userRepository.save).toHaveBeenCalledWith(user, { transaction: false }); expect(authService.issueCookie).toHaveBeenCalledWith(res, user, false, browserId); diff --git a/packages/cli/src/controllers/__tests__/users.controller.test.ts b/packages/cli/src/controllers/__tests__/users.controller.test.ts index 65c16b7b80..b17537baf8 100644 --- a/packages/cli/src/controllers/__tests__/users.controller.test.ts +++ b/packages/cli/src/controllers/__tests__/users.controller.test.ts @@ -35,7 +35,7 @@ describe('UsersController', () => { const request = mock({ user: { id: '123' }, }); - userRepository.findOneBy.mockResolvedValue(mock({ id: '456' })); + userRepository.findOne.mockResolvedValue(mock({ id: '456' })); projectService.getUserOwnedOrAdminProjects.mockResolvedValue([]); await controller.changeGlobalRole( diff --git a/packages/cli/src/controllers/api-keys.controller.ts b/packages/cli/src/controllers/api-keys.controller.ts index 43fa163826..40016101d8 100644 --- a/packages/cli/src/controllers/api-keys.controller.ts +++ b/packages/cli/src/controllers/api-keys.controller.ts @@ -33,7 +33,7 @@ export class ApiKeysController { _res: Response, @Body body: CreateApiKeyRequestDto, ) { - if (!this.publicApiKeyService.apiKeyHasValidScopesForRole(req.user.role, body.scopes)) { + if (!this.publicApiKeyService.apiKeyHasValidScopesForRole(req.user, body.scopes)) { throw new BadRequestError('Invalid scopes for user role'); } @@ -80,7 +80,7 @@ export class ApiKeysController { @Param('id') apiKeyId: string, @Body body: UpdateApiKeyRequestDto, ) { - if (!this.publicApiKeyService.apiKeyHasValidScopesForRole(req.user.role, body.scopes)) { + if (!this.publicApiKeyService.apiKeyHasValidScopesForRole(req.user, body.scopes)) { throw new BadRequestError('Invalid scopes for user role'); } @@ -91,8 +91,7 @@ export class ApiKeysController { @Get('/scopes', { middlewares: [isApiEnabledMiddleware] }) async getApiKeyScopes(req: AuthenticatedRequest, _res: Response) { - const { role } = req.user; - const scopes = getApiKeyScopesForRole(role); + const scopes = getApiKeyScopesForRole(req.user); return scopes; } } diff --git a/packages/cli/src/controllers/auth.controller.ts b/packages/cli/src/controllers/auth.controller.ts index 55fc67d3ad..0424e14e20 100644 --- a/packages/cli/src/controllers/auth.controller.ts +++ b/packages/cli/src/controllers/auth.controller.ts @@ -1,7 +1,7 @@ import { LoginRequestDto, ResolveSignupTokenQueryDto } from '@n8n/api-types'; import { Logger } from '@n8n/backend-common'; import type { User, PublicUser } from '@n8n/db'; -import { UserRepository, AuthenticatedRequest } from '@n8n/db'; +import { UserRepository, AuthenticatedRequest, GLOBAL_OWNER_ROLE } from '@n8n/db'; import { Body, Get, Post, Query, RestController } from '@n8n/decorators'; import { Container } from '@n8n/di'; import { isEmail } from 'class-validator'; @@ -60,7 +60,7 @@ export class AuthController { const preliminaryUser = await handleEmailLogin(emailOrLdapLoginId, password); // if the user is an owner, continue with the login if ( - preliminaryUser?.role === 'global:owner' || + preliminaryUser?.role.slug === GLOBAL_OWNER_ROLE.slug || preliminaryUser?.settings?.allowSSOManualLogin ) { user = preliminaryUser; @@ -70,7 +70,7 @@ export class AuthController { } } else if (isLdapCurrentAuthenticationMethod()) { const preliminaryUser = await handleEmailLogin(emailOrLdapLoginId, password); - if (preliminaryUser?.role === 'global:owner') { + if (preliminaryUser?.role.slug === GLOBAL_OWNER_ROLE.slug) { user = preliminaryUser; usedAuthenticationMethod = 'email'; } else { diff --git a/packages/cli/src/controllers/e2e.controller.ts b/packages/cli/src/controllers/e2e.controller.ts index 0ebe4cffb1..250a4a6ca5 100644 --- a/packages/cli/src/controllers/e2e.controller.ts +++ b/packages/cli/src/controllers/e2e.controller.ts @@ -305,7 +305,9 @@ export class E2EController { id: uuid(), ...owner, password: await this.passwordUtility.hash(owner.password), - role: 'global:owner', + role: { + slug: 'global:owner', + }, }), ]; @@ -314,7 +316,9 @@ export class E2EController { id: uuid(), ...admin, password: await this.passwordUtility.hash(admin.password), - role: 'global:admin', + role: { + slug: 'global:admin', + }, }), ); @@ -324,7 +328,9 @@ export class E2EController { id: uuid(), ...payload, password: await this.passwordUtility.hash(password), - role: 'global:member', + role: { + slug: 'global:member', + }, }), ); } diff --git a/packages/cli/src/controllers/invitation.controller.ts b/packages/cli/src/controllers/invitation.controller.ts index ffa84a2c99..8dd5a7ed22 100644 --- a/packages/cli/src/controllers/invitation.controller.ts +++ b/packages/cli/src/controllers/invitation.controller.ts @@ -99,7 +99,10 @@ export class InvitationController { ) { const { inviterId, firstName, lastName, password } = payload; - const users = await this.userRepository.findManyByIds([inviterId, inviteeId]); + const users = await this.userRepository.find({ + where: [{ id: inviterId }, { id: inviteeId }], + relations: ['role'], + }); if (users.length !== 2) { this.logger.debug( diff --git a/packages/cli/src/controllers/me.controller.ts b/packages/cli/src/controllers/me.controller.ts index 2a86f4b02c..568204a1c1 100644 --- a/packages/cli/src/controllers/me.controller.ts +++ b/packages/cli/src/controllers/me.controller.ts @@ -109,6 +109,7 @@ export class MeController { await this.userService.update(userId, payload); const user = await this.userRepository.findOneOrFail({ where: { id: userId }, + relations: ['role'], }); this.logger.info('User updated successfully', { userId }); diff --git a/packages/cli/src/controllers/mfa.controller.ts b/packages/cli/src/controllers/mfa.controller.ts index 8833a25ecc..41790b8a70 100644 --- a/packages/cli/src/controllers/mfa.controller.ts +++ b/packages/cli/src/controllers/mfa.controller.ts @@ -132,7 +132,10 @@ export class MFAController { await this.mfaService.disableMfaWithRecoveryCode(userId, mfaRecoveryCode); } - const updatedUser = await this.userRepository.findOneByOrFail({ id: userId }); + const updatedUser = await this.userRepository.findOneOrFail({ + where: { id: userId }, + relations: ['role'], + }); this.authService.issueCookie(res, updatedUser, false, req.browserId); } diff --git a/packages/cli/src/controllers/oauth/__tests__/oauth1-credential.controller.test.ts b/packages/cli/src/controllers/oauth/__tests__/oauth1-credential.controller.test.ts index 28750cfcc5..d87459b527 100644 --- a/packages/cli/src/controllers/oauth/__tests__/oauth1-credential.controller.test.ts +++ b/packages/cli/src/controllers/oauth/__tests__/oauth1-credential.controller.test.ts @@ -2,7 +2,7 @@ import { Logger } from '@n8n/backend-common'; import { mockInstance } from '@n8n/backend-test-utils'; import { Time } from '@n8n/constants'; import type { CredentialsEntity, User } from '@n8n/db'; -import { CredentialsRepository } from '@n8n/db'; +import { CredentialsRepository, GLOBAL_OWNER_ROLE } from '@n8n/db'; import { Container } from '@n8n/di'; import Csrf from 'csrf'; import type { Response } from 'express'; @@ -44,7 +44,7 @@ describe('OAuth1CredentialController', () => { id: '123', password: 'password', authIdentities: [], - role: 'global:owner', + role: GLOBAL_OWNER_ROLE, }); const credential = mock({ id: '1', diff --git a/packages/cli/src/controllers/oauth/__tests__/oauth2-credential.controller.test.ts b/packages/cli/src/controllers/oauth/__tests__/oauth2-credential.controller.test.ts index 76fe241232..73b9ce34df 100644 --- a/packages/cli/src/controllers/oauth/__tests__/oauth2-credential.controller.test.ts +++ b/packages/cli/src/controllers/oauth/__tests__/oauth2-credential.controller.test.ts @@ -2,7 +2,7 @@ import { Logger } from '@n8n/backend-common'; import { mockInstance } from '@n8n/backend-test-utils'; import { Time } from '@n8n/constants'; import type { CredentialsEntity, User } from '@n8n/db'; -import { CredentialsRepository } from '@n8n/db'; +import { CredentialsRepository, GLOBAL_OWNER_ROLE } from '@n8n/db'; import { Container } from '@n8n/di'; import Csrf from 'csrf'; import { type Response } from 'express'; @@ -46,7 +46,7 @@ describe('OAuth2CredentialController', () => { id: '123', password: 'password', authIdentities: [], - role: 'global:owner', + role: GLOBAL_OWNER_ROLE, }); const credential = mock({ id: '1', diff --git a/packages/cli/src/controllers/owner.controller.ts b/packages/cli/src/controllers/owner.controller.ts index 9b4feb5945..6192fed4cb 100644 --- a/packages/cli/src/controllers/owner.controller.ts +++ b/packages/cli/src/controllers/owner.controller.ts @@ -1,6 +1,11 @@ import { DismissBannerRequestDto, OwnerSetupRequestDto } from '@n8n/api-types'; import { Logger } from '@n8n/backend-common'; -import { AuthenticatedRequest, SettingsRepository, UserRepository } from '@n8n/db'; +import { + AuthenticatedRequest, + GLOBAL_OWNER_ROLE, + SettingsRepository, + UserRepository, +} from '@n8n/db'; import { Body, GlobalScope, Post, RestController } from '@n8n/decorators'; import { Response } from 'express'; @@ -44,7 +49,8 @@ export class OwnerController { } let owner = await this.userRepository.findOneOrFail({ - where: { role: 'global:owner' }, + where: { role: { slug: GLOBAL_OWNER_ROLE.slug } }, + relations: ['role'], }); owner.email = email; owner.firstName = firstName; diff --git a/packages/cli/src/controllers/password-reset.controller.ts b/packages/cli/src/controllers/password-reset.controller.ts index 706b5ebfa1..94164bb70b 100644 --- a/packages/cli/src/controllers/password-reset.controller.ts +++ b/packages/cli/src/controllers/password-reset.controller.ts @@ -4,7 +4,7 @@ import { ResolvePasswordTokenQueryDto, } from '@n8n/api-types'; import { Logger } from '@n8n/backend-common'; -import { UserRepository } from '@n8n/db'; +import { GLOBAL_OWNER_ROLE, UserRepository } from '@n8n/db'; import { Body, Get, Post, Query, RestController } from '@n8n/decorators'; import { hasGlobalScope } from '@n8n/permissions'; import { Response } from 'express'; @@ -71,7 +71,7 @@ export class PasswordResetController { return; } - if (user.role !== 'global:owner' && !this.license.isWithinUsersLimit()) { + if (user.role.slug !== GLOBAL_OWNER_ROLE.slug && !this.license.isWithinUsersLimit()) { this.logger.debug( 'Request to send password reset email failed because the user limit was reached', ); @@ -147,7 +147,7 @@ export class PasswordResetController { const user = await this.authService.resolvePasswordResetToken(token); if (!user) throw new NotFoundError(''); - if (user.role !== 'global:owner' && !this.license.isWithinUsersLimit()) { + if (user.role.slug !== GLOBAL_OWNER_ROLE.slug && !this.license.isWithinUsersLimit()) { this.logger.debug( 'Request to resolve password token failed because the user limit was reached', { userId: user.id }, diff --git a/packages/cli/src/controllers/project.controller.ts b/packages/cli/src/controllers/project.controller.ts index 939369acc1..98725fc443 100644 --- a/packages/cli/src/controllers/project.controller.ts +++ b/packages/cli/src/controllers/project.controller.ts @@ -14,7 +14,12 @@ import { Param, Query, } from '@n8n/decorators'; -import { combineScopes, getRoleScopes, hasGlobalScope } from '@n8n/permissions'; +import { + combineScopes, + getAuthPrincipalScopes, + getRoleScopes, + 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'; @@ -60,7 +65,7 @@ export class ProjectController { this.eventService.emit('team-project-created', { userId: req.user.id, - role: req.user.role, + role: req.user.role.slug, }); return { @@ -68,7 +73,7 @@ export class ProjectController { role: 'project:admin', scopes: [ ...combineScopes({ - global: getRoleScopes(req.user.role), + global: getAuthPrincipalScopes(req.user), project: getRoleScopes('project:admin'), }), ], @@ -105,7 +110,7 @@ export class ProjectController { if (result.scopes) { result.scopes.push( ...combineScopes({ - global: getRoleScopes(req.user.role), + global: getAuthPrincipalScopes(req.user), project: getRoleScopes(pr.role), }), ); @@ -121,13 +126,13 @@ export class ProjectController { // If the user has the global `project:read` scope then they may not // own this relationship in that case we use the global user role // instead of the relation role, which is for another user. - role: req.user.role, + role: req.user.role.slug, scopes: [], }, ); if (result.scopes) { - result.scopes.push(...combineScopes({ global: getRoleScopes(req.user.role) })); + result.scopes.push(...combineScopes({ global: getAuthPrincipalScopes(req.user) })); } results.push(result); @@ -151,7 +156,7 @@ export class ProjectController { } const scopes: Scope[] = [ ...combineScopes({ - global: getRoleScopes(req.user.role), + global: getAuthPrincipalScopes(req.user), project: getRoleScopes('project:personalOwner'), }), ]; @@ -189,7 +194,7 @@ export class ProjectController { })), scopes: [ ...combineScopes({ - global: getRoleScopes(req.user.role), + global: getAuthPrincipalScopes(req.user), ...(myRelation ? { project: getRoleScopes(myRelation.role) } : {}), }), ], @@ -230,7 +235,7 @@ export class ProjectController { this.eventService.emit('team-project-updated', { userId: req.user.id, - role: req.user.role, + role: req.user.role.slug, members: relations, projectId, }); @@ -251,7 +256,7 @@ export class ProjectController { this.eventService.emit('team-project-deleted', { userId: req.user.id, - role: req.user.role, + role: req.user.role.slug, projectId, removalType: query.transferId !== undefined ? 'transfer' : 'delete', targetProjectId: query.transferId, diff --git a/packages/cli/src/controllers/users.controller.ts b/packages/cli/src/controllers/users.controller.ts index a101097474..0b8fe925dc 100644 --- a/packages/cli/src/controllers/users.controller.ts +++ b/packages/cli/src/controllers/users.controller.ts @@ -15,6 +15,8 @@ import { SharedWorkflowRepository, UserRepository, AuthenticatedRequest, + GLOBAL_ADMIN_ROLE, + GLOBAL_OWNER_ROLE, } from '@n8n/db'; import { GlobalScope, @@ -115,6 +117,9 @@ export class UsersController { withInviteUrl, inviterId: req.user.id, }); + if (listQueryOptions.select && !listQueryOptions.select?.includes('role')) { + delete user.role; + } return { ...user, projectRelations: u.projectRelations?.map((pr) => ({ @@ -137,12 +142,16 @@ export class UsersController { async getUserPasswordResetLink(req: UserRequest.PasswordResetLink) { const user = await this.userRepository.findOneOrFail({ where: { id: req.params.id }, + relations: ['role'], }); if (!user) { throw new NotFoundError('User not found'); } - if (req.user.role === 'global:admin' && user.role === 'global:owner') { + if ( + req.user.role.slug === GLOBAL_ADMIN_ROLE.slug && + user.role.slug === GLOBAL_OWNER_ROLE.slug + ) { throw new ForbiddenError('Admin cannot reset password of global owner'); } @@ -186,7 +195,10 @@ export class UsersController { const { transferId } = req.query; - const userToDelete = await this.userRepository.findOneBy({ id: idToDelete }); + const userToDelete = await this.userRepository.findOne({ + where: { id: idToDelete }, + relations: ['role'], + }); if (!userToDelete) { throw new NotFoundError( @@ -194,7 +206,7 @@ export class UsersController { ); } - if (userToDelete.role === 'global:owner') { + if (userToDelete.role.slug === GLOBAL_OWNER_ROLE.slug) { throw new ForbiddenError('Instance owner cannot be deleted.'); } @@ -302,16 +314,25 @@ export class UsersController { const { NO_ADMIN_ON_OWNER, NO_USER, NO_OWNER_ON_OWNER } = UsersController.ERROR_MESSAGES.CHANGE_ROLE; - const targetUser = await this.userRepository.findOneBy({ id }); + const targetUser = await this.userRepository.findOne({ + where: { id }, + relations: ['role'], + }); if (targetUser === null) { throw new NotFoundError(NO_USER); } - if (req.user.role === 'global:admin' && targetUser.role === 'global:owner') { + if ( + req.user.role.slug === GLOBAL_ADMIN_ROLE.slug && + targetUser.role.slug === GLOBAL_OWNER_ROLE.slug + ) { throw new ForbiddenError(NO_ADMIN_ON_OWNER); } - if (req.user.role === 'global:owner' && targetUser.role === 'global:owner') { + if ( + req.user.role.slug === GLOBAL_OWNER_ROLE.slug && + targetUser.role.slug === GLOBAL_OWNER_ROLE.slug + ) { throw new ForbiddenError(NO_OWNER_ON_OWNER); } 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 2cec34f872..039674a0a2 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 { User } from '@n8n/db'; +import { GLOBAL_ADMIN_ROLE, User } from '@n8n/db'; import type { SharedCredentials, SharedWorkflow, @@ -23,7 +23,7 @@ import { SourceControlContext } from '../types/source-control-context'; describe('SourceControlExportService', () => { const globalAdminContext = new SourceControlContext( Object.assign(new User(), { - role: 'global:admin', + role: GLOBAL_ADMIN_ROLE, }), ); 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 246cd45647..b4c170fdc8 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 @@ -5,6 +5,8 @@ import { type ProjectRepository, User, WorkflowEntity, + GLOBAL_ADMIN_ROLE, + GLOBAL_MEMBER_ROLE, } from '@n8n/db'; import * as fastGlob from 'fast-glob'; import { mock } from 'jest-mock-extended'; @@ -20,13 +22,13 @@ jest.mock('fast-glob'); const globalAdminContext = new SourceControlContext( Object.assign(new User(), { - role: 'global:admin', + role: GLOBAL_ADMIN_ROLE, }), ); const globalMemberContext = new SourceControlContext( Object.assign(new User(), { - role: 'global:member', + role: GLOBAL_MEMBER_ROLE, }), ); diff --git a/packages/cli/src/environments.ee/source-control/__tests__/source-control.service.test.ts b/packages/cli/src/environments.ee/source-control/__tests__/source-control.service.test.ts index 1f001f6c61..6d7c8d4b27 100644 --- a/packages/cli/src/environments.ee/source-control/__tests__/source-control.service.test.ts +++ b/packages/cli/src/environments.ee/source-control/__tests__/source-control.service.test.ts @@ -1,12 +1,14 @@ import type { SourceControlledFile } from '@n8n/api-types'; -import type { - Variables, - FolderWithWorkflowAndSubFolderCount, - TagEntity, - User, - FolderRepository, - TagRepository, - WorkflowEntity, +import { + type Variables, + type FolderWithWorkflowAndSubFolderCount, + type TagEntity, + type User, + type FolderRepository, + type TagRepository, + type WorkflowEntity, + GLOBAL_MEMBER_ROLE, + GLOBAL_ADMIN_ROLE, } from '@n8n/db'; import { Container } from '@n8n/di'; import { mock } from 'jest-mock-extended'; @@ -149,8 +151,9 @@ describe('SourceControlService', () => { describe('getStatus', () => { it('ensure updatedAt field for last deleted tag', async () => { // ARRANGE - const user = mock(); - user.role = 'global:admin'; + const user = mock({ + role: GLOBAL_ADMIN_ROLE, + }); sourceControlImportService.getRemoteVersionIdsFromFiles.mockResolvedValue([]); sourceControlImportService.getLocalVersionIdsFromDb.mockResolvedValue([]); @@ -204,8 +207,9 @@ describe('SourceControlService', () => { it('ensure updatedAt field for last deleted folder', async () => { // ARRANGE - const user = mock(); - user.role = 'global:admin'; + const user = mock({ + role: GLOBAL_ADMIN_ROLE, + }); sourceControlImportService.getRemoteVersionIdsFromFiles.mockResolvedValue([]); sourceControlImportService.getLocalVersionIdsFromDb.mockResolvedValue([]); @@ -262,8 +266,9 @@ describe('SourceControlService', () => { it('conflict depends on the value of `direction`', async () => { // ARRANGE - const user = mock(); - user.role = 'global:admin'; + const user = mock({ + role: GLOBAL_ADMIN_ROLE, + }); // Define a credential that does only exist locally. // Pulling this would delete it so it should be marked as a conflict. @@ -368,8 +373,9 @@ describe('SourceControlService', () => { it('should throw `ForbiddenError` if direction is pull and user is not allowed to globally pull', async () => { // ARRANGE - const user = mock(); - user.role = 'global:member'; + const user = mock({ + role: GLOBAL_MEMBER_ROLE, + }); // ACT await expect( @@ -387,7 +393,7 @@ describe('SourceControlService', () => { 'should return file content for $type', async ({ type, id, content }) => { jest.spyOn(gitService, 'getFileContent').mockResolvedValue(content); - const user = mock({ id: 'user-id', role: 'global:admin' }); + const user = mock({ id: 'user-id', role: GLOBAL_ADMIN_ROLE }); const result = await sourceControlService.getRemoteFileEntity({ user, type, id }); @@ -398,7 +404,7 @@ describe('SourceControlService', () => { it.each(['folders', 'credential', 'tags', 'variables'])( 'should throw an error if the file type is not handled', async (type) => { - const user = mock({ id: 'user-id', role: 'global:admin' }); + const user = mock({ id: 'user-id', role: { slug: 'global:admin' } }); await expect( sourceControlService.getRemoteFileEntity({ user, type, id: 'unknown' }), ).rejects.toThrow(`Unsupported file type: ${type}`); @@ -407,7 +413,7 @@ describe('SourceControlService', () => { it('should fail if the git service fails to get the file content', async () => { jest.spyOn(gitService, 'getFileContent').mockRejectedValue(new Error('Git service error')); - const user = mock({ id: 'user-id', role: 'global:admin' }); + const user = mock({ id: 'user-id', role: { slug: 'global:admin' } }); await expect( sourceControlService.getRemoteFileEntity({ user, type: 'workflow', id: '1234' }), @@ -417,7 +423,7 @@ describe('SourceControlService', () => { it('should throw an error if the user does not have access to the project', async () => { const user = mock({ id: 'user-id', - role: 'global:member', + role: { slug: 'global:member' }, }); jest .spyOn(sourceControlScopedService, 'getWorkflowsInAdminProjectsFromContext') @@ -429,7 +435,7 @@ describe('SourceControlService', () => { }); it('should return content for an authorized workflow', async () => { - const user = mock({ id: 'user-id', role: 'global:member' }); + const user = mock({ id: 'user-id', role: { slug: 'global:member' } }); jest .spyOn(sourceControlScopedService, 'getWorkflowsInAdminProjectsFromContext') .mockResolvedValue([{ id: '1234' } as WorkflowEntity]); diff --git a/packages/cli/src/events/__tests__/log-streaming-event-relay.test.ts b/packages/cli/src/events/__tests__/log-streaming-event-relay.test.ts index 2cd2019018..576b1fc24c 100644 --- a/packages/cli/src/events/__tests__/log-streaming-event-relay.test.ts +++ b/packages/cli/src/events/__tests__/log-streaming-event-relay.test.ts @@ -1,4 +1,4 @@ -import type { IWorkflowDb } from '@n8n/db'; +import { GLOBAL_OWNER_ROLE, type IWorkflowDb } from '@n8n/db'; import { mock } from 'jest-mock-extended'; import type { InstanceSettings } from 'n8n-core'; import type { INode, IRun, IWorkflowBase } from 'n8n-workflow'; @@ -27,7 +27,7 @@ describe('LogStreamingEventRelay', () => { email: 'john@n8n.io', firstName: 'John', lastName: 'Doe', - role: 'owner', + role: { slug: 'owner' }, }, workflow: mock({ id: 'wf123', @@ -61,7 +61,7 @@ describe('LogStreamingEventRelay', () => { email: 'jane@n8n.io', firstName: 'Jane', lastName: 'Smith', - role: 'user', + role: { slug: 'user' }, }, workflowId: 'wf789', publicApi: false, @@ -89,7 +89,7 @@ describe('LogStreamingEventRelay', () => { email: 'jane@n8n.io', firstName: 'Jane', lastName: 'Smith', - role: 'user', + role: { slug: 'user' }, }, workflowId: 'wf789', publicApi: false, @@ -117,7 +117,7 @@ describe('LogStreamingEventRelay', () => { email: 'jane@n8n.io', firstName: 'Jane', lastName: 'Smith', - role: 'user', + role: { slug: 'user' }, }, workflowId: 'wf789', publicApi: false, @@ -145,7 +145,7 @@ describe('LogStreamingEventRelay', () => { email: 'alex@n8n.io', firstName: 'Alex', lastName: 'Johnson', - role: 'editor', + role: { slug: 'editor' }, }, workflow: mock({ id: 'wf101', name: 'Updated Workflow' }), publicApi: false, @@ -347,7 +347,7 @@ describe('LogStreamingEventRelay', () => { email: 'updated@example.com', firstName: 'Updated', lastName: 'User', - role: 'global:member', + role: { slug: 'global:member' }, }, fieldsChanged: ['firstName', 'lastName', 'password'], }; @@ -374,7 +374,7 @@ describe('LogStreamingEventRelay', () => { email: 'john@n8n.io', firstName: 'John', lastName: 'Doe', - role: 'some-role', + role: { slug: 'some-role' }, }, targetUserOldStatus: 'active', publicApi: false, @@ -404,7 +404,7 @@ describe('LogStreamingEventRelay', () => { email: 'inviter@example.com', firstName: 'Inviter', lastName: 'User', - role: 'global:owner', + role: { slug: GLOBAL_OWNER_ROLE.slug }, }, targetUserId: ['newUser123'], publicApi: false, @@ -434,7 +434,7 @@ describe('LogStreamingEventRelay', () => { email: 'reinviter@example.com', firstName: 'Reinviter', lastName: 'User', - role: 'global:admin', + role: { slug: 'global:admin' }, }, targetUserId: ['existingUser456'], }; @@ -461,7 +461,7 @@ describe('LogStreamingEventRelay', () => { email: 'newuser@example.com', firstName: 'New', lastName: 'User', - role: 'global:member', + role: { slug: 'global:member' }, }, userType: 'email', wasDisabledLdapUser: false, @@ -488,7 +488,7 @@ describe('LogStreamingEventRelay', () => { email: 'loggedin@example.com', firstName: 'Logged', lastName: 'In', - role: 'global:owner', + role: { slug: GLOBAL_OWNER_ROLE.slug }, }, authenticationMethod: 'email', }; @@ -517,7 +517,7 @@ describe('LogStreamingEventRelay', () => { email: 'user101@example.com', firstName: 'John', lastName: 'Doe', - role: 'global:member', + role: { slug: 'global:member' }, }, }; @@ -542,14 +542,14 @@ describe('LogStreamingEventRelay', () => { email: 'john@n8n.io', firstName: 'John', lastName: 'Doe', - role: 'some-role', + role: { slug: 'some-role' }, }, invitee: { id: '456', email: 'jane@n8n.io', firstName: 'Jane', lastName: 'Doe', - role: 'some-other-role', + role: { slug: 'some-other-role' }, }, }; @@ -583,7 +583,7 @@ describe('LogStreamingEventRelay', () => { email: 'resetuser@example.com', firstName: 'Reset', lastName: 'User', - role: 'global:member', + role: { slug: 'global:member' }, }, }; @@ -708,7 +708,7 @@ describe('LogStreamingEventRelay', () => { email: 'sharer@example.com', firstName: 'Alice', lastName: 'Sharer', - role: 'global:owner', + role: { slug: GLOBAL_OWNER_ROLE.slug }, }, credentialId: 'cred789', credentialType: 'githubApi', @@ -743,7 +743,7 @@ describe('LogStreamingEventRelay', () => { email: 'user@example.com', firstName: 'Test', lastName: 'User', - role: 'global:owner', + role: { slug: GLOBAL_OWNER_ROLE.slug }, }, credentialType: 'githubApi', credentialId: 'cred456', @@ -778,7 +778,7 @@ describe('LogStreamingEventRelay', () => { email: 'creduser@example.com', firstName: 'Cred', lastName: 'User', - role: 'global:owner', + role: { slug: GLOBAL_OWNER_ROLE.slug }, }, credentialId: 'cred789', credentialType: 'githubApi', @@ -807,7 +807,7 @@ describe('LogStreamingEventRelay', () => { email: 'updatecred@example.com', firstName: 'Update', lastName: 'Cred', - role: 'global:owner', + role: { slug: GLOBAL_OWNER_ROLE.slug }, }, credentialId: 'cred101', credentialType: 'slackApi', @@ -859,7 +859,7 @@ describe('LogStreamingEventRelay', () => { email: 'packageupdater@example.com', firstName: 'Package', lastName: 'Updater', - role: 'global:admin', + role: { slug: 'global:admin' }, }, packageName: 'n8n-nodes-awesome-package', packageVersionCurrent: '1.0.0', @@ -896,7 +896,7 @@ describe('LogStreamingEventRelay', () => { email: 'admin@example.com', firstName: 'Admin', lastName: 'User', - role: 'global:admin', + role: { slug: 'global:admin' }, }, inputString: 'n8n-nodes-custom-package', packageName: 'n8n-nodes-custom-package', @@ -935,7 +935,7 @@ describe('LogStreamingEventRelay', () => { email: 'packagedeleter@example.com', firstName: 'Package', lastName: 'Deleter', - role: 'global:admin', + role: { slug: 'global:admin' }, }, packageName: 'n8n-nodes-awesome-package', packageVersion: '1.0.0', @@ -972,7 +972,7 @@ describe('LogStreamingEventRelay', () => { email: 'recipient@example.com', firstName: 'Failed', lastName: 'Recipient', - role: 'global:member', + role: { slug: 'global:member' }, }, messageType: 'New user invite', publicApi: false, @@ -1002,7 +1002,7 @@ describe('LogStreamingEventRelay', () => { email: 'apiuser@example.com', firstName: 'API', lastName: 'User', - role: 'global:owner', + role: { slug: GLOBAL_OWNER_ROLE.slug }, }, publicApi: true, }; @@ -1028,7 +1028,7 @@ describe('LogStreamingEventRelay', () => { email: 'apiuser@example.com', firstName: 'API', lastName: 'User', - role: 'global:owner', + role: { slug: GLOBAL_OWNER_ROLE.slug }, }, publicApi: true, }; diff --git a/packages/cli/src/events/__tests__/telemetry-event-relay.test.ts b/packages/cli/src/events/__tests__/telemetry-event-relay.test.ts index 078e463b72..b80c01d79a 100644 --- a/packages/cli/src/events/__tests__/telemetry-event-relay.test.ts +++ b/packages/cli/src/events/__tests__/telemetry-event-relay.test.ts @@ -1,14 +1,15 @@ import type { NodeTypes } from '@/node-types'; import { mockInstance } from '@n8n/backend-test-utils'; import type { GlobalConfig } from '@n8n/config'; -import type { - CredentialsEntity, - WorkflowEntity, - IWorkflowDb, - CredentialsRepository, - ProjectRelationRepository, - SharedWorkflowRepository, - WorkflowRepository, +import { + type CredentialsEntity, + type WorkflowEntity, + type IWorkflowDb, + type CredentialsRepository, + type ProjectRelationRepository, + type SharedWorkflowRepository, + type WorkflowRepository, + GLOBAL_OWNER_ROLE, } from '@n8n/db'; import { mock } from 'jest-mock-extended'; import { type BinaryDataConfig, InstanceSettings } from 'n8n-core'; @@ -381,7 +382,7 @@ describe('TelemetryEventRelay', () => { email: 'user@example.com', firstName: 'John', lastName: 'Doe', - role: 'global:owner', + role: { slug: GLOBAL_OWNER_ROLE.slug }, }, publicApi: true, }; @@ -401,7 +402,7 @@ describe('TelemetryEventRelay', () => { email: 'user@example.com', firstName: 'John', lastName: 'Doe', - role: 'global:owner', + role: { slug: GLOBAL_OWNER_ROLE.slug }, }, publicApi: true, }; @@ -423,7 +424,7 @@ describe('TelemetryEventRelay', () => { email: 'user@example.com', firstName: 'John', lastName: 'Doe', - role: 'global:owner', + role: { slug: GLOBAL_OWNER_ROLE.slug }, }, inputString: 'n8n-nodes-package', packageName: 'n8n-nodes-package', @@ -456,7 +457,7 @@ describe('TelemetryEventRelay', () => { email: 'user@example.com', firstName: 'John', lastName: 'Doe', - role: 'global:owner', + role: { slug: GLOBAL_OWNER_ROLE.slug }, }, packageName: 'n8n-nodes-package', packageVersionCurrent: '1.0.0', @@ -486,7 +487,7 @@ describe('TelemetryEventRelay', () => { email: 'user@example.com', firstName: 'John', lastName: 'Doe', - role: 'global:owner', + role: { slug: GLOBAL_OWNER_ROLE.slug }, }, packageName: 'n8n-nodes-package', packageVersion: '1.0.0', @@ -516,7 +517,7 @@ describe('TelemetryEventRelay', () => { email: 'user@example.com', firstName: 'John', lastName: 'Doe', - role: 'global:owner', + role: { slug: GLOBAL_OWNER_ROLE.slug }, }, credentialType: 'github', credentialId: 'cred123', @@ -543,7 +544,7 @@ describe('TelemetryEventRelay', () => { email: 'user@example.com', firstName: 'John', lastName: 'Doe', - role: 'global:owner', + role: { slug: GLOBAL_OWNER_ROLE.slug }, }, credentialType: 'github', credentialId: 'cred123', @@ -571,7 +572,7 @@ describe('TelemetryEventRelay', () => { email: 'user@example.com', firstName: 'John', lastName: 'Doe', - role: 'global:owner', + role: { slug: GLOBAL_OWNER_ROLE.slug }, }, credentialId: 'cred123', credentialType: 'github', @@ -593,7 +594,7 @@ describe('TelemetryEventRelay', () => { email: 'user@example.com', firstName: 'John', lastName: 'Doe', - role: 'global:owner', + role: { slug: GLOBAL_OWNER_ROLE.slug }, }, credentialId: 'cred123', credentialType: 'github', @@ -689,7 +690,7 @@ describe('TelemetryEventRelay', () => { email: 'user@example.com', firstName: 'John', lastName: 'Doe', - role: 'global:owner', + role: { slug: GLOBAL_OWNER_ROLE.slug }, }, workflow: mock({ id: 'workflow123', name: 'Test Workflow', nodes: [] }), publicApi: false, @@ -718,7 +719,7 @@ describe('TelemetryEventRelay', () => { email: 'user@example.com', firstName: 'John', lastName: 'Doe', - role: 'global:owner', + role: { slug: GLOBAL_OWNER_ROLE.slug }, }, workflowId: 'workflow123', publicApi: false, @@ -740,7 +741,7 @@ describe('TelemetryEventRelay', () => { email: 'user@example.com', firstName: 'John', lastName: 'Doe', - role: 'global:owner', + role: { slug: GLOBAL_OWNER_ROLE.slug }, }, workflowId: 'workflow123', publicApi: false, @@ -762,7 +763,7 @@ describe('TelemetryEventRelay', () => { email: 'user@example.com', firstName: 'John', lastName: 'Doe', - role: 'global:owner', + role: { slug: GLOBAL_OWNER_ROLE.slug }, }, workflowId: 'workflow123', publicApi: false, @@ -808,7 +809,7 @@ describe('TelemetryEventRelay', () => { email: 'user@example.com', firstName: 'John', lastName: 'Doe', - role: 'global:owner', + role: { slug: GLOBAL_OWNER_ROLE.slug }, }, workflow: mock({ id: 'workflow123', name: 'Test Workflow', nodes: [] }), publicApi: false, @@ -856,7 +857,7 @@ describe('TelemetryEventRelay', () => { email: 'user@example.com', firstName: 'John', lastName: 'Doe', - role: 'global:owner', + role: { slug: GLOBAL_OWNER_ROLE.slug }, }, fieldsChanged: ['firstName', 'lastName'], }; @@ -876,7 +877,7 @@ describe('TelemetryEventRelay', () => { email: 'user@example.com', firstName: 'John', lastName: 'Doe', - role: 'global:owner', + role: { slug: GLOBAL_OWNER_ROLE.slug }, }, publicApi: false, targetUserOldStatus: 'active', @@ -904,7 +905,7 @@ describe('TelemetryEventRelay', () => { email: 'user@example.com', firstName: 'John', lastName: 'Doe', - role: 'global:owner', + role: { slug: GLOBAL_OWNER_ROLE.slug }, }, targetUserId: ['user456'], publicApi: false, @@ -930,7 +931,7 @@ describe('TelemetryEventRelay', () => { email: 'user@example.com', firstName: 'John', lastName: 'Doe', - role: 'global:owner', + role: { slug: GLOBAL_OWNER_ROLE.slug }, }, userType: 'email', wasDisabledLdapUser: false, @@ -1148,7 +1149,7 @@ describe('TelemetryEventRelay', () => { email: 'user@example.com', firstName: 'John', lastName: 'Doe', - role: 'global:owner', + role: { slug: GLOBAL_OWNER_ROLE.slug }, }, messageType: 'New user invite', publicApi: false, diff --git a/packages/cli/src/events/maps/relay.event-map.ts b/packages/cli/src/events/maps/relay.event-map.ts index 2d5fd0e2c7..0967c9314d 100644 --- a/packages/cli/src/events/maps/relay.event-map.ts +++ b/packages/cli/src/events/maps/relay.event-map.ts @@ -1,6 +1,5 @@ import type { AuthenticationMethod, ProjectRelation } from '@n8n/api-types'; import type { AuthProviderType, User, IWorkflowDb } from '@n8n/db'; -import type { GlobalRole } from '@n8n/permissions'; import type { IPersonalizationSurveyAnswersV4, IRun, @@ -17,7 +16,9 @@ export type UserLike = { email?: string; firstName?: string; lastName?: string; - role: string; + role: { + slug: string; + }; }; export type RelayEventMap = { @@ -368,14 +369,14 @@ export type RelayEventMap = { 'team-project-updated': { userId: string; - role: GlobalRole; + role: string; members: ProjectRelation[]; projectId: string; }; 'team-project-deleted': { userId: string; - role: GlobalRole; + role: string; projectId: string; removalType: 'transfer' | 'delete'; targetProjectId?: string; @@ -383,7 +384,7 @@ export type RelayEventMap = { 'team-project-created': { userId: string; - role: GlobalRole; + role: string; }; // #endregion diff --git a/packages/cli/src/executions/pre-execution-checks/__tests__/credentials-permission-checker.test.ts b/packages/cli/src/executions/pre-execution-checks/__tests__/credentials-permission-checker.test.ts index 7dc1685cb5..bb2f1b2a2f 100644 --- a/packages/cli/src/executions/pre-execution-checks/__tests__/credentials-permission-checker.test.ts +++ b/packages/cli/src/executions/pre-execution-checks/__tests__/credentials-permission-checker.test.ts @@ -1,4 +1,9 @@ -import type { Project, User, SharedCredentialsRepository } from '@n8n/db'; +import { + type Project, + type User, + type SharedCredentialsRepository, + GLOBAL_OWNER_ROLE, +} from '@n8n/db'; import { mock } from 'jest-mock-extended'; import type { INode } from 'n8n-workflow'; @@ -93,7 +98,7 @@ describe('CredentialsPermissionChecker', () => { }); it('should skip credential checks if the home project owner has global scope', async () => { - const projectOwner = mock({ role: 'global:owner' }); + const projectOwner = mock({ role: GLOBAL_OWNER_ROLE }); ownershipService.getPersonalProjectOwnerCached.mockResolvedValueOnce(projectOwner); await expect(permissionChecker.check(workflowId, [node])).resolves.not.toThrow(); diff --git a/packages/cli/src/ldap.ee/helpers.ee.ts b/packages/cli/src/ldap.ee/helpers.ee.ts index a58b5ae03d..074e49de97 100644 --- a/packages/cli/src/ldap.ee/helpers.ee.ts +++ b/packages/cli/src/ldap.ee/helpers.ee.ts @@ -7,6 +7,7 @@ import { AuthIdentityRepository, AuthProviderSyncHistoryRepository, UserRepository, + GLOBAL_MEMBER_ROLE, } from '@n8n/db'; import { Container } from '@n8n/di'; import { validate } from 'jsonschema'; @@ -91,6 +92,22 @@ export const getAuthIdentityByLdapId = async ( }); }; +/** + * Retrieve user by LDAP ID from database + * @param idAttributeValue - LDAP ID value + */ +export const getUserByLdapId = async (idAttributeValue: string) => { + return await Container.get(UserRepository).findOne({ + relations: { role: true }, + where: { + authIdentities: { + providerId: idAttributeValue, + providerType: 'ldap', + }, + }, + }); +}; + export const getUserByEmail = async (email: string): Promise => { return await Container.get(UserRepository).findOne({ where: { email }, @@ -150,7 +167,7 @@ export const mapLdapUserToDbUser = ( const [ldapId, data] = mapLdapAttributesToUser(ldapUser, ldapConfig); Object.assign(user, data); if (toCreate) { - user.role = 'global:member'; + user.role = GLOBAL_MEMBER_ROLE; user.password = randomString(8); user.disabled = false; } else { @@ -270,7 +287,7 @@ export const createLdapAuthIdentity = async (user: User, ldapId: string) => { export const createLdapUserOnLocalDb = async (data: Partial, ldapId: string) => { const { user } = await Container.get(UserRepository).createUserWithProject({ password: randomString(8), - role: 'global:member', + role: GLOBAL_MEMBER_ROLE, ...data, }); await createLdapAuthIdentity(user, ldapId); diff --git a/packages/cli/src/ldap.ee/ldap.service.ee.ts b/packages/cli/src/ldap.ee/ldap.service.ee.ts index 1dbf050b94..c111b3f43f 100644 --- a/packages/cli/src/ldap.ee/ldap.service.ee.ts +++ b/packages/cli/src/ldap.ee/ldap.service.ee.ts @@ -37,6 +37,7 @@ import { resolveEntryBinaryAttributes, saveLdapSynchronization, validateLdapConfigurationSchema, + getUserByLdapId, } from '@/ldap.ee/helpers.ee'; import { getCurrentAuthenticationMethod, @@ -504,6 +505,6 @@ export class LdapService { } // Retrieve the user again as user's data might have been updated - return (await getAuthIdentityByLdapId(ldapId))?.user; + return (await getUserByLdapId(ldapId)) ?? undefined; } } diff --git a/packages/cli/src/mfa/mfa.service.ts b/packages/cli/src/mfa/mfa.service.ts index 3355442ad2..60d2965720 100644 --- a/packages/cli/src/mfa/mfa.service.ts +++ b/packages/cli/src/mfa/mfa.service.ts @@ -122,7 +122,10 @@ export class MfaService { } async enableMfa(userId: string) { - const user = await this.userRepository.findOneByOrFail({ id: userId }); + const user = await this.userRepository.findOneOrFail({ + where: { id: userId }, + relations: ['role'], + }); user.mfaEnabled = true; return await this.userRepository.save(user); } diff --git a/packages/cli/src/modules/data-store/__tests__/data-store-aggregate.service.test.ts b/packages/cli/src/modules/data-store/__tests__/data-store-aggregate.service.test.ts index b2465216b9..e64c1cc685 100644 --- a/packages/cli/src/modules/data-store/__tests__/data-store-aggregate.service.test.ts +++ b/packages/cli/src/modules/data-store/__tests__/data-store-aggregate.service.test.ts @@ -1,5 +1,11 @@ import { createTeamProject, testDb, testModules } from '@n8n/backend-test-utils'; -import { ProjectRelationRepository, type Project, type User } from '@n8n/db'; +import { + GLOBAL_MEMBER_ROLE, + GLOBAL_OWNER_ROLE, + ProjectRelationRepository, + type Project, + type User, +} from '@n8n/db'; import { Container } from '@n8n/di'; import type { EntityManager } from '@n8n/typeorm'; import { mock } from 'jest-mock-extended'; @@ -41,7 +47,7 @@ describe('dataStoreAggregate', () => { beforeEach(async () => { project1 = await createTeamProject(); project2 = await createTeamProject(); - user = await createUser({ role: 'global:owner' }); + user = await createUser({ role: GLOBAL_OWNER_ROLE }); }); afterEach(async () => { @@ -108,7 +114,7 @@ describe('dataStoreAggregate', () => { it('should return an empty array if user has no access to any project', async () => { // ARRANGE - const currentUser = await createUser({ role: 'global:member' }); + const currentUser = await createUser({ role: GLOBAL_MEMBER_ROLE }); await dataStoreService.createDataStore(project1.id, { name: 'store1', 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 bb518ab0f4..d60620a69d 100644 --- a/packages/cli/src/permissions.ee/__tests__/check-access.test.ts +++ b/packages/cli/src/permissions.ee/__tests__/check-access.test.ts @@ -1,4 +1,5 @@ import { + GLOBAL_MEMBER_ROLE, ProjectRepository, SharedCredentialsRepository, SharedWorkflowRepository, @@ -62,7 +63,7 @@ describe('userHasScopes', () => { findByWorkflowMock.mockResolvedValueOnce([]); findByCredentialMock.mockResolvedValueOnce([]); - const user = { id: 'userId', scopes: [], role: 'global:member' } as unknown as User; + const user = { id: 'userId', scopes: [], role: GLOBAL_MEMBER_ROLE } as unknown as User; const scopes = ['workflow:read', 'credential:read'] as Scope[]; const params: { credentialId?: string; workflowId?: string; projectId?: string } = { @@ -140,7 +141,7 @@ describe('userHasScopes', () => { const user = { id: 'userId', scopes: userScopes, - role: 'global:member', + role: GLOBAL_MEMBER_ROLE, } as unknown as User; const scopes = [scope] as Scope[]; const params: { credentialId?: string; workflowId?: string; projectId?: string } = { diff --git a/packages/cli/src/public-api/v1/handlers/credentials/credentials.handler.ts b/packages/cli/src/public-api/v1/handlers/credentials/credentials.handler.ts index 2224f67c6d..d933531024 100644 --- a/packages/cli/src/public-api/v1/handlers/credentials/credentials.handler.ts +++ b/packages/cli/src/public-api/v1/handlers/credentials/credentials.handler.ts @@ -72,7 +72,7 @@ export = { const { id: credentialId } = req.params; let credential: CredentialsEntity | undefined; - if (!['global:owner', 'global:admin'].includes(req.user.role)) { + if (!['global:owner', 'global:admin'].includes(req.user.role.slug)) { const shared = await getSharedCredentials(req.user.id, credentialId); if (shared?.role === 'credential:owner') { diff --git a/packages/cli/src/public-api/v1/handlers/workflows/workflows.handler.ts b/packages/cli/src/public-api/v1/handlers/workflows/workflows.handler.ts index 8a85df8f4b..02ef3c8825 100644 --- a/packages/cli/src/public-api/v1/handlers/workflows/workflows.handler.ts +++ b/packages/cli/src/public-api/v1/handlers/workflows/workflows.handler.ts @@ -157,7 +157,7 @@ export = { ...(name !== undefined && { name: Like('%' + name.trim() + '%') }), }; - if (['global:owner', 'global:admin'].includes(req.user.role)) { + if (['global:owner', 'global:admin'].includes(req.user.role.slug)) { if (tags) { const workflowIds = await Container.get(TagRepository).getWorkflowIdsViaTags( parseTagNames(tags), 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 3fb3825847..f396d3ef50 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 @@ -44,7 +44,7 @@ export async function getSharedWorkflow( ): Promise { return await Container.get(SharedWorkflowRepository).findOne({ where: { - ...(!['global:owner', 'global:admin'].includes(user.role) && { userId: user.id }), + ...(!['global:owner', 'global:admin'].includes(user.role.slug) && { userId: user.id }), ...(workflowId && { workflowId }), }, relations: [ diff --git a/packages/cli/src/requests.ts b/packages/cli/src/requests.ts index bfde747102..7d64f06f26 100644 --- a/packages/cli/src/requests.ts +++ b/packages/cli/src/requests.ts @@ -8,7 +8,7 @@ import type { ListQueryDb, WorkflowHistory, } from '@n8n/db'; -import type { AssignableGlobalRole, GlobalRole, ProjectRole, Scope } from '@n8n/permissions'; +import type { AssignableGlobalRole, ProjectRole, Scope } from '@n8n/permissions'; import type { ICredentialDataDecryptedObject, INodeCredentialTestRequest, @@ -268,9 +268,7 @@ export declare namespace ActiveWorkflowRequest { // ---------------------------------- export declare namespace ProjectRequest { - type GetMyProjectsResponse = Array< - Project & { role: ProjectRole | GlobalRole; scopes?: Scope[] } - >; + type GetMyProjectsResponse = Array; type ProjectRelationResponse = { id: string; diff --git a/packages/cli/src/services/__tests__/active-workflows.service.test.ts b/packages/cli/src/services/__tests__/active-workflows.service.test.ts index f2984e6e28..11a5957f93 100644 --- a/packages/cli/src/services/__tests__/active-workflows.service.test.ts +++ b/packages/cli/src/services/__tests__/active-workflows.service.test.ts @@ -1,4 +1,4 @@ -import { WorkflowEntity } from '@n8n/db'; +import { GLOBAL_ADMIN_ROLE, GLOBAL_MEMBER_ROLE, WorkflowEntity } from '@n8n/db'; import type { User, SharedWorkflowRepository, WorkflowRepository } from '@n8n/db'; import { mock } from 'jest-mock-extended'; @@ -41,7 +41,7 @@ describe('ActiveWorkflowsService', () => { }); it('should return all workflow ids when user has full access', async () => { - user.role = 'global:admin'; + user.role = GLOBAL_ADMIN_ROLE; const ids = await service.getAllActiveIdsFor(user); expect(ids).toEqual(['2', '3', '4']); @@ -49,7 +49,7 @@ describe('ActiveWorkflowsService', () => { }); it('should filter out workflow ids that the user does not have access to', async () => { - user.role = 'global:member'; + user.role = GLOBAL_MEMBER_ROLE; sharedWorkflowRepository.getSharedWorkflowIds.mockResolvedValue(['3']); const ids = await service.getAllActiveIdsFor(user); 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 3356e42a54..18c5cb1e0c 100644 --- a/packages/cli/src/services/__tests__/credentials-finder.service.test.ts +++ b/packages/cli/src/services/__tests__/credentials-finder.service.test.ts @@ -1,4 +1,4 @@ -import { SharedCredentials } from '@n8n/db'; +import { GLOBAL_MEMBER_ROLE, GLOBAL_OWNER_ROLE, SharedCredentials } from '@n8n/db'; import type { CredentialsEntity, User } from '@n8n/db'; import { Container } from '@n8n/di'; import { In } from '@n8n/typeorm'; @@ -16,10 +16,10 @@ describe('CredentialsFinderService', () => { const sharedCredential = mock(); sharedCredential.credentials = mock({ id: credentialsId }); const owner = mock({ - role: 'global:owner', + role: GLOBAL_OWNER_ROLE, }); const member = mock({ - role: 'global:member', + role: GLOBAL_MEMBER_ROLE, id: 'test', }); diff --git a/packages/cli/src/services/__tests__/ownership.service.test.ts b/packages/cli/src/services/__tests__/ownership.service.test.ts index 2c307192b3..3105a7348b 100644 --- a/packages/cli/src/services/__tests__/ownership.service.test.ts +++ b/packages/cli/src/services/__tests__/ownership.service.test.ts @@ -9,6 +9,7 @@ import { ProjectRelationRepository, SharedWorkflowRepository, UserRepository, + GLOBAL_OWNER_ROLE, } from '@n8n/db'; import { mock } from 'jest-mock-extended'; import { v4 as uuid } from 'uuid'; @@ -218,7 +219,7 @@ describe('OwnershipService', () => { await ownershipService.getInstanceOwner(); expect(userRepository.findOneOrFail).toHaveBeenCalledWith({ - where: { role: 'global:owner' }, + where: { role: { slug: GLOBAL_OWNER_ROLE.slug } }, }); }); }); diff --git a/packages/cli/src/services/__tests__/public-api-key.service.test.ts b/packages/cli/src/services/__tests__/public-api-key.service.test.ts index 519b69ffc2..2ea748d894 100644 --- a/packages/cli/src/services/__tests__/public-api-key.service.test.ts +++ b/packages/cli/src/services/__tests__/public-api-key.service.test.ts @@ -1,6 +1,6 @@ import { testDb } from '@n8n/backend-test-utils'; import type { AuthenticatedRequest } from '@n8n/db'; -import { ApiKeyRepository, UserRepository } from '@n8n/db'; +import { ApiKeyRepository, GLOBAL_MEMBER_ROLE, GLOBAL_OWNER_ROLE, UserRepository } from '@n8n/db'; import { Container } from '@n8n/di'; import { getOwnerOnlyApiKeyScopes, type ApiKeyScope } from '@n8n/permissions'; import type { Response, NextFunction } from 'express'; @@ -427,7 +427,9 @@ describe('PublicApiKeyService', () => { // Act const result = publicApiKeyService.apiKeyHasValidScopesForRole( - 'global:owner', + { + role: GLOBAL_OWNER_ROLE, + }, ownerOnlyScopes, ); @@ -443,7 +445,9 @@ describe('PublicApiKeyService', () => { // Act const result = publicApiKeyService.apiKeyHasValidScopesForRole( - 'global:member', + { + role: GLOBAL_MEMBER_ROLE, + }, ownerOnlyScopes, ); diff --git a/packages/cli/src/services/__tests__/user.service.test.ts b/packages/cli/src/services/__tests__/user.service.test.ts index 651fcb1c14..5cf2025556 100644 --- a/packages/cli/src/services/__tests__/user.service.test.ts +++ b/packages/cli/src/services/__tests__/user.service.test.ts @@ -1,6 +1,6 @@ import { mockInstance } from '@n8n/backend-test-utils'; import { GlobalConfig } from '@n8n/config'; -import { User, UserRepository } from '@n8n/db'; +import { GLOBAL_MEMBER_ROLE, User, UserRepository } from '@n8n/db'; import { mock } from 'jest-mock-extended'; import { v4 as uuid } from 'uuid'; @@ -23,6 +23,7 @@ describe('UserService', () => { const commonMockUser = Object.assign(new User(), { id: uuid(), password: 'passwordHash', + role: GLOBAL_MEMBER_ROLE, }); describe('toPublic', () => { @@ -35,6 +36,7 @@ describe('UserService', () => { mfaRecoveryCodes: ['test'], updatedAt: new Date(), authIdentities: [], + role: GLOBAL_MEMBER_ROLE, }); type MaybeSensitiveProperties = Partial< @@ -55,13 +57,17 @@ describe('UserService', () => { const scoped = await userService.toPublic(commonMockUser, { withScopes: true }); const unscoped = await userService.toPublic(commonMockUser); - expect(scoped.globalScopes).toEqual([]); + expect(scoped.globalScopes).toEqual(GLOBAL_MEMBER_ROLE.scopes.map((s) => s.slug)); expect(unscoped.globalScopes).toBeUndefined(); }); it('should add invite URL if requested', async () => { - const firstUser = Object.assign(new User(), { id: uuid() }); - const secondUser = Object.assign(new User(), { id: uuid(), isPending: true }); + const firstUser = Object.assign(new User(), { id: uuid(), role: GLOBAL_MEMBER_ROLE }); + const secondUser = Object.assign(new User(), { + id: uuid(), + role: GLOBAL_MEMBER_ROLE, + isPending: true, + }); const withoutUrl = await userService.toPublic(secondUser); const withUrl = await userService.toPublic(secondUser, { diff --git a/packages/cli/src/services/access.service.ts b/packages/cli/src/services/access.service.ts index c81ed37175..4932e04ea8 100644 --- a/packages/cli/src/services/access.service.ts +++ b/packages/cli/src/services/access.service.ts @@ -17,7 +17,7 @@ export class AccessService { /** Whether a user has read access to a workflow based on their project and scope. */ async hasReadAccess(userId: User['id'], workflowId: Workflow['id']) { - const user = await this.userRepository.findOneBy({ id: userId }); + const user = await this.userRepository.findOne({ where: { id: userId }, relations: ['role'] }); if (!user) return false; diff --git a/packages/cli/src/services/ownership.service.ts b/packages/cli/src/services/ownership.service.ts index 9f7ad7d458..ffcd3cd996 100644 --- a/packages/cli/src/services/ownership.service.ts +++ b/packages/cli/src/services/ownership.service.ts @@ -1,5 +1,6 @@ import type { Project, User, ListQueryDb } from '@n8n/db'; import { + GLOBAL_OWNER_ROLE, ProjectRelationRepository, ProjectRepository, SharedWorkflowRepository, @@ -106,7 +107,7 @@ export class OwnershipService { async getInstanceOwner() { return await this.userRepository.findOneOrFail({ - where: { role: 'global:owner' }, + where: { role: { slug: GLOBAL_OWNER_ROLE.slug } }, }); } } diff --git a/packages/cli/src/services/public-api-key.service.ts b/packages/cli/src/services/public-api-key.service.ts index 58a80a5995..f5d7778597 100644 --- a/packages/cli/src/services/public-api-key.service.ts +++ b/packages/cli/src/services/public-api-key.service.ts @@ -2,7 +2,7 @@ import type { CreateApiKeyRequestDto, UnixTimestamp, UpdateApiKeyRequestDto } fr import type { AuthenticatedRequest, User } from '@n8n/db'; import { ApiKey, ApiKeyRepository, UserRepository } from '@n8n/db'; import { Service } from '@n8n/di'; -import type { GlobalRole, ApiKeyScope } from '@n8n/permissions'; +import type { ApiKeyScope, AuthPrincipal } from '@n8n/permissions'; import { getApiKeyScopesForRole, getOwnerOnlyApiKeyScopes } from '@n8n/permissions'; // eslint-disable-next-line n8n-local-rules/misplaced-n8n-typeorm-import import type { EntityManager } from '@n8n/typeorm'; @@ -79,12 +79,14 @@ export class PublicApiKeyService { } private async getUserForApiKey(apiKey: string) { - return await this.userRepository - .createQueryBuilder('user') - .innerJoin(ApiKey, 'apiKey', 'apiKey.userId = user.id') - .where('apiKey.apiKey = :apiKey', { apiKey }) - .select('user') - .getOne(); + return await this.userRepository.findOne({ + where: { + apiKeys: { + apiKey, + }, + }, + relations: ['role'], + }); } /** @@ -161,7 +163,7 @@ export class PublicApiKeyService { return decoded?.exp ?? null; }; - apiKeyHasValidScopesForRole(role: GlobalRole, apiKeyScopes: ApiKeyScope[]) { + apiKeyHasValidScopesForRole(role: AuthPrincipal, apiKeyScopes: ApiKeyScope[]) { const scopesForRole = getApiKeyScopesForRole(role); return apiKeyScopes.every((scope) => scopesForRole.includes(scope)); } diff --git a/packages/cli/src/services/role.service.ts b/packages/cli/src/services/role.service.ts index da1dd406d9..f86aff0902 100644 --- a/packages/cli/src/services/role.service.ts +++ b/packages/cli/src/services/role.service.ts @@ -9,7 +9,7 @@ import type { } from '@n8n/db'; import { Service } from '@n8n/di'; import type { AllRoleTypes, Scope } from '@n8n/permissions'; -import { ALL_ROLES, combineScopes, getRoleScopes } from '@n8n/permissions'; +import { ALL_ROLES, combineScopes, getAuthPrincipalScopes, getRoleScopes } from '@n8n/permissions'; import { UnexpectedError } from 'n8n-workflow'; import { License } from '@/license'; @@ -89,7 +89,7 @@ export class RoleService { shared: SharedCredentials[] | SharedWorkflow[], userProjectRelations: ProjectRelation[], ): Scope[] { - const globalScopes = getRoleScopes(user.role, [type]); + const globalScopes = getAuthPrincipalScopes(user, [type]); const scopesSet: Set = new Set(globalScopes); for (const sharedEntity of shared) { const pr = userProjectRelations.find( diff --git a/packages/cli/src/services/user.service.ts b/packages/cli/src/services/user.service.ts index 8d2e9b5894..9b5c8d1210 100644 --- a/packages/cli/src/services/user.service.ts +++ b/packages/cli/src/services/user.service.ts @@ -64,14 +64,16 @@ export class UserService { mfaAuthenticated?: boolean; }, ) { - const { password, updatedAt, authIdentities, mfaRecoveryCodes, mfaSecret, ...rest } = user; + const { password, updatedAt, authIdentities, mfaRecoveryCodes, mfaSecret, role, ...rest } = + user; const providerType = authIdentities?.[0]?.providerType; let publicUser: PublicUser = { ...rest, + role: role.slug, signInType: providerType ?? 'email', - isOwner: user.role === 'global:owner', + isOwner: user.role.slug === 'global:owner', }; if (options?.withInviteUrl && !options?.inviterId) { @@ -214,7 +216,12 @@ export class UserService { await Promise.all( toCreateUsers.map(async ({ email, role }) => { const { user: savedUser } = await this.userRepository.createUserWithProject( - { email, role }, + { + email, + role: { + slug: role, + }, + }, transactionManager, ); createdUsers.set(email, savedUser.id); @@ -240,11 +247,11 @@ export class UserService { async changeUserRole(user: User, targetUser: User, newRole: RoleChangeRequestDto) { return await this.userRepository.manager.transaction(async (trx) => { - await trx.update(User, { id: targetUser.id }, { role: newRole.newRoleName }); + await trx.update(User, { id: targetUser.id }, { role: { slug: newRole.newRoleName } }); const adminDowngradedToMember = - user.role === 'global:owner' && - targetUser.role === 'global:admin' && + user.role.slug === 'global:owner' && + targetUser.role.slug === 'global:admin' && newRole.newRoleName === 'global:member'; if (adminDowngradedToMember) { diff --git a/packages/cli/src/sso.ee/oidc/oidc.service.ee.ts b/packages/cli/src/sso.ee/oidc/oidc.service.ee.ts index 47100d0460..e716c52056 100644 --- a/packages/cli/src/sso.ee/oidc/oidc.service.ee.ts +++ b/packages/cli/src/sso.ee/oidc/oidc.service.ee.ts @@ -5,6 +5,7 @@ import { AuthIdentity, AuthIdentityRepository, isValidEmail, + GLOBAL_MEMBER_ROLE, SettingsRepository, type User, UserRepository, @@ -111,14 +112,21 @@ export class OidcService { const openidUser = await this.authIdentityRepository.findOne({ where: { providerId: claims.sub, providerType: 'oidc' }, - relations: ['user'], + relations: { + user: { + role: true, + }, + }, }); if (openidUser) { return openidUser.user; } - const foundUser = await this.userRepository.findOneBy({ email: userInfo.email }); + const foundUser = await this.userRepository.findOne({ + where: { email: userInfo.email }, + relations: ['authIdentities', 'role'], + }); if (foundUser) { this.logger.debug( @@ -143,7 +151,7 @@ export class OidcService { lastName: userInfo.family_name, email: userInfo.email, authIdentities: [], - role: 'global:member', + role: GLOBAL_MEMBER_ROLE, password: 'no password set', }, trx, diff --git a/packages/cli/src/sso.ee/oidc/routes/__tests__/oidc.controller.ee.test.ts b/packages/cli/src/sso.ee/oidc/routes/__tests__/oidc.controller.ee.test.ts index 93208f0db2..cff5369540 100644 --- a/packages/cli/src/sso.ee/oidc/routes/__tests__/oidc.controller.ee.test.ts +++ b/packages/cli/src/sso.ee/oidc/routes/__tests__/oidc.controller.ee.test.ts @@ -1,4 +1,4 @@ -import type { User } from '@n8n/db'; +import { GLOBAL_MEMBER_ROLE, type User } from '@n8n/db'; import { type Request, type Response } from 'express'; import { mock } from 'jest-mock-extended'; @@ -21,7 +21,7 @@ const user = mock({ lastName: 'User', password: 'password', authIdentities: [], - role: 'global:member', + role: GLOBAL_MEMBER_ROLE, }); describe('OidcController', () => { diff --git a/packages/cli/src/sso.ee/saml/__tests__/saml-helpers.test.ts b/packages/cli/src/sso.ee/saml/__tests__/saml-helpers.test.ts index e9f9917e86..7ad9b2efb7 100644 --- a/packages/cli/src/sso.ee/saml/__tests__/saml-helpers.test.ts +++ b/packages/cli/src/sso.ee/saml/__tests__/saml-helpers.test.ts @@ -29,6 +29,7 @@ describe('sso/saml/samlHelpers', () => { userPrincipalName: 'Huh?', }; + userRepository.findOne.mockImplementationOnce(async (user) => user as User); userRepository.save.mockImplementationOnce(async (user) => user as User); // diff --git a/packages/cli/src/sso.ee/saml/routes/__tests__/saml.controller.ee.test.ts b/packages/cli/src/sso.ee/saml/routes/__tests__/saml.controller.ee.test.ts index e88c2a5ce3..bbafc277a1 100644 --- a/packages/cli/src/sso.ee/saml/routes/__tests__/saml.controller.ee.test.ts +++ b/packages/cli/src/sso.ee/saml/routes/__tests__/saml.controller.ee.test.ts @@ -1,4 +1,4 @@ -import type { User } from '@n8n/db'; +import { GLOBAL_OWNER_ROLE, type User } from '@n8n/db'; import { type Response } from 'express'; import { mock } from 'jest-mock-extended'; @@ -30,7 +30,7 @@ const user = mock({ id: '123', password: 'password', authIdentities: [], - role: 'global:owner', + role: GLOBAL_OWNER_ROLE, }); const attributes: SamlUserAttributes = { diff --git a/packages/cli/src/sso.ee/saml/saml-helpers.ts b/packages/cli/src/sso.ee/saml/saml-helpers.ts index cebbac7ea4..52079b1fb5 100644 --- a/packages/cli/src/sso.ee/saml/saml-helpers.ts +++ b/packages/cli/src/sso.ee/saml/saml-helpers.ts @@ -79,7 +79,7 @@ export async function createUserFromSamlAttributes(attributes: SamlUserAttribute email: attributes.email.toLowerCase(), firstName: attributes.firstName, lastName: attributes.lastName, - role: 'global:member', + role: { slug: 'global:member' }, // generates a password that is not used or known to the user password: await Container.get(PasswordUtility).hash(randomPassword), }, @@ -118,8 +118,14 @@ export async function updateUserFromSamlAttributes( user.firstName = attributes.firstName; user.lastName = attributes.lastName; const resultUser = await Container.get(UserRepository).save(user, { transaction: false }); - if (!resultUser) throw new AuthError('Could not create User'); - return resultUser; + if (!resultUser) throw new AuthError('Could not update User'); + const userWithRole = await Container.get(UserRepository).findOne({ + where: { id: resultUser.id }, + relations: ['role'], + transaction: false, + }); + if (!userWithRole) throw new AuthError('Failed to fetch user!'); + return userWithRole; } type GetMappedSamlReturn = { diff --git a/packages/cli/src/sso.ee/saml/saml.service.ee.ts b/packages/cli/src/sso.ee/saml/saml.service.ee.ts index b96c3fe147..327b8417ce 100644 --- a/packages/cli/src/sso.ee/saml/saml.service.ee.ts +++ b/packages/cli/src/sso.ee/saml/saml.service.ee.ts @@ -204,7 +204,7 @@ export class SamlService { const user = await this.userRepository.findOne({ where: { email: lowerCasedEmail }, - relations: ['authIdentities'], + relations: ['authIdentities', 'role'], }); if (user) { // Login path for existing users that are fully set up and that have a SAML authIdentity set up diff --git a/packages/cli/src/workflows/workflows.controller.ts b/packages/cli/src/workflows/workflows.controller.ts index 618d03fc4c..a15044719e 100644 --- a/packages/cli/src/workflows/workflows.controller.ts +++ b/packages/cli/src/workflows/workflows.controller.ts @@ -564,7 +564,7 @@ export class WorkflowsController { @Post('/with-node-types') async getWorkflowsWithNodesIncluded(req: AuthenticatedRequest, res: express.Response) { try { - const hasPermission = req.user.role === ROLE.Owner || req.user.role === ROLE.Admin; + const hasPermission = req.user.role.slug === ROLE.Owner || req.user.role.slug === ROLE.Admin; if (!hasPermission) { res.json({ data: [], count: 0 }); diff --git a/packages/cli/test/integration/api-keys.api.test.ts b/packages/cli/test/integration/api-keys.api.test.ts index 8f6ff0e0c0..573c78308b 100644 --- a/packages/cli/test/integration/api-keys.api.test.ts +++ b/packages/cli/test/integration/api-keys.api.test.ts @@ -2,7 +2,7 @@ import type { ApiKeyWithRawValue } from '@n8n/api-types'; import { testDb, randomValidPassword, mockInstance } from '@n8n/backend-test-utils'; import { GlobalConfig } from '@n8n/config'; import type { User } from '@n8n/db'; -import { ApiKeyRepository } from '@n8n/db'; +import { ApiKeyRepository, GLOBAL_MEMBER_ROLE, GLOBAL_OWNER_ROLE } from '@n8n/db'; import { Container } from '@n8n/di'; import { getApiKeyScopesForRole, @@ -59,7 +59,7 @@ describe('Owner shell', () => { let ownerShell: User; beforeEach(async () => { - ownerShell = await createUserShell('global:owner'); + ownerShell = await createUserShell(GLOBAL_OWNER_ROLE); }); test('POST /api-keys should create an api key with no expiration', async () => { @@ -304,9 +304,9 @@ describe('Owner shell', () => { const scopes = apiKeyScopesResponse.body.data as ApiKeyScope[]; - const scopesForRole = getApiKeyScopesForRole(ownerShell.role); + const scopesForRole = getApiKeyScopesForRole(ownerShell); - expect(scopes).toEqual(scopesForRole); + expect(scopes.sort()).toEqual(scopesForRole.sort()); }); }); @@ -317,7 +317,7 @@ describe('Member', () => { beforeEach(async () => { member = await createUser({ password: memberPassword, - role: 'global:member', + role: GLOBAL_MEMBER_ROLE, }); await utils.setInstanceOwnerSetUp(true); }); @@ -328,6 +328,7 @@ 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(); @@ -492,8 +493,8 @@ describe('Member', () => { const scopes = apiKeyScopesResponse.body.data as ApiKeyScope[]; - const scopesForRole = getApiKeyScopesForRole(member.role); + const scopesForRole = getApiKeyScopesForRole(member); - expect(scopes).toEqual(scopesForRole); + expect(scopes.sort()).toEqual(scopesForRole.sort()); }); }); diff --git a/packages/cli/test/integration/auth.api.test.ts b/packages/cli/test/integration/auth.api.test.ts index 5fcd891a50..3412daf080 100644 --- a/packages/cli/test/integration/auth.api.test.ts +++ b/packages/cli/test/integration/auth.api.test.ts @@ -1,6 +1,6 @@ import { randomValidPassword, testDb } from '@n8n/backend-test-utils'; import type { User } from '@n8n/db'; -import { UserRepository } from '@n8n/db'; +import { GLOBAL_MEMBER_ROLE, GLOBAL_OWNER_ROLE, UserRepository } from '@n8n/db'; import { Container } from '@n8n/di'; import validator from 'validator'; @@ -36,7 +36,7 @@ describe('POST /login', () => { beforeEach(async () => { owner = await createUser({ password: ownerPassword, - role: 'global:owner', + role: GLOBAL_OWNER_ROLE, }); }); @@ -140,7 +140,7 @@ describe('POST /login', () => { license.setQuota('quota:users', 0); const ownerUser = await createUser({ password: randomValidPassword(), - role: 'global:owner', + role: GLOBAL_OWNER_ROLE, }); const response = await testServer.authAgentFor(ownerUser).get('/login'); @@ -182,7 +182,7 @@ describe('GET /login', () => { }); test('should return logged-in owner shell', async () => { - const ownerShell = await createUserShell('global:owner'); + const ownerShell = await createUserShell(GLOBAL_OWNER_ROLE); const response = await testServer.authAgentFor(ownerShell).get('/login'); @@ -217,7 +217,7 @@ describe('GET /login', () => { }); test('should return logged-in member shell', async () => { - const memberShell = await createUserShell('global:member'); + const memberShell = await createUserShell(GLOBAL_MEMBER_ROLE); const response = await testServer.authAgentFor(memberShell).get('/login'); @@ -252,7 +252,7 @@ describe('GET /login', () => { }); test('should return logged-in owner', async () => { - const owner = await createUser({ role: 'global:owner' }); + const owner = await createUser({ role: GLOBAL_OWNER_ROLE }); const response = await testServer.authAgentFor(owner).get('/login'); @@ -287,7 +287,7 @@ describe('GET /login', () => { }); test('should return logged-in member', async () => { - const member = await createUser({ role: 'global:member' }); + const member = await createUser({ role: { slug: 'global:member' } }); const response = await testServer.authAgentFor(member).get('/login'); @@ -326,13 +326,13 @@ describe('GET /resolve-signup-token', () => { beforeEach(async () => { owner = await createUser({ password: ownerPassword, - role: 'global:owner', + role: GLOBAL_OWNER_ROLE, }); authOwnerAgent = testServer.authAgentFor(owner); }); test('should validate invite token', async () => { - const memberShell = await createUserShell('global:member'); + const memberShell = await createUserShell(GLOBAL_MEMBER_ROLE); const response = await authOwnerAgent .get('/resolve-signup-token') @@ -352,7 +352,7 @@ describe('GET /resolve-signup-token', () => { test('should return 403 if user quota reached', async () => { license.setQuota('quota:users', 0); - const memberShell = await createUserShell('global:member'); + const memberShell = await createUserShell(GLOBAL_MEMBER_ROLE); const response = await authOwnerAgent .get('/resolve-signup-token') @@ -363,7 +363,7 @@ describe('GET /resolve-signup-token', () => { }); test('should fail with invalid inputs', async () => { - const { id: inviteeId } = await createUser({ role: 'global:member' }); + const { id: inviteeId } = await createUser({ role: { slug: 'global:member' } }); const first = await authOwnerAgent.get('/resolve-signup-token').query({ inviterId: owner.id }); @@ -396,7 +396,7 @@ describe('GET /resolve-signup-token', () => { describe('POST /logout', () => { test('should log user out', async () => { - const owner = await createUser({ role: 'global:owner' }); + const owner = await createUser({ role: GLOBAL_OWNER_ROLE }); const ownerAgent = testServer.authAgentFor(owner); // @ts-expect-error `accessInfo` types are incorrect const cookie = ownerAgent.jar.getCookie(AUTH_COOKIE_NAME, { path: '/' }); diff --git a/packages/cli/test/integration/auth.mw.test.ts b/packages/cli/test/integration/auth.mw.test.ts index d9f1dbd872..ec64df904b 100644 --- a/packages/cli/test/integration/auth.mw.test.ts +++ b/packages/cli/test/integration/auth.mw.test.ts @@ -40,7 +40,7 @@ describe('Auth Middleware', () => { describe('Routes requiring Authorization', () => { let authMemberAgent: SuperAgentTest; beforeAll(async () => { - const member = await createUser({ role: 'global:member' }); + const member = await createUser({ role: { slug: 'global:member' } }); authMemberAgent = testServer.authAgentFor(member); }); diff --git a/packages/cli/test/integration/commands/ldap/reset.test.ts b/packages/cli/test/integration/commands/ldap/reset.test.ts index 3be9485658..725f53a492 100644 --- a/packages/cli/test/integration/commands/ldap/reset.test.ts +++ b/packages/cli/test/integration/commands/ldap/reset.test.ts @@ -61,7 +61,7 @@ describe('--deleteWorkflowsAndCredentials', () => { // // ARRANGE // - const member = await createLdapUser({ role: 'global:member' }, uuid()); + const member = await createLdapUser({ role: { slug: 'global:member' } }, uuid()); const memberProject = await getPersonalProject(member); const workflow = await createWorkflow({}, member); const credential = await saveCredential(randomCredentialPayload(), { @@ -166,7 +166,7 @@ describe('--userId', () => { // // ARRANGE // - const member = await createLdapUser({ role: 'global:member' }, uuid()); + const member = await createLdapUser({ role: { slug: 'global:member' } }, uuid()); await expect(command.run([`--userId=${member.id}`])).rejects.toThrowError( `Can't migrate workflows and credentials to the user with the ID ${member.id}. That user was created via LDAP and will be deleted as well.`, @@ -177,7 +177,7 @@ describe('--userId', () => { // // ARRANGE // - const member = await createLdapUser({ role: 'global:member' }, uuid()); + const member = await createLdapUser({ role: { slug: 'global:member' } }, uuid()); const memberProject = await getPersonalProject(member); const workflow = await createWorkflow({}, member); const credential = await saveCredential(randomCredentialPayload(), { @@ -242,7 +242,7 @@ describe('--projectId', () => { // // ARRANGE // - const member = await createLdapUser({ role: 'global:member' }, uuid()); + const member = await createLdapUser({ role: { slug: 'global:member' } }, uuid()); const memberProject = await getPersonalProject(member); await expect(command.run([`--projectId=${memberProject.id}`])).rejects.toThrowError( @@ -254,7 +254,7 @@ describe('--projectId', () => { // // ARRANGE // - const member = await createLdapUser({ role: 'global:member' }, uuid()); + const member = await createLdapUser({ role: { slug: 'global:member' } }, uuid()); const memberProject = await getPersonalProject(member); const workflow = await createWorkflow({}, member); const credential = await saveCredential(randomCredentialPayload(), { @@ -310,7 +310,7 @@ describe('--projectId', () => { // // ARRANGE // - const member = await createLdapUser({ role: 'global:member' }, uuid()); + const member = await createLdapUser({ role: { slug: 'global:member' } }, uuid()); const memberProject = await getPersonalProject(member); const workflow = await createWorkflow({}, member); const credential = await saveCredential(randomCredentialPayload(), { diff --git a/packages/cli/test/integration/commands/reset.cmd.test.ts b/packages/cli/test/integration/commands/reset.cmd.test.ts index f5968db155..e1262d5fe3 100644 --- a/packages/cli/test/integration/commands/reset.cmd.test.ts +++ b/packages/cli/test/integration/commands/reset.cmd.test.ts @@ -12,6 +12,7 @@ import { SharedCredentialsRepository, SharedWorkflowRepository, UserRepository, + GLOBAL_OWNER_ROLE, } from '@n8n/db'; import { Container } from '@n8n/di'; @@ -35,7 +36,7 @@ test('user-management:reset should reset DB to default user state', async () => // // ARRANGE // - const owner = await createUser({ role: 'global:owner' }); + const owner = await createUser({ role: GLOBAL_OWNER_ROLE }); const ownerProject = await getPersonalProject(owner); // should be deleted @@ -70,7 +71,7 @@ test('user-management:reset should reset DB to default user state', async () => // check if the owner account was reset: await expect( - Container.get(UserRepository).findOneBy({ role: 'global:owner' }), + Container.get(UserRepository).findOneBy({ role: { slug: GLOBAL_OWNER_ROLE.slug } }), ).resolves.toMatchObject({ email: null, firstName: null, @@ -80,7 +81,9 @@ test('user-management:reset should reset DB to default user state', async () => }); // all members were deleted: - const members = await Container.get(UserRepository).findOneBy({ role: 'global:member' }); + const members = await Container.get(UserRepository).findOneBy({ + role: { slug: 'global:member' }, + }); expect(members).toBeNull(); // all workflows are owned by the owner: 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 ca00de5f99..0101a847bb 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 @@ -6,7 +6,12 @@ import { randomValidPassword, } from '@n8n/backend-test-utils'; import type { User } from '@n8n/db'; -import { ProjectRelationRepository, UserRepository } from '@n8n/db'; +import { + GLOBAL_ADMIN_ROLE, + GLOBAL_MEMBER_ROLE, + ProjectRelationRepository, + UserRepository, +} from '@n8n/db'; import { Container } from '@n8n/di'; import { Not } from '@n8n/typeorm'; @@ -52,7 +57,7 @@ describe('InvitationController', () => { describe('POST /invitations/:id/accept', () => { test('should fill out a member shell', async () => { - const memberShell = await createUserShell('global:member'); + const memberShell = await createUserShell(GLOBAL_MEMBER_ROLE); const memberProps = { inviterId: instanceOwner.id, @@ -83,7 +88,7 @@ describe('InvitationController', () => { }); test('should fill out an admin shell', async () => { - const adminShell = await createUserShell('global:admin'); + const adminShell = await createUserShell(GLOBAL_ADMIN_ROLE); const memberProps = { inviterId: instanceOwner.id, @@ -116,7 +121,7 @@ describe('InvitationController', () => { test('should fail with invalid payloads', async () => { const memberShell = await userRepository.save({ email: randomEmail(), - role: 'global:member', + role: { slug: 'global:member' }, }); const invalidPaylods = [ @@ -374,7 +379,7 @@ describe('InvitationController', () => { mailer.invite.mockResolvedValue({ emailSent: true }); const member = await createMember(); - const memberShell = await createUserShell('global:member'); + const memberShell = await createUserShell(GLOBAL_MEMBER_ROLE); const newUserEmail = randomEmail(); const existingUserEmails = [member.email]; diff --git a/packages/cli/test/integration/credentials/credentials.api.ee.test.ts b/packages/cli/test/integration/credentials/credentials.api.ee.test.ts index ceafb94eda..69d487489d 100644 --- a/packages/cli/test/integration/credentials/credentials.api.ee.test.ts +++ b/packages/cli/test/integration/credentials/credentials.api.ee.test.ts @@ -9,7 +9,7 @@ import { mockInstance, } from '@n8n/backend-test-utils'; import type { Project, User, ListQueryDb } from '@n8n/db'; -import { ProjectRepository, SharedCredentialsRepository } from '@n8n/db'; +import { GLOBAL_MEMBER_ROLE, ProjectRepository, SharedCredentialsRepository } from '@n8n/db'; import { Container } from '@n8n/di'; import type { ProjectRole } from '@n8n/permissions'; import { In } from '@n8n/typeorm'; @@ -68,10 +68,10 @@ beforeEach(async () => { admin = await createAdmin(); ownerPersonalProject = await projectRepository.getPersonalProjectForUserOrFail(owner.id); - member = await createUser({ role: 'global:member' }); + member = await createUser({ role: { slug: 'global:member' } }); memberPersonalProject = await projectRepository.getPersonalProjectForUserOrFail(member.id); - anotherMember = await createUser({ role: 'global:member' }); + anotherMember = await createUser({ role: { slug: 'global:member' } }); anotherMemberPersonalProject = await projectRepository.getPersonalProjectForUserOrFail( anotherMember.id, ); @@ -110,7 +110,7 @@ describe('POST /credentials', () => { describe('GET /credentials', () => { test('should return all creds for owner', async () => { const [member1, member2, member3] = await createManyUsers(3, { - role: 'global:member', + role: { slug: 'global:member' }, }); const member1PersonalProject = await projectRepository.getPersonalProjectForUserOrFail( member1.id, @@ -183,7 +183,7 @@ describe('GET /credentials', () => { test('should return only relevant creds for member', async () => { const [member1, member2] = await createManyUsers(2, { - role: 'global:member', + role: { slug: 'global:member' }, }); const member1PersonalProject = await projectRepository.getPersonalProjectForUserOrFail( member1.id, @@ -579,7 +579,7 @@ describe('GET /credentials/:id', () => { test('should retrieve non-owned cred for owner', async () => { const [member1, member2] = await createManyUsers(2, { - role: 'global:member', + role: { slug: 'global:member' }, }); const member1PersonalProject = await projectRepository.getPersonalProjectForUserOrFail( member1.id, @@ -626,7 +626,7 @@ describe('GET /credentials/:id', () => { test('should retrieve owned cred for member', async () => { const [member1, member2, member3] = await createManyUsers(3, { - role: 'global:member', + role: { slug: 'global:member' }, }); const member1PersonalProject = await projectRepository.getPersonalProjectForUserOrFail( member1.id, @@ -745,7 +745,7 @@ describe('PUT /credentials/:id/share', () => { const savedCredential = await saveCredential(randomCredentialPayload(), { user: owner }); const [member1, member2, member3, member4, member5] = await createManyUsers(5, { - role: 'global:member', + role: { slug: 'global:member' }, }); // TODO: write helper for getting multiple personal projects by user id const shareWithProjectIds = ( @@ -793,7 +793,7 @@ describe('PUT /credentials/:id/share', () => { test('should share the credential with the provided userIds', async () => { const [member1, member2, member3] = await createManyUsers(3, { - role: 'global:member', + role: { slug: 'global:member' }, }); const projectIds = ( await Promise.all([ @@ -876,7 +876,7 @@ describe('PUT /credentials/:id/share', () => { test('should respond 403 for non-owned credentials for non-shared members sharing', async () => { const savedCredential = await saveCredential(randomCredentialPayload(), { user: member }); - const tempUser = await createUser({ role: 'global:member' }); + const tempUser = await createUser({ role: { slug: 'global:member' } }); const tempUserPersonalProject = await projectRepository.getPersonalProjectForUserOrFail( tempUser.id, ); @@ -910,7 +910,7 @@ describe('PUT /credentials/:id/share', () => { }); test('should not ignore pending sharee', async () => { - const memberShell = await createUserShell('global:member'); + const memberShell = await createUserShell(GLOBAL_MEMBER_ROLE); const memberShellPersonalProject = await projectRepository.getPersonalProjectForUserOrFail( memberShell.id, ); @@ -1019,7 +1019,7 @@ describe('PUT /credentials/:id/share', () => { const savedCredential = await saveCredential(randomCredentialPayload(), { user: owner }); const [member1, member2] = await createManyUsers(2, { - role: 'global:member', + role: { slug: 'global:member' }, }); await shareCredentialWithUsers(savedCredential, [member1, member2]); @@ -1045,7 +1045,7 @@ describe('PUT /credentials/:id/share', () => { const savedCredential = await saveCredential(randomCredentialPayload(), { user: owner }); const [member1, member2] = await createManyUsers(2, { - role: 'global:member', + role: { slug: 'global:member' }, }); await shareCredentialWithUsers(savedCredential, [member1, member2]); diff --git a/packages/cli/test/integration/credentials/credentials.api.test.ts b/packages/cli/test/integration/credentials/credentials.api.test.ts index 9a4cbb706f..edbdc8e3e7 100644 --- a/packages/cli/test/integration/credentials/credentials.api.test.ts +++ b/packages/cli/test/integration/credentials/credentials.api.test.ts @@ -103,7 +103,7 @@ describe('GET /credentials', () => { test('should return only own creds for member', async () => { const [member1, member2] = await createManyUsers(2, { - role: 'global:member', + role: { slug: 'global:member' }, }); const [savedCredential1] = await Promise.all([ @@ -125,7 +125,7 @@ describe('GET /credentials', () => { test('should return scopes when ?includeScopes=true', async () => { const [member1, member2] = await createManyUsers(2, { - role: 'global:member', + role: { slug: 'global:member' }, }); const teamProject = await createTeamProject(undefined, member1); @@ -239,7 +239,7 @@ describe('GET /credentials', () => { test('should return data when ?includeData=true', async () => { // ARRANGE const [actor, otherMember] = await createManyUsers(2, { - role: 'global:member', + role: { slug: 'global:member' }, }); const teamProjectViewer = await createTeamProject(undefined); diff --git a/packages/cli/test/integration/environments/source-control.api.test.ts b/packages/cli/test/integration/environments/source-control.api.test.ts index 0fc559f552..91c39335d9 100644 --- a/packages/cli/test/integration/environments/source-control.api.test.ts +++ b/packages/cli/test/integration/environments/source-control.api.test.ts @@ -1,6 +1,6 @@ import type { SourceControlledFile } from '@n8n/api-types'; import { mockInstance } from '@n8n/backend-test-utils'; -import type { User } from '@n8n/db'; +import { GLOBAL_OWNER_ROLE, type User } from '@n8n/db'; import { Container } from '@n8n/di'; import { SourceControlPreferencesService } from '@/environments.ee/source-control/source-control-preferences.service.ee'; @@ -24,7 +24,7 @@ const testServer = utils.setupTestServer({ let sourceControlPreferencesService: SourceControlPreferencesService; beforeAll(async () => { - owner = await createUser({ role: 'global:owner' }); + owner = await createUser({ role: GLOBAL_OWNER_ROLE }); authOwnerAgent = testServer.authAgentFor(owner); sourceControlPreferencesService = Container.get(SourceControlPreferencesService); diff --git a/packages/cli/test/integration/environments/source-control.service.test.ts b/packages/cli/test/integration/environments/source-control.service.test.ts index 3491f57ea2..ebecf561e6 100644 --- a/packages/cli/test/integration/environments/source-control.service.test.ts +++ b/packages/cli/test/integration/environments/source-control.service.test.ts @@ -4,6 +4,9 @@ import { CredentialsEntity, type Folder, FolderRepository, + GLOBAL_ADMIN_ROLE, + GLOBAL_MEMBER_ROLE, + GLOBAL_OWNER_ROLE, Project, type TagEntity, TagRepository, @@ -217,10 +220,10 @@ describe('SourceControlService', () => { */ [globalAdmin, globalOwner, globalMember, projectAdmin] = await Promise.all([ - await createUser({ role: 'global:admin' }), - await createUser({ role: 'global:owner' }), - await createUser({ role: 'global:member' }), - await createUser({ role: 'global:member' }), + await createUser({ role: GLOBAL_ADMIN_ROLE }), + await createUser({ role: GLOBAL_OWNER_ROLE }), + await createUser({ role: GLOBAL_MEMBER_ROLE }), + await createUser({ role: GLOBAL_MEMBER_ROLE }), ]); [projectA, projectB] = await Promise.all([ diff --git a/packages/cli/test/integration/evaluation/test-runs.api.test.ts b/packages/cli/test/integration/evaluation/test-runs.api.test.ts index 40916fd23a..f08df23429 100644 --- a/packages/cli/test/integration/evaluation/test-runs.api.test.ts +++ b/packages/cli/test/integration/evaluation/test-runs.api.test.ts @@ -1,6 +1,11 @@ import { createWorkflow, testDb } from '@n8n/backend-test-utils'; import type { User } from '@n8n/db'; -import { ProjectRepository, TestRunRepository } from '@n8n/db'; +import { + GLOBAL_MEMBER_ROLE, + GLOBAL_OWNER_ROLE, + ProjectRepository, + TestRunRepository, +} from '@n8n/db'; import { Container } from '@n8n/di'; import { mockInstance } from 'n8n-core/test/utils'; import type { IWorkflowBase } from 'n8n-workflow'; @@ -25,7 +30,7 @@ const testServer = utils.setupTestServer({ }); beforeAll(async () => { - ownerShell = await createUserShell('global:owner'); + ownerShell = await createUserShell(GLOBAL_OWNER_ROLE); authOwnerAgent = testServer.authAgentFor(ownerShell); }); @@ -113,7 +118,7 @@ describe('GET /workflows/:workflowId/test-runs', () => { }); test('should retrieve list of test runs for a shared workflow', async () => { - const memberShell = await createUserShell('global:member'); + const memberShell = await createUserShell(GLOBAL_MEMBER_ROLE); const memberAgent = testServer.authAgentFor(memberShell); const memberPersonalProject = await Container.get( ProjectRepository, @@ -171,7 +176,7 @@ describe('GET /workflows/:workflowId/test-runs/:id', () => { }); test('should retrieve test run of a shared workflow', async () => { - const memberShell = await createUserShell('global:member'); + const memberShell = await createUserShell(GLOBAL_MEMBER_ROLE); const memberAgent = testServer.authAgentFor(memberShell); const memberPersonalProject = await Container.get( ProjectRepository, @@ -345,7 +350,7 @@ describe('GET /workflows/:workflowId/test-runs/:id/test-cases', () => { }); test('should return test cases for a shared workflow', async () => { - const memberShell = await createUserShell('global:member'); + const memberShell = await createUserShell(GLOBAL_MEMBER_ROLE); const memberAgent = testServer.authAgentFor(memberShell); const memberPersonalProject = await Container.get( ProjectRepository, diff --git a/packages/cli/test/integration/eventbus.ee.test.ts b/packages/cli/test/integration/eventbus.ee.test.ts index 1787176571..122ada4760 100644 --- a/packages/cli/test/integration/eventbus.ee.test.ts +++ b/packages/cli/test/integration/eventbus.ee.test.ts @@ -1,5 +1,5 @@ import { mockInstance } from '@n8n/backend-test-utils'; -import type { User } from '@n8n/db'; +import { GLOBAL_OWNER_ROLE, type User } from '@n8n/db'; import { Container } from '@n8n/di'; import axios from 'axios'; import type { @@ -89,7 +89,7 @@ const testServer = utils.setupTestServer({ }); beforeAll(async () => { - owner = await createUser({ role: 'global:owner' }); + owner = await createUser({ role: GLOBAL_OWNER_ROLE }); authOwnerAgent = testServer.authAgentFor(owner); mockedSyslog.createClient.mockImplementation(() => new syslog.Client()); diff --git a/packages/cli/test/integration/eventbus.test.ts b/packages/cli/test/integration/eventbus.test.ts index 86bfec4a39..48cc184489 100644 --- a/packages/cli/test/integration/eventbus.test.ts +++ b/packages/cli/test/integration/eventbus.test.ts @@ -1,5 +1,5 @@ import { mockInstance } from '@n8n/backend-test-utils'; -import type { User } from '@n8n/db'; +import { GLOBAL_OWNER_ROLE, type User } from '@n8n/db'; import { MessageEventBus } from '@/eventbus/message-event-bus/message-event-bus'; import { ExecutionRecoveryService } from '@/executions/execution-recovery.service'; @@ -25,7 +25,7 @@ const testServer = utils.setupTestServer({ }); beforeAll(async () => { - owner = await createUser({ role: 'global:owner' }); + owner = await createUser({ role: GLOBAL_OWNER_ROLE }); authOwnerAgent = testServer.authAgentFor(owner); }); diff --git a/packages/cli/test/integration/folder/folder.controller.test.ts b/packages/cli/test/integration/folder/folder.controller.test.ts index bb0c2563d2..653a0d3b8a 100644 --- a/packages/cli/test/integration/folder/folder.controller.test.ts +++ b/packages/cli/test/integration/folder/folder.controller.test.ts @@ -1793,7 +1793,7 @@ describe('PUT /projects/:projectId/folders/:folderId/transfer', () => { test('should not transfer folder if license does not allow it', async () => { testServer.license.disable('feat:folders'); - const admin = await createUser({ role: 'global:admin' }); + const admin = await createUser({ role: { slug: 'global:admin' } }); const sourceProject = await createTeamProject('source project', admin); const destinationProject = await createTeamProject('destination project', member); const sourceFolder1 = await createFolder(sourceProject, { name: 'Source Folder 1' }); @@ -1996,7 +1996,7 @@ describe('PUT /projects/:projectId/folders/:folderId/transfer', () => { test('owner transfers folder from project they are not part of, e.g. test global cred sharing scope', async () => { // ARRANGE - const admin = await createUser({ role: 'global:admin' }); + const admin = await createUser({ role: { slug: 'global:admin' } }); const sourceProject = await createTeamProject('source project', admin); const destinationProject = await createTeamProject('destination project', member); const sourceFolder1 = await createFolder(sourceProject, { name: 'Source Folder 1' }); @@ -2078,7 +2078,7 @@ describe('PUT /projects/:projectId/folders/:folderId/transfer', () => { test('admin transfers folder from project they are not part of, e.g. test global cred sharing scope', async () => { // ARRANGE - const admin = await createUser({ role: 'global:admin' }); + const admin = await createUser({ role: { slug: 'global:admin' } }); const sourceProject = await createTeamProject('source project', owner); const destinationProject = await createTeamProject('destination project', owner); const sourceFolder1 = await createFolder(sourceProject, { name: 'Source Folder 1' }); diff --git a/packages/cli/test/integration/insights/insights.api.test.ts b/packages/cli/test/integration/insights/insights.api.test.ts index 296d2ba3da..5bbe6ff462 100644 --- a/packages/cli/test/integration/insights/insights.api.test.ts +++ b/packages/cli/test/integration/insights/insights.api.test.ts @@ -8,6 +8,7 @@ import { createCompactedInsightsEvent } from '@/modules/insights/database/entiti import { createUser } from '../shared/db/users'; import type { SuperAgentTest } from '../shared/types'; import * as utils from '../shared/utils'; +import { GLOBAL_ADMIN_ROLE, GLOBAL_MEMBER_ROLE, GLOBAL_OWNER_ROLE } from '@n8n/db'; mockInstance(Telemetry); @@ -20,9 +21,9 @@ const testServer = utils.setupTestServer({ }); beforeAll(async () => { - const owner = await createUser({ role: 'global:owner' }); - const admin = await createUser({ role: 'global:admin' }); - const member = await createUser({ role: 'global:member' }); + const owner = await createUser({ role: GLOBAL_OWNER_ROLE }); + const admin = await createUser({ role: GLOBAL_ADMIN_ROLE }); + const member = await createUser({ role: GLOBAL_MEMBER_ROLE }); agents.owner = testServer.authAgentFor(owner); agents.admin = testServer.authAgentFor(admin); agents.member = testServer.authAgentFor(member); diff --git a/packages/cli/test/integration/ldap/ldap.api.test.ts b/packages/cli/test/integration/ldap/ldap.api.test.ts index 3a26f45a0d..29e9473447 100644 --- a/packages/cli/test/integration/ldap/ldap.api.test.ts +++ b/packages/cli/test/integration/ldap/ldap.api.test.ts @@ -7,7 +7,12 @@ import { } from '@n8n/backend-test-utils'; import { LDAP_DEFAULT_CONFIGURATION } from '@n8n/constants'; import type { User } from '@n8n/db'; -import { AuthProviderSyncHistoryRepository, UserRepository } from '@n8n/db'; +import { + AuthProviderSyncHistoryRepository, + GLOBAL_MEMBER_ROLE, + GLOBAL_OWNER_ROLE, + UserRepository, +} from '@n8n/db'; import { Container } from '@n8n/di'; import { Not } from '@n8n/typeorm'; import type { Entry as LdapUser } from 'ldapts'; @@ -37,7 +42,7 @@ const testServer = utils.setupTestServer({ }); beforeAll(async () => { - owner = await createUser({ role: 'global:owner' }); + owner = await createUser({ role: GLOBAL_OWNER_ROLE }); authOwnerAgent = testServer.authAgentFor(owner); defaultLdapConfig.bindingAdminPassword = Container.get(Cipher).encrypt( @@ -65,7 +70,7 @@ beforeEach(async () => { }); test('Member role should not be able to access ldap routes', async () => { - const member = await createUser({ role: 'global:member' }); + const member = await createUser({ role: { slug: 'global:member' } }); const authAgent = testServer.authAgentFor(member); await authAgent.get('/ldap/config').expect(403); await authAgent.put('/ldap/config').expect(403); @@ -137,7 +142,7 @@ describe('PUT /ldap/config', () => { const ldapConfig = await createLdapConfig(); Container.get(LdapService).setConfig(ldapConfig); - const member = await createLdapUser({ role: 'global:member' }, uniqueId()); + const member = await createLdapUser({ role: { slug: 'global:member' } }, uniqueId()); const configuration = ldapConfig; @@ -250,7 +255,7 @@ describe('POST /ldap/sync', () => { const ldapUserId = uniqueId(); const member = await createLdapUser( - { role: 'global:member', email: ldapUserEmail }, + { role: { slug: 'global:member' }, email: ldapUserEmail }, ldapUserId, ); @@ -279,7 +284,7 @@ describe('POST /ldap/sync', () => { const ldapUserId = uniqueId(); const member = await createLdapUser( - { role: 'global:member', email: ldapUserEmail }, + { role: { slug: 'global:member' }, email: ldapUserEmail }, ldapUserId, ); @@ -364,7 +369,7 @@ describe('POST /ldap/sync', () => { await createLdapUser( { - role: 'global:member', + role: { slug: 'global:member' }, email: ldapUser.mail, firstName: ldapUser.givenName, lastName: randomName(), @@ -397,7 +402,7 @@ describe('POST /ldap/sync', () => { await createLdapUser( { - role: 'global:member', + role: { slug: 'global:member' }, email: ldapUser.mail, firstName: ldapUser.givenName, lastName: ldapUser.sn, @@ -426,7 +431,7 @@ describe('POST /ldap/sync', () => { }); test('should remove user instance access once the user is disabled during synchronization', async () => { - const member = await createLdapUser({ role: 'global:member' }, uniqueId()); + const member = await createLdapUser({ role: { slug: 'global:member' } }, uniqueId()); jest.spyOn(LdapService.prototype, 'searchWithAdminBinding').mockResolvedValue([]); @@ -485,7 +490,7 @@ describe('POST /ldap/sync', () => { // Create user with valid email first await createLdapUser( { - role: 'global:member', + role: GLOBAL_MEMBER_ROLE, email: originalEmail, firstName: randomName(), lastName: randomName(), @@ -603,7 +608,7 @@ describe('POST /login', () => { await createLdapUser( { - role: 'global:member', + role: { slug: 'global:member' }, email: ldapUser.mail, firstName: 'firstname', lastName: 'lastname', @@ -637,7 +642,7 @@ describe('POST /login', () => { }; await createUser({ - role: 'global:member', + role: GLOBAL_MEMBER_ROLE, email: ldapUser.mail, firstName: ldapUser.givenName, lastName: 'lastname', @@ -652,7 +657,7 @@ describe('Instance owner should able to delete LDAP users', () => { const ldapConfig = await createLdapConfig(); Container.get(LdapService).setConfig(ldapConfig); - const member = await createLdapUser({ role: 'global:member' }, uniqueId()); + const member = await createLdapUser({ role: { slug: 'global:member' } }, uniqueId()); await authOwnerAgent.post(`/users/${member.id}`); }); @@ -661,7 +666,7 @@ describe('Instance owner should able to delete LDAP users', () => { const ldapConfig = await createLdapConfig(); Container.get(LdapService).setConfig(ldapConfig); - const member = await createLdapUser({ role: 'global:member' }, uniqueId()); + const member = await createLdapUser({ role: { slug: 'global:member' } }, uniqueId()); // delete the LDAP member and transfer its workflows/credentials to instance owner await authOwnerAgent.post(`/users/${member.id}?transferId=${owner.id}`); diff --git a/packages/cli/test/integration/license.api.test.ts b/packages/cli/test/integration/license.api.test.ts index 357ef5fed7..bbf9a8eb75 100644 --- a/packages/cli/test/integration/license.api.test.ts +++ b/packages/cli/test/integration/license.api.test.ts @@ -1,5 +1,5 @@ import { testDb } from '@n8n/backend-test-utils'; -import type { User } from '@n8n/db'; +import { GLOBAL_MEMBER_ROLE, GLOBAL_OWNER_ROLE, type User } from '@n8n/db'; import nock from 'nock'; import config from '@/config'; @@ -21,8 +21,8 @@ let authMemberAgent: SuperAgentTest; const testServer = utils.setupTestServer({ endpointGroups: ['license'] }); beforeAll(async () => { - owner = await createUserShell('global:owner'); - member = await createUserShell('global:member'); + owner = await createUserShell(GLOBAL_OWNER_ROLE); + member = await createUserShell(GLOBAL_MEMBER_ROLE); authOwnerAgent = testServer.authAgentFor(owner); authMemberAgent = testServer.authAgentFor(member); diff --git a/packages/cli/test/integration/me.api.test.ts b/packages/cli/test/integration/me.api.test.ts index 6fb7b19776..7b9ebc8a77 100644 --- a/packages/cli/test/integration/me.api.test.ts +++ b/packages/cli/test/integration/me.api.test.ts @@ -7,7 +7,7 @@ import { } from '@n8n/backend-test-utils'; import { GlobalConfig } from '@n8n/config'; import type { User } from '@n8n/db'; -import { ProjectRepository, UserRepository } from '@n8n/db'; +import { GLOBAL_OWNER_ROLE, ProjectRepository, UserRepository } from '@n8n/db'; import { Container } from '@n8n/di'; import type { IPersonalizationSurveyAnswersV4 } from 'n8n-workflow'; import validator from 'validator'; @@ -32,7 +32,7 @@ describe('Owner shell', () => { let authOwnerShellAgent: SuperAgentTest; beforeEach(async () => { - ownerShell = await createUserShell('global:owner'); + ownerShell = await createUserShell(GLOBAL_OWNER_ROLE); authOwnerShellAgent = testServer.authAgentFor(ownerShell); }); @@ -139,7 +139,7 @@ describe('Member', () => { beforeEach(async () => { member = await createUser({ password: memberPassword, - role: 'global:member', + role: { slug: 'global:member' }, }); authMemberAgent = testServer.authAgentFor(member); await utils.setInstanceOwnerSetUp(true); @@ -243,7 +243,7 @@ describe('Member', () => { describe('Owner', () => { test('PATCH /me should succeed with valid inputs', async () => { - const owner = await createUser({ role: 'global:owner' }); + const owner = await createUser({ role: GLOBAL_OWNER_ROLE }); const authOwnerAgent = testServer.authAgentFor(owner); for (const validPayload of VALID_PATCH_ME_PAYLOADS) { diff --git a/packages/cli/test/integration/mfa/mfa.api.test.ts b/packages/cli/test/integration/mfa/mfa.api.test.ts index 83e760d680..65c45f798c 100644 --- a/packages/cli/test/integration/mfa/mfa.api.test.ts +++ b/packages/cli/test/integration/mfa/mfa.api.test.ts @@ -30,6 +30,11 @@ beforeEach(async () => { owner = await createOwner(); + owner = await Container.get(UserRepository).findOneOrFail({ + where: { id: owner.id }, + relations: ['role'], + }); + externalHooks.run.mockReset(); config.set('userManagement.disabled', false); diff --git a/packages/cli/test/integration/owner.api.test.ts b/packages/cli/test/integration/owner.api.test.ts index 2238c6e05f..f1f096e625 100644 --- a/packages/cli/test/integration/owner.api.test.ts +++ b/packages/cli/test/integration/owner.api.test.ts @@ -6,7 +6,7 @@ import { testDb, } from '@n8n/backend-test-utils'; import type { User } from '@n8n/db'; -import { UserRepository } from '@n8n/db'; +import { GLOBAL_OWNER_ROLE, UserRepository } from '@n8n/db'; import { Container } from '@n8n/di'; import validator from 'validator'; @@ -20,7 +20,7 @@ const testServer = utils.setupTestServer({ endpointGroups: ['owner'] }); let ownerShell: User; beforeEach(async () => { - ownerShell = await createUserShell('global:owner'); + ownerShell = await createUserShell(GLOBAL_OWNER_ROLE); config.set('userManagement.isInstanceOwnerSetUp', false); }); diff --git a/packages/cli/test/integration/password-reset.api.test.ts b/packages/cli/test/integration/password-reset.api.test.ts index 1a13eac312..6194f21119 100644 --- a/packages/cli/test/integration/password-reset.api.test.ts +++ b/packages/cli/test/integration/password-reset.api.test.ts @@ -7,7 +7,7 @@ import { mockInstance, } from '@n8n/backend-test-utils'; import type { User } from '@n8n/db'; -import { UserRepository } from '@n8n/db'; +import { GLOBAL_MEMBER_ROLE, GLOBAL_OWNER_ROLE, UserRepository } from '@n8n/db'; import { Container } from '@n8n/di'; import { compare } from 'bcryptjs'; import { mock } from 'jest-mock-extended'; @@ -39,8 +39,8 @@ let authService: AuthService; beforeEach(async () => { await testDb.truncate(['User']); - owner = await createUser({ role: 'global:owner' }); - member = await createUser({ role: 'global:member' }); + owner = await createUser({ role: GLOBAL_OWNER_ROLE }); + member = await createUser({ role: GLOBAL_MEMBER_ROLE }); externalHooks.run.mockReset(); jest.replaceProperty(mailer, 'isEmailSetUp', true); authService = Container.get(AuthService); @@ -50,7 +50,7 @@ describe('POST /forgot-password', () => { test('should send password reset email', async () => { const member = await createUser({ email: 'test@test.com', - role: 'global:member', + role: { slug: 'global:member' }, }); await Promise.all( @@ -76,7 +76,7 @@ describe('POST /forgot-password', () => { await setCurrentAuthenticationMethod('saml'); const member = await createUser({ email: 'test@test.com', - role: 'global:member', + role: { slug: 'global:member' }, }); await testServer.authlessAgent diff --git a/packages/cli/test/integration/public-api/endpoints-with-scopes-enabled.test.ts b/packages/cli/test/integration/public-api/endpoints-with-scopes-enabled.test.ts index 673fdef04c..026b704b94 100644 --- a/packages/cli/test/integration/public-api/endpoints-with-scopes-enabled.test.ts +++ b/packages/cli/test/integration/public-api/endpoints-with-scopes-enabled.test.ts @@ -222,7 +222,7 @@ describe('Public API endpoints with feat:apiKeyScopes enabled', () => { expect(returnedUser.id).toBe(storedUser.id); expect(returnedUser.email).toBe(storedUser.email); expect(returnedUser.email).toBe(payloadUser.email); - expect(storedUser.role).toBe(payloadUser.role); + expect(storedUser.role.slug).toBe(payloadUser.role); }); test('should fail to create user when API key doesn\'t have "user:create" scope', async () => { @@ -267,7 +267,7 @@ describe('Public API endpoints with feat:apiKeyScopes enabled', () => { */ expect(response.status).toBe(204); const storedUser = await getUserById(member.id); - expect(storedUser.role).toBe(payload.newRoleName); + expect(storedUser.role.slug).toBe(payload.newRoleName); }); test('should fail to change role when API key doesn\'t have "user:changeRole" scope', async () => { diff --git a/packages/cli/test/integration/public-api/users.ee.test.ts b/packages/cli/test/integration/public-api/users.ee.test.ts index ead728b7f5..9e60554260 100644 --- a/packages/cli/test/integration/public-api/users.ee.test.ts +++ b/packages/cli/test/integration/public-api/users.ee.test.ts @@ -4,7 +4,7 @@ import { testDb, mockInstance, } from '@n8n/backend-test-utils'; -import type { User } from '@n8n/db'; +import { GLOBAL_MEMBER_ROLE, type User } from '@n8n/db'; import { v4 as uuid } from 'uuid'; import validator from 'validator'; @@ -155,7 +155,7 @@ describe('With license unlimited quota:users', () => { test('should return a pending user', async () => { const owner = await createOwnerWithApiKey(); - const { id: memberId } = await createUserShell('global:member'); + const { id: memberId } = await createUserShell(GLOBAL_MEMBER_ROLE); const authOwnerAgent = testServer.publicApiAgentFor(owner); const response = await authOwnerAgent.get(`/users/${memberId}`).expect(200); diff --git a/packages/cli/test/integration/public-api/users.test.ts b/packages/cli/test/integration/public-api/users.test.ts index 8c120bd2f2..e42f192608 100644 --- a/packages/cli/test/integration/public-api/users.test.ts +++ b/packages/cli/test/integration/public-api/users.test.ts @@ -95,7 +95,7 @@ describe('Users in Public API', () => { expect(returnedUser.id).toBe(storedUser.id); expect(returnedUser.email).toBe(storedUser.email); expect(returnedUser.email).toBe(payloadUser.email); - expect(storedUser.role).toBe(payloadUser.role); + expect(storedUser.role.slug).toBe(payloadUser.role); }); }); @@ -275,7 +275,7 @@ describe('Users in Public API', () => { */ expect(response.status).toBe(204); const storedUser = await getUserById(member.id); - expect(storedUser.role).toBe(payload.newRoleName); + expect(storedUser.role.slug).toBe(payload.newRoleName); }); }); }); diff --git a/packages/cli/test/integration/shared/db/users.ts b/packages/cli/test/integration/shared/db/users.ts index 64aefcc929..a3f684fb26 100644 --- a/packages/cli/test/integration/shared/db/users.ts +++ b/packages/cli/test/integration/shared/db/users.ts @@ -1,14 +1,23 @@ import { randomEmail, randomName, randomValidPassword } from '@n8n/backend-test-utils'; -import { AuthIdentity, AuthIdentityRepository, UserRepository } from '@n8n/db'; +import { + AuthIdentity, + AuthIdentityRepository, + GLOBAL_ADMIN_ROLE, + GLOBAL_MEMBER_ROLE, + GLOBAL_OWNER_ROLE, + type Role, + UserRepository, +} from '@n8n/db'; import { type User } from '@n8n/db'; import { Container } from '@n8n/di'; -import type { ApiKeyScope, GlobalRole } from '@n8n/permissions'; +import type { ApiKeyScope } from '@n8n/permissions'; import { getApiKeyScopesForRole } from '@n8n/permissions'; import { hash } from 'bcryptjs'; import { MfaService } from '@/mfa/mfa.service'; import { TOTPService } from '@/mfa/totp.service'; import { PublicApiKeyService } from '@/services/public-api-key.service'; +import type { DeepPartial } from '@n8n/typeorm'; type ApiKeyOptions = { expiresAt?: number | null; @@ -32,26 +41,26 @@ async function handlePasswordSetup(password: string | null | undefined): Promise } /** Store a new user object, defaulting to a `member` */ -export async function newUser(attributes: Partial = {}): Promise { +export async function newUser(attributes: DeepPartial = {}): Promise { const { email, password, firstName, lastName, role, ...rest } = attributes; return Container.get(UserRepository).create({ email: email ?? randomEmail(), password: await handlePasswordSetup(password), firstName: firstName ?? randomName(), lastName: lastName ?? randomName(), - role: role ?? 'global:member', + role: role ?? GLOBAL_MEMBER_ROLE, ...rest, }); } /** Store a user object in the DB */ -export async function createUser(attributes: Partial = {}): Promise { +export async function createUser(attributes: DeepPartial = {}): Promise { const userInstance = await newUser(attributes); const { user } = await Container.get(UserRepository).createUserWithProject(userInstance); return user; } -export async function createLdapUser(attributes: Partial, ldapId: string): Promise { +export async function createLdapUser(attributes: DeepPartial, ldapId: string): Promise { const user = await createUser(attributes); await Container.get(AuthIdentityRepository).save(AuthIdentity.create(user, ldapId, 'ldap')); return user; @@ -105,7 +114,7 @@ export const addApiKey = async ( return await Container.get(PublicApiKeyService).createPublicApiKeyForUser(user, { label: randomName(), expiresAt, - scopes: scopes.length ? scopes : getApiKeyScopesForRole(user.role), + scopes: scopes.length ? scopes : getApiKeyScopesForRole(user), }); }; @@ -134,21 +143,21 @@ export async function createAdminWithApiKey({ expiresAt = null, scopes = [] }: A } export async function createOwner() { - return await createUser({ role: 'global:owner' }); + return await createUser({ role: GLOBAL_OWNER_ROLE }); } export async function createMember() { - return await createUser({ role: 'global:member' }); + return await createUser({ role: GLOBAL_MEMBER_ROLE }); } export async function createAdmin() { - return await createUser({ role: 'global:admin' }); + return await createUser({ role: GLOBAL_ADMIN_ROLE }); } -export async function createUserShell(role: GlobalRole): Promise { - const shell: Partial = { role }; +export async function createUserShell(role: Role): Promise { + const shell: DeepPartial = { role }; - if (role !== 'global:owner') { + if (role.slug !== GLOBAL_OWNER_ROLE.slug) { shell.email = randomEmail(); } @@ -161,7 +170,7 @@ export async function createUserShell(role: GlobalRole): Promise { */ export async function createManyUsers( amount: number, - attributes: Partial = {}, + attributes: DeepPartial = {}, ): Promise { const result = await Promise.all( Array(amount) @@ -176,13 +185,13 @@ export async function createManyUsers( export const getAllUsers = async () => await Container.get(UserRepository).find({ - relations: ['authIdentities'], + relations: ['authIdentities', 'role'], }); export const getUserById = async (id: string) => await Container.get(UserRepository).findOneOrFail({ where: { id }, - relations: ['authIdentities'], + relations: ['authIdentities', 'role'], }); export const getLdapIdentities = async () => @@ -192,5 +201,8 @@ export const getLdapIdentities = async () => }); export async function getGlobalOwner() { - return await Container.get(UserRepository).findOneByOrFail({ role: 'global:owner' }); + return await Container.get(UserRepository).findOneOrFail({ + where: { role: { slug: GLOBAL_OWNER_ROLE.slug } }, + relations: ['role'], + }); } diff --git a/packages/cli/test/integration/tags.api.test.ts b/packages/cli/test/integration/tags.api.test.ts index b74187baf0..d275dc85da 100644 --- a/packages/cli/test/integration/tags.api.test.ts +++ b/packages/cli/test/integration/tags.api.test.ts @@ -1,5 +1,5 @@ import { testDb } from '@n8n/backend-test-utils'; -import { TagRepository } from '@n8n/db'; +import { GLOBAL_OWNER_ROLE, TagRepository } from '@n8n/db'; import { Container } from '@n8n/di'; import { createUserShell } from './shared/db/users'; @@ -11,7 +11,7 @@ let authOwnerAgent: SuperAgentTest; const testServer = utils.setupTestServer({ endpointGroups: ['tags'] }); beforeAll(async () => { - const ownerShell = await createUserShell('global:owner'); + const ownerShell = await createUserShell(GLOBAL_OWNER_ROLE); authOwnerAgent = testServer.authAgentFor(ownerShell); }); diff --git a/packages/cli/test/integration/user.repository.test.ts b/packages/cli/test/integration/user.repository.test.ts index f9caa4fe01..f4328ac372 100644 --- a/packages/cli/test/integration/user.repository.test.ts +++ b/packages/cli/test/integration/user.repository.test.ts @@ -44,7 +44,7 @@ describe('UserRepository', () => { test('should create personal project for a user', async () => { const { user, project } = await userRepository.createUserWithProject({ email: randomEmail(), - role: 'global:member', + role: { slug: 'global:member' }, }); const projectRelation = await Container.get(ProjectRelationRepository).findOneOrFail({ diff --git a/packages/cli/test/integration/users.api.test.ts b/packages/cli/test/integration/users.api.test.ts index 49167b1bc3..4685f15fb1 100644 --- a/packages/cli/test/integration/users.api.test.ts +++ b/packages/cli/test/integration/users.api.test.ts @@ -12,6 +12,9 @@ import { import type { PublicUser, User } from '@n8n/db'; import { FolderRepository, + GLOBAL_ADMIN_ROLE, + GLOBAL_MEMBER_ROLE, + GLOBAL_OWNER_ROLE, ProjectRelationRepository, ProjectRepository, SharedCredentialsRepository, @@ -61,26 +64,26 @@ describe('GET /users', () => { userRepository = Container.get(UserRepository); owner = await createUser({ - role: 'global:owner', + role: GLOBAL_OWNER_ROLE, email: 'owner@n8n.io', firstName: 'OwnerFirstName', lastName: 'OwnerLastName', }); member1 = await createUser({ - role: 'global:member', + role: GLOBAL_MEMBER_ROLE, email: 'member1@n8n.io', firstName: 'Member1FirstName', lastName: 'Member1LastName', mfaEnabled: true, }); member2 = await createUser({ - role: 'global:member', + role: GLOBAL_MEMBER_ROLE, email: 'member2@n8n.io', firstName: 'Member2FirstName', lastName: 'Member2LastName', }); await createUser({ - role: 'global:admin', + role: GLOBAL_ADMIN_ROLE, email: 'admin@n8n.io', firstName: 'AdminFirstName', lastName: 'AdminLastName', @@ -575,7 +578,7 @@ describe('GET /users', () => { let pendingUser: User; beforeAll(async () => { pendingUser = await createUser({ - role: 'global:member', + role: { slug: 'global:member' }, email: 'pending@n8n.io', firstName: 'PendingFirstName', lastName: 'PendingLastName', @@ -723,14 +726,14 @@ describe('GET /users', () => { test('should sort by firstName and lastName combined', async () => { const user1 = await createUser({ - role: 'global:member', + role: { slug: 'global:member' }, email: 'memberz1@n8n.io', firstName: 'ZZZFirstName', lastName: 'ZZZLastName', }); const user2 = await createUser({ - role: 'global:member', + role: { slug: 'global:member' }, email: 'memberz2@n8n.io', firstName: 'ZZZFirstName', lastName: 'ZZYLastName', @@ -1423,7 +1426,7 @@ describe('PATCH /users/:id/role', () => { const user = await getUserById(otherAdmin.id); - expect(user.role).toBe('global:member'); + expect(user.role.slug).toBe('global:member'); // restore other admin @@ -1441,7 +1444,7 @@ describe('PATCH /users/:id/role', () => { const user = await getUserById(admin.id); - expect(user.role).toBe('global:member'); + expect(user.role.slug).toBe('global:member'); // restore admin @@ -1459,7 +1462,7 @@ describe('PATCH /users/:id/role', () => { const user = await getUserById(admin.id); - expect(user.role).toBe('global:admin'); + expect(user.role.slug).toBe('global:admin'); // restore member @@ -1508,7 +1511,7 @@ describe('PATCH /users/:id/role', () => { const user = await getUserById(admin.id); - expect(user.role).toBe('global:admin'); + expect(user.role.slug).toBe('global:admin'); // restore member @@ -1526,7 +1529,7 @@ describe('PATCH /users/:id/role', () => { const user = await getUserById(admin.id); - expect(user.role).toBe('global:member'); + expect(user.role.slug).toBe('global:member'); // restore admin diff --git a/packages/cli/test/integration/workflows/workflow-sharing.service.test.ts b/packages/cli/test/integration/workflows/workflow-sharing.service.test.ts index 0739084250..1f2a6a4fe7 100644 --- a/packages/cli/test/integration/workflows/workflow-sharing.service.test.ts +++ b/packages/cli/test/integration/workflows/workflow-sharing.service.test.ts @@ -1,6 +1,6 @@ import { LicenseState } from '@n8n/backend-common'; import { createWorkflow, shareWorkflowWithUsers, testDb } from '@n8n/backend-test-utils'; -import type { User } from '@n8n/db'; +import { GLOBAL_MEMBER_ROLE, GLOBAL_OWNER_ROLE, type User } from '@n8n/db'; import { Container } from '@n8n/di'; import { mock } from 'jest-mock-extended'; @@ -17,9 +17,9 @@ let projectService: ProjectService; beforeAll(async () => { await testDb.init(); - owner = await createUser({ role: 'global:owner' }); - member = await createUser({ role: 'global:member' }); - anotherMember = await createUser({ role: 'global:member' }); + owner = await createUser({ role: GLOBAL_OWNER_ROLE }); + member = await createUser({ role: GLOBAL_MEMBER_ROLE }); + anotherMember = await createUser({ role: GLOBAL_MEMBER_ROLE }); const licenseMock = mock(); licenseMock.isSharingLicensed.mockReturnValue(true); licenseMock.getMaxTeamProjects.mockReturnValue(-1); @@ -39,7 +39,6 @@ afterAll(async () => { describe('WorkflowSharingService', () => { describe('getSharedWorkflowIds', () => { it('should show all workflows to owners', async () => { - owner.role = 'global:owner'; const workflow1 = await createWorkflow({}, member); const workflow2 = await createWorkflow({}, anotherMember); const sharedWorkflowIds = await workflowSharingService.getSharedWorkflowIds(owner, { @@ -51,7 +50,6 @@ describe('WorkflowSharingService', () => { }); it('should show shared workflows to users', async () => { - member.role = 'global:member'; const workflow1 = await createWorkflow({}, anotherMember); const workflow2 = await createWorkflow({}, anotherMember); const workflow3 = await createWorkflow({}, anotherMember); diff --git a/packages/cli/test/integration/workflows/workflows.controller-with-active-workflow-manager.ee.test.ts b/packages/cli/test/integration/workflows/workflows.controller-with-active-workflow-manager.ee.test.ts index 6180e13b33..964b4dce48 100644 --- a/packages/cli/test/integration/workflows/workflows.controller-with-active-workflow-manager.ee.test.ts +++ b/packages/cli/test/integration/workflows/workflows.controller-with-active-workflow-manager.ee.test.ts @@ -21,7 +21,7 @@ const testServer = utils.setupTestServer({ }); beforeAll(async () => { - member = await createUser({ role: 'global:member' }); + member = await createUser({ role: { slug: 'global:member' } }); await utils.initNodeTypes(); }); diff --git a/packages/cli/test/integration/workflows/workflows.controller.ee.test.ts b/packages/cli/test/integration/workflows/workflows.controller.ee.test.ts index 8ed11d94de..f59509e48d 100644 --- a/packages/cli/test/integration/workflows/workflows.controller.ee.test.ts +++ b/packages/cli/test/integration/workflows/workflows.controller.ee.test.ts @@ -16,6 +16,7 @@ import { WorkflowHistoryRepository, SharedWorkflowRepository, WorkflowRepository, + GLOBAL_MEMBER_ROLE, } from '@n8n/db'; import { Container } from '@n8n/di'; import type { ProjectRole } from '@n8n/permissions'; @@ -70,9 +71,9 @@ beforeAll(async () => { owner = await createOwner(); admin = await createAdmin(); ownerPersonalProject = await projectRepository.getPersonalProjectForUserOrFail(owner.id); - member = await createUser({ role: 'global:member' }); + member = await createUser({ role: { slug: 'global:member' } }); memberPersonalProject = await projectRepository.getPersonalProjectForUserOrFail(member.id); - anotherMember = await createUser({ role: 'global:member' }); + anotherMember = await createUser({ role: { slug: 'global:member' } }); anotherMemberPersonalProject = await projectRepository.getPersonalProjectForUserOrFail( anotherMember.id, ); @@ -159,7 +160,7 @@ describe('PUT /workflows/:workflowId/share', () => { test('should allow sharing with pending users', async () => { const workflow = await createWorkflow({}, owner); - const memberShell = await createUserShell('global:member'); + const memberShell = await createUserShell(GLOBAL_MEMBER_ROLE); const memberShellPersonalProject = await projectRepository.getPersonalProjectForUserOrFail( memberShell.id, ); @@ -272,7 +273,7 @@ describe('PUT /workflows/:workflowId/share', () => { test('should not allow sharing by another non-shared member', async () => { const workflow = await createWorkflow({}, member); - const tempUser = await createUser({ role: 'global:member' }); + const tempUser = await createUser({ role: { slug: 'global:member' } }); const tempUserPersonalProject = await projectRepository.getPersonalProjectForUserOrFail( tempUser.id, ); diff --git a/packages/cli/test/integration/workflows/workflows.controller.test.ts b/packages/cli/test/integration/workflows/workflows.controller.test.ts index 486b31f95e..3a2fccec3d 100644 --- a/packages/cli/test/integration/workflows/workflows.controller.test.ts +++ b/packages/cli/test/integration/workflows/workflows.controller.test.ts @@ -657,7 +657,7 @@ describe('GET /workflows', () => { test('should return workflows with scopes when ?includeScopes=true', async () => { const [member1, member2] = await createManyUsers(2, { - role: 'global:member', + role: { slug: 'global:member' }, }); const teamProject = await createTeamProject(undefined, member1); @@ -1474,7 +1474,7 @@ describe('GET /workflows?includeFolders=true', () => { test('should return workflows with scopes and folders when ?includeScopes=true', async () => { const [member1, member2] = await createManyUsers(2, { - role: 'global:member', + role: { slug: 'global:member' }, }); const teamProject = await createTeamProject(undefined, member1); @@ -2165,6 +2165,8 @@ describe('PATCH /workflows/:workflowId', () => { const response = await authOwnerAgent.patch(`/workflows/${workflow.id}`).send(payload); + console.log(response.body); + const { data: { id }, } = response.body; diff --git a/packages/frontend/editor-ui/src/stores/rbac.store.ts b/packages/frontend/editor-ui/src/stores/rbac.store.ts index a528ae2adc..1fbb2fc4f4 100644 --- a/packages/frontend/editor-ui/src/stores/rbac.store.ts +++ b/packages/frontend/editor-ui/src/stores/rbac.store.ts @@ -38,6 +38,8 @@ export const useRBACStore = defineStore(STORES.RBAC, () => { folder: {}, insights: {}, dataStore: {}, + execution: {}, + workflowTags: {}, }); function addGlobalRole(role: Role) {