diff --git a/packages/@n8n/db/src/entities/index.ts b/packages/@n8n/db/src/entities/index.ts index 1b629aba1b..8b458a795a 100644 --- a/packages/@n8n/db/src/entities/index.ts +++ b/packages/@n8n/db/src/entities/index.ts @@ -17,6 +17,8 @@ import { InvalidAuthToken } from './invalid-auth-token'; import { ProcessedData } from './processed-data'; import { Project } from './project'; import { ProjectRelation } from './project-relation'; +import { Role } from './role'; +import { Scope } from './scope'; import { Settings } from './settings'; import { SharedCredentials } from './shared-credentials'; import { SharedWorkflow } from './shared-workflow'; @@ -46,6 +48,8 @@ export { Folder, Project, ProjectRelation, + Role, + Scope, SharedCredentials, SharedWorkflow, TagEntity, @@ -81,6 +85,7 @@ export const entities = { Folder, Project, ProjectRelation, + Scope, SharedCredentials, SharedWorkflow, TagEntity, @@ -99,4 +104,5 @@ export const entities = { TestRun, TestCaseExecution, ExecutionEntity, + Role, }; diff --git a/packages/@n8n/db/src/entities/role.ts b/packages/@n8n/db/src/entities/role.ts new file mode 100644 index 0000000000..5bea216275 --- /dev/null +++ b/packages/@n8n/db/src/entities/role.ts @@ -0,0 +1,57 @@ +import { Column, Entity, JoinTable, ManyToMany, PrimaryColumn } from '@n8n/typeorm'; + +import { Scope } from './scope'; + +@Entity({ + name: 'role', +}) +export class Role { + @PrimaryColumn({ + type: String, + name: 'slug', + }) + slug: string; + + @Column({ + type: String, + nullable: false, + name: 'displayName', + }) + displayName: string; + + @Column({ + type: String, + nullable: true, + name: 'description', + }) + description: string | null; + + @Column({ + type: Boolean, + default: false, + name: 'systemRole', + }) + /** + * Indicates if the role is managed by the system and cannot be edited. + */ + systemRole: boolean; + + @Column({ + type: String, + name: 'roleType', + }) + /** + * Type of the role, e.g., global, project, or workflow. + */ + roleType: 'global' | 'project' | 'workflow' | 'credential'; + + @ManyToMany(() => Scope, { + eager: true, + }) + @JoinTable({ + name: 'role_scope', + joinColumn: { name: 'roleSlug', referencedColumnName: 'slug' }, + inverseJoinColumn: { name: 'scopeSlug', referencedColumnName: 'slug' }, + }) + scopes: Scope[]; +} diff --git a/packages/@n8n/db/src/entities/scope.ts b/packages/@n8n/db/src/entities/scope.ts new file mode 100644 index 0000000000..0630c19f6a --- /dev/null +++ b/packages/@n8n/db/src/entities/scope.ts @@ -0,0 +1,27 @@ +import type { Scope as ScopeType } from '@n8n/permissions'; +import { Column, Entity, PrimaryColumn } from '@n8n/typeorm'; + +@Entity({ + name: 'scope', +}) +export class Scope { + @PrimaryColumn({ + type: String, + name: 'slug', + }) + slug: ScopeType; + + @Column({ + type: String, + nullable: true, + name: 'displayName', + }) + displayName: string | null; + + @Column({ + type: String, + nullable: true, + name: 'description', + }) + description: string | null; +} diff --git a/packages/@n8n/db/src/entities/user.ts b/packages/@n8n/db/src/entities/user.ts index 472a80a602..10fb8998d1 100644 --- a/packages/@n8n/db/src/entities/user.ts +++ b/packages/@n8n/db/src/entities/user.ts @@ -1,5 +1,4 @@ -import type { AuthPrincipal } from '@n8n/permissions'; -import { GlobalRole } from '@n8n/permissions'; +import type { AuthPrincipal, GlobalRole } from '@n8n/permissions'; import { AfterLoad, AfterUpdate, diff --git a/packages/@n8n/db/src/index.ts b/packages/@n8n/db/src/index.ts index 2856310188..b658b00bf6 100644 --- a/packages/@n8n/db/src/index.ts +++ b/packages/@n8n/db/src/index.ts @@ -33,3 +33,5 @@ export { wrapMigration } from './migrations/migration-helpers'; export * from './migrations/migration-types'; export { DbConnection } from './connection/db-connection'; export { DbConnectionOptions } from './connection/db-connection-options'; + +export { AuthRolesService } from './services/auth.roles.service'; diff --git a/packages/@n8n/db/src/migrations/common/1750252139166-AddScopeTables.ts b/packages/@n8n/db/src/migrations/common/1750252139166-AddScopeTables.ts new file mode 100644 index 0000000000..05e27d66df --- /dev/null +++ b/packages/@n8n/db/src/migrations/common/1750252139166-AddScopeTables.ts @@ -0,0 +1,32 @@ +import type { MigrationContext, ReversibleMigration } from '../migration-types'; + +/* + * We introduce a scope table, this will hold all scopes that we know about. + * + * The scope table should never be edited by users, on every startup + * the system will make sure that all scopes that it knows about are stored + * in here. + * + * ColumnName | Type | Description + * ================================= + * slug | Text | Unique identifier of the scope for example: 'project:create' + * displayName | Text | Name used to display in the UI + * description | Text | Text describing the scope in more detail of users + */ +export class AddScopeTables1750252139166 implements ReversibleMigration { + async up({ schemaBuilder: { createTable, column } }: MigrationContext) { + await createTable('scope').withColumns( + column('slug') + .varchar(128) + .primary.notNull.comment('Unique identifier of the scope for example: "project:create"'), + column('displayName').text.default(null).comment('Name used to display in the UI'), + column('description') + .text.default(null) + .comment('Text describing the scope in more detail of users'), + ); + } + + async down({ schemaBuilder: { dropTable } }: MigrationContext) { + await dropTable('scope'); + } +} diff --git a/packages/@n8n/db/src/migrations/common/1750252139167-AddRolesTables.ts b/packages/@n8n/db/src/migrations/common/1750252139167-AddRolesTables.ts new file mode 100644 index 0000000000..edafcd8d92 --- /dev/null +++ b/packages/@n8n/db/src/migrations/common/1750252139167-AddRolesTables.ts @@ -0,0 +1,98 @@ +import type { MigrationContext, ReversibleMigration } from '../migration-types'; + +/* + * We introduce roles table, this will hold all roles that we know about + * + * There are roles that can't be edited by users, these are marked as system-only and will + * be managed by the system itself. On every startup, the system will ensure + * that these roles are synchronized. + * + * ColumnName | Type | Description + * ================================= + * slug | Text | Unique identifier of the role for example: 'global:owner' + * displayName | Text | Name used to display in the UI + * description | Text | Text describing the scope in more detail of users + * roleType | Text | Text type of role, such as 'global', 'project', etc. + * systemRole | Bool | Indicates if the role is managed by the system and cannot be edited by users + * + * For the role table there is a junction table that will hold the + * relationships between the roles and the scopes that are associated with them. + */ + +export class AddRolesTables1750252139167 implements ReversibleMigration { + async up({ + schemaBuilder: { createTable, column, createIndex }, + queryRunner, + tablePrefix, + dbType, + }: MigrationContext) { + await createTable('role').withColumns( + column('slug') + .varchar(128) + .primary.notNull.comment('Unique identifier of the role for example: "global:owner"'), + column('displayName').text.default(null).comment('Name used to display in the UI'), + column('description') + .text.default(null) + .comment('Text describing the scope in more detail of users'), + column('roleType') + .text.default(null) + .comment('Type of the role, e.g., global, project, or workflow'), + column('systemRole') + .bool.default(false) + .notNull.comment('Indicates if the role is managed by the system and cannot be edited'), + ); + + // MYSQL + if (dbType === 'postgresdb' || dbType === 'sqlite') { + // POSTGRES + await queryRunner.query( + `CREATE TABLE ${tablePrefix}role_scope ( + "roleSlug" VARCHAR(128) NOT NULL, + "scopeSlug" VARCHAR(128) NOT NULL, + CONSTRAINT "PK_${tablePrefix}role_scope" PRIMARY KEY ("roleSlug", "scopeSlug"), + CONSTRAINT "FK_${tablePrefix}role" FOREIGN KEY ("roleSlug") REFERENCES ${tablePrefix}role ("slug") ON DELETE CASCADE ON UPDATE CASCADE, + CONSTRAINT "FK_${tablePrefix}scope" FOREIGN KEY ("scopeSlug") REFERENCES "${tablePrefix}scope" ("slug") ON DELETE CASCADE ON UPDATE CASCADE + );`, + ); + } else { + // MYSQL + await queryRunner.query( + `CREATE TABLE ${tablePrefix}role_scope ( + \`roleSlug\` VARCHAR(128) NOT NULL, + \`scopeSlug\` VARCHAR(128) NOT NULL, + FOREIGN KEY (\`scopeSlug\`) REFERENCES ${tablePrefix}scope (\`slug\`) ON DELETE CASCADE ON UPDATE CASCADE, + FOREIGN KEY (\`roleSlug\`) REFERENCES ${tablePrefix}role (\`slug\`) ON DELETE CASCADE ON UPDATE CASCADE, + PRIMARY KEY (\`roleSlug\`, \`scopeSlug\`) + ) ENGINE=InnoDB;`, + ); + } + + await createIndex('role_scope', ['scopeSlug']); + /* + await createTable('role_scope') + .withColumns( + column('id').int.primary.autoGenerate2, + column('roleSlug').varchar(128).notNull, + column('scopeSlug').varchar(128).notNull, + ) + .withForeignKey('roleSlug', { + tableName: 'role', + columnName: 'slug', + onDelete: 'CASCADE', + onUpdate: 'CASCADE', + }) + .withForeignKey('scopeSlug', { + tableName: 'scope', + columnName: 'slug', + onDelete: 'CASCADE', + onUpdate: 'CASCADE', + }) + .withIndexOn('scopeSlug') // For fast lookup of which roles have access to a scope + .withIndexOn(['roleSlug', 'scopeSlug'], true); */ + } + + async down({ schemaBuilder: { dropTable } }: MigrationContext) { + await dropTable('role_scope'); + await dropTable('role'); + } +} diff --git a/packages/@n8n/db/src/migrations/common/1750252139168-LinkRoleToUserTable.ts b/packages/@n8n/db/src/migrations/common/1750252139168-LinkRoleToUserTable.ts new file mode 100644 index 0000000000..b0909a45ad --- /dev/null +++ b/packages/@n8n/db/src/migrations/common/1750252139168-LinkRoleToUserTable.ts @@ -0,0 +1,59 @@ +import type { MigrationContext, ReversibleMigration } from '../migration-types'; + +/* + * This migration links the role table to the user table, by adding a new column 'roleSlug' + * to the user table. It also ensures that all users have a valid role set in the 'roleSlug' column. + * The migration will insert the global roles that we need into the role table if they do not exist. + * + * The old 'role' column in the user table will be removed in a later migration. + */ +export class LinkRoleToUserTable1750252139168 implements ReversibleMigration { + async up({ + schemaBuilder: { addForeignKey, addColumns, column }, + escape, + dbType, + 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'); + const roleTypeColumn = escape.columnName('roleType'); + const systemRoleColumn = escape.columnName('systemRole'); + + const isPostgresOrSqlite = dbType === 'postgresdb' || dbType === 'sqlite'; + const upsertQuery = isPostgresOrSqlite + ? `INSERT INTO ${roleTableName} (${slugColumn}, ${roleTypeColumn}, ${systemRoleColumn}) VALUES (:slug, :roleType, :systemRole) ON CONFLICT DO NOTHING` + : `INSERT IGNORE INTO ${roleTableName} (${slugColumn}, ${roleTypeColumn}, ${systemRoleColumn}) VALUES (:slug, :roleType, :systemRole)`; + + // Make sure that the global roles that we need exist + for (const role of ['global:owner', 'global:admin', 'global:member']) { + await runQuery(upsertQuery, { + slug: role, + roleType: 'global', + systemRole: true, + }); + } + + await addColumns('user', [column('roleSlug').varchar(128).default("'global:member'").notNull]); + + await runQuery( + `UPDATE ${userTableName} SET ${roleSlugColumn} = ${roleColumn} WHERE ${roleColumn} != ${roleSlugColumn}`, + ); + + // 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' WHERE NOT EXISTS (SELECT 1 FROM ${roleTableName} WHERE ${slugColumn} = ${roleSlugColumn})`, + ); + + await addForeignKey('user', 'roleSlug', ['role', 'slug']); + } + + async down({ schemaBuilder: { dropForeignKey, dropColumns } }: MigrationContext) { + await dropForeignKey('user', 'roleSlug', ['role', 'slug']); + await dropColumns('user', ['roleSlug']); + } +} diff --git a/packages/@n8n/db/src/migrations/mysqldb/index.ts b/packages/@n8n/db/src/migrations/mysqldb/index.ts index 4cd64ed081..cedde7a623 100644 --- a/packages/@n8n/db/src/migrations/mysqldb/index.ts +++ b/packages/@n8n/db/src/migrations/mysqldb/index.ts @@ -88,6 +88,9 @@ import { AddWorkflowArchivedColumn1745934666076 } from '../common/1745934666076- import { DropRoleTable1745934666077 } from '../common/1745934666077-DropRoleTable'; import { AddProjectDescriptionColumn1747824239000 } from '../common/1747824239000-AddProjectDescriptionColumn'; import { AddLastActiveAtColumnToUser1750252139166 } from '../common/1750252139166-AddLastActiveAtColumnToUser'; +import { AddScopeTables1750252139166 } from '../common/1750252139166-AddScopeTables'; +import { AddRolesTables1750252139167 } from '../common/1750252139167-AddRolesTables'; +import { LinkRoleToUserTable1750252139168 } from '../common/1750252139168-LinkRoleToUserTable'; import { AddInputsOutputsToTestCaseExecution1752669793000 } from '../common/1752669793000-AddInputsOutputsToTestCaseExecution'; import { CreateDataStoreTables1754475614601 } from '../common/1754475614601-CreateDataStoreTables'; import type { Migration } from '../migration-types'; @@ -185,6 +188,9 @@ export const mysqlMigrations: Migration[] = [ ClearEvaluation1745322634000, AddProjectDescriptionColumn1747824239000, AddLastActiveAtColumnToUser1750252139166, + AddScopeTables1750252139166, + AddRolesTables1750252139167, + LinkRoleToUserTable1750252139168, AddInputsOutputsToTestCaseExecution1752669793000, CreateDataStoreTables1754475614601, ]; diff --git a/packages/@n8n/db/src/migrations/postgresdb/index.ts b/packages/@n8n/db/src/migrations/postgresdb/index.ts index 7ee90699ae..d85271a13c 100644 --- a/packages/@n8n/db/src/migrations/postgresdb/index.ts +++ b/packages/@n8n/db/src/migrations/postgresdb/index.ts @@ -89,6 +89,9 @@ import { AddWorkflowArchivedColumn1745934666076 } from '../common/1745934666076- import { DropRoleTable1745934666077 } from '../common/1745934666077-DropRoleTable'; import { AddProjectDescriptionColumn1747824239000 } from '../common/1747824239000-AddProjectDescriptionColumn'; import { AddLastActiveAtColumnToUser1750252139166 } from '../common/1750252139166-AddLastActiveAtColumnToUser'; +import { AddScopeTables1750252139166 } from '../common/1750252139166-AddScopeTables'; +import { AddRolesTables1750252139167 } from '../common/1750252139167-AddRolesTables'; +import { LinkRoleToUserTable1750252139168 } from '../common/1750252139168-LinkRoleToUserTable'; import { CreateDataStoreTables1754475614601 } from '../common/1754475614601-CreateDataStoreTables'; import type { Migration } from '../migration-types'; @@ -183,6 +186,9 @@ export const postgresMigrations: Migration[] = [ ClearEvaluation1745322634000, AddProjectDescriptionColumn1747824239000, AddLastActiveAtColumnToUser1750252139166, + AddScopeTables1750252139166, + AddRolesTables1750252139167, + LinkRoleToUserTable1750252139168, AddInputsOutputsToTestCaseExecution1752669793000, CreateDataStoreTables1754475614601, ]; diff --git a/packages/@n8n/db/src/migrations/sqlite/index.ts b/packages/@n8n/db/src/migrations/sqlite/index.ts index c3d80ff661..f7a5d5e520 100644 --- a/packages/@n8n/db/src/migrations/sqlite/index.ts +++ b/packages/@n8n/db/src/migrations/sqlite/index.ts @@ -85,6 +85,9 @@ import { AddWorkflowArchivedColumn1745934666076 } from '../common/1745934666076- import { DropRoleTable1745934666077 } from '../common/1745934666077-DropRoleTable'; import { AddProjectDescriptionColumn1747824239000 } from '../common/1747824239000-AddProjectDescriptionColumn'; import { AddLastActiveAtColumnToUser1750252139166 } from '../common/1750252139166-AddLastActiveAtColumnToUser'; +import { AddScopeTables1750252139166 } from '../common/1750252139166-AddScopeTables'; +import { AddRolesTables1750252139167 } from '../common/1750252139167-AddRolesTables'; +import { LinkRoleToUserTable1750252139168 } from '../common/1750252139168-LinkRoleToUserTable'; import { AddInputsOutputsToTestCaseExecution1752669793000 } from '../common/1752669793000-AddInputsOutputsToTestCaseExecution'; import { CreateDataStoreTables1754475614601 } from '../common/1754475614601-CreateDataStoreTables'; import type { Migration } from '../migration-types'; @@ -177,6 +180,9 @@ const sqliteMigrations: Migration[] = [ ClearEvaluation1745322634000, AddProjectDescriptionColumn1747824239000, AddLastActiveAtColumnToUser1750252139166, + AddScopeTables1750252139166, + AddRolesTables1750252139167, + LinkRoleToUserTable1750252139168, AddInputsOutputsToTestCaseExecution1752669793000, CreateDataStoreTables1754475614601, ]; diff --git a/packages/@n8n/db/src/repositories/index.ts b/packages/@n8n/db/src/repositories/index.ts index c896116d63..4f0fb5f3bf 100644 --- a/packages/@n8n/db/src/repositories/index.ts +++ b/packages/@n8n/db/src/repositories/index.ts @@ -11,12 +11,14 @@ export { ExecutionRepository } from './execution.repository'; export { EventDestinationsRepository } from './event-destinations.repository'; export { FolderRepository } from './folder.repository'; export { FolderTagMappingRepository } from './folder-tag-mapping.repository'; +export { ScopeRepository } from './scope.repository'; export { InstalledNodesRepository } from './installed-nodes.repository'; export { InstalledPackagesRepository } from './installed-packages.repository'; export { InvalidAuthTokenRepository } from './invalid-auth-token.repository'; export { LicenseMetricsRepository } from './license-metrics.repository'; export { ProjectRelationRepository } from './project-relation.repository'; export { ProjectRepository } from './project.repository'; +export { RoleRepository } from './role.repository'; export { ProcessedDataRepository } from './processed-data.repository'; export { SettingsRepository } from './settings.repository'; export { TagRepository } from './tag.repository'; diff --git a/packages/@n8n/db/src/repositories/role.repository.ts b/packages/@n8n/db/src/repositories/role.repository.ts new file mode 100644 index 0000000000..05f378ad75 --- /dev/null +++ b/packages/@n8n/db/src/repositories/role.repository.ts @@ -0,0 +1,11 @@ +import { Service } from '@n8n/di'; +import { DataSource, Repository } from '@n8n/typeorm'; + +import { Role } from '../entities'; + +@Service() +export class RoleRepository extends Repository { + constructor(dataSource: DataSource) { + super(Role, dataSource.manager); + } +} diff --git a/packages/@n8n/db/src/repositories/scope.repository.ts b/packages/@n8n/db/src/repositories/scope.repository.ts new file mode 100644 index 0000000000..dbf7824122 --- /dev/null +++ b/packages/@n8n/db/src/repositories/scope.repository.ts @@ -0,0 +1,11 @@ +import { Service } from '@n8n/di'; +import { DataSource, Repository } from '@n8n/typeorm'; + +import { Scope } from '../entities'; + +@Service() +export class ScopeRepository extends Repository { + constructor(dataSource: DataSource) { + super(Scope, dataSource.manager); + } +} diff --git a/packages/@n8n/db/src/repositories/user.repository.ts b/packages/@n8n/db/src/repositories/user.repository.ts index a1f8217877..328ff09b92 100644 --- a/packages/@n8n/db/src/repositories/user.repository.ts +++ b/packages/@n8n/db/src/repositories/user.repository.ts @@ -1,6 +1,5 @@ import type { UsersListFilterDto } from '@n8n/api-types'; import { Service } from '@n8n/di'; -import type { GlobalRole } from '@n8n/permissions'; import type { DeepPartial, EntityManager, SelectQueryBuilder } from '@n8n/typeorm'; import { Brackets, DataSource, In, IsNull, Not, Repository } from '@n8n/typeorm'; @@ -65,13 +64,13 @@ export class UserRepository extends Repository { const rows = (await this.createQueryBuilder() .select(['role', 'COUNT(role) as count']) .groupBy('role') - .execute()) as Array<{ role: GlobalRole; count: string }>; + .execute()) as Array<{ role: string; count: string }>; return rows.reduce( (acc, row) => { acc[row.role] = parseInt(row.count, 10); return acc; }, - {} as Record, + {} as Record, ); } diff --git a/packages/@n8n/db/src/services/auth.roles.service.ts b/packages/@n8n/db/src/services/auth.roles.service.ts new file mode 100644 index 0000000000..e5c08aec60 --- /dev/null +++ b/packages/@n8n/db/src/services/auth.roles.service.ts @@ -0,0 +1,136 @@ +import { Logger } from '@n8n/backend-common'; +import { Service } from '@n8n/di'; +import { ALL_SCOPES, ALL_ROLES, scopeInformation } from '@n8n/permissions'; + +import { Scope } from '../entities'; +import { RoleRepository, ScopeRepository } from '../repositories'; + +@Service() +export class AuthRolesService { + constructor( + private readonly logger: Logger, + private readonly scopeRepository: ScopeRepository, + private readonly roleRepository: RoleRepository, + ) {} + + private async syncScopes() { + const availableScopes = await this.scopeRepository.find({ + select: { + slug: true, + displayName: true, + description: true, + }, + }); + + const availableScopesMap = new Map(availableScopes.map((scope) => [scope.slug, scope])); + + const scopesToUpdate = ALL_SCOPES.map((slug) => { + const info = scopeInformation[slug] ?? { + displayName: slug, + description: null, + }; + + const existingScope = availableScopesMap.get(slug); + if (!existingScope) { + const newScope = new Scope(); + newScope.slug = slug; + newScope.displayName = info.displayName; + newScope.description = info.description ?? null; + return newScope; + } + + const needsUpdate = + existingScope.displayName !== info.displayName || + existingScope.description !== info.description; + + if (needsUpdate) { + existingScope.displayName = info.displayName; + existingScope.description = info.description ?? null; + return existingScope; + } + return null; + }).filter((scope) => scope !== null); + + if (scopesToUpdate.length > 0) { + this.logger.info(`Updating ${scopesToUpdate.length} scopes...`); + await this.scopeRepository.save(scopesToUpdate); + this.logger.info('Scopes updated successfully.'); + } else { + this.logger.debug('No scopes to update.'); + } + } + + private async syncRoles() { + const existingRoles = await this.roleRepository.find({ + select: { + slug: true, + displayName: true, + description: true, + systemRole: true, + roleType: true, + }, + where: { + systemRole: true, + }, + }); + + const allScopes = await this.scopeRepository.find({ + select: { + slug: true, + }, + }); + + const existingRolesMap = new Map(existingRoles.map((role) => [role.slug, role])); + + for (const roleNamespace of Object.keys(ALL_ROLES) as Array) { + const rolesToUpdate = ALL_ROLES[roleNamespace] + .map((role) => { + const existingRole = existingRolesMap.get(role.role); + + if (!existingRole) { + const newRole = this.roleRepository.create({ + slug: role.role, + displayName: role.name, + description: role.description ?? null, + roleType: roleNamespace, + systemRole: true, + scopes: allScopes.filter((scope) => role.scopes.includes(scope.slug)), + }); + return newRole; + } + + const needsUpdate = + existingRole.displayName !== role.name || + existingRole.description !== role.description || + existingRole.roleType !== roleNamespace || + existingRole.scopes.some((scope) => !role.scopes.includes(scope.slug)) || // DB roles has scope that it should not have + role.scopes.some((scope) => !existingRole.scopes.some((s) => s.slug === scope)); // A role has scope that is not in DB + + if (needsUpdate) { + existingRole.displayName = role.name; + existingRole.description = role.description ?? null; + existingRole.roleType = roleNamespace; + existingRole.scopes = allScopes.filter((scope) => role.scopes.includes(scope.slug)); + return existingRole; + } + + return null; + }) + .filter((role) => role !== null); + if (rolesToUpdate.length > 0) { + this.logger.info(`Updating ${rolesToUpdate.length} ${roleNamespace} roles...`); + await this.roleRepository.save(rolesToUpdate); + this.logger.info(`${roleNamespace} roles updated successfully.`); + } else { + this.logger.debug(`No ${roleNamespace} roles to update.`); + } + } + } + + async init() { + this.logger.info('Initializing AuthRolesService...'); + await this.syncScopes(); + await this.syncRoles(); + this.logger.info('AuthRolesService initialized successfully.'); + } +} diff --git a/packages/@n8n/db/src/services/index.ts b/packages/@n8n/db/src/services/index.ts new file mode 100644 index 0000000000..d64e012353 --- /dev/null +++ b/packages/@n8n/db/src/services/index.ts @@ -0,0 +1 @@ +export { AuthRolesService } from './auth.roles.service'; 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 new file mode 100644 index 0000000000..a334188606 --- /dev/null +++ b/packages/@n8n/permissions/src/__tests__/__snapshots__/scope-information.test.ts.snap @@ -0,0 +1,125 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`Scope Information ensure scopes are defined correctly 1`] = ` +[ + "annotationTag:create", + "annotationTag:read", + "annotationTag:update", + "annotationTag:delete", + "annotationTag:list", + "annotationTag:*", + "auditLogs:manage", + "auditLogs:*", + "banner:dismiss", + "banner:*", + "community:register", + "community:*", + "communityPackage:install", + "communityPackage:uninstall", + "communityPackage:update", + "communityPackage:list", + "communityPackage:manage", + "communityPackage:*", + "credential:share", + "credential:move", + "credential:create", + "credential:read", + "credential:update", + "credential:delete", + "credential:list", + "credential:*", + "externalSecretsProvider:sync", + "externalSecretsProvider:create", + "externalSecretsProvider:read", + "externalSecretsProvider:update", + "externalSecretsProvider:delete", + "externalSecretsProvider:list", + "externalSecretsProvider:*", + "externalSecret:list", + "externalSecret:use", + "externalSecret:*", + "eventBusDestination:test", + "eventBusDestination:create", + "eventBusDestination:read", + "eventBusDestination:update", + "eventBusDestination:delete", + "eventBusDestination:list", + "eventBusDestination:*", + "ldap:sync", + "ldap:manage", + "ldap:*", + "license:manage", + "license:*", + "logStreaming:manage", + "logStreaming:*", + "orchestration:read", + "orchestration:list", + "orchestration:*", + "project:create", + "project:read", + "project:update", + "project:delete", + "project:list", + "project:*", + "saml:manage", + "saml:*", + "securityAudit:generate", + "securityAudit:*", + "sourceControl:pull", + "sourceControl:push", + "sourceControl:manage", + "sourceControl:*", + "tag:create", + "tag:read", + "tag:update", + "tag:delete", + "tag:list", + "tag:*", + "user:resetPassword", + "user:changeRole", + "user:enforceMfa", + "user:create", + "user:read", + "user:update", + "user:delete", + "user:list", + "user:*", + "variable:create", + "variable:read", + "variable:update", + "variable:delete", + "variable:list", + "variable:*", + "workersView:manage", + "workersView:*", + "workflow:share", + "workflow:execute", + "workflow:move", + "workflow:create", + "workflow:read", + "workflow:update", + "workflow:delete", + "workflow:list", + "workflow:*", + "folder:create", + "folder:read", + "folder:update", + "folder:delete", + "folder:list", + "folder:move", + "folder:*", + "insights:list", + "insights:*", + "oidc:manage", + "oidc:*", + "dataStore:create", + "dataStore:read", + "dataStore:update", + "dataStore:delete", + "dataStore:list", + "dataStore:readRow", + "dataStore:writeRow", + "dataStore:*", + "*", +] +`; diff --git a/packages/@n8n/permissions/src/__tests__/scope-information.test.ts b/packages/@n8n/permissions/src/__tests__/scope-information.test.ts new file mode 100644 index 0000000000..b88abaf560 --- /dev/null +++ b/packages/@n8n/permissions/src/__tests__/scope-information.test.ts @@ -0,0 +1,7 @@ +import { ALL_SCOPES } from '@/scope-information'; + +describe('Scope Information', () => { + it('ensure scopes are defined correctly', () => { + expect(ALL_SCOPES).toMatchSnapshot(); + }); +}); diff --git a/packages/@n8n/permissions/src/index.ts b/packages/@n8n/permissions/src/index.ts index 3e65e5d635..f8b8fb6d86 100644 --- a/packages/@n8n/permissions/src/index.ts +++ b/packages/@n8n/permissions/src/index.ts @@ -2,6 +2,7 @@ export type * from './types.ee'; export * from './constants.ee'; export * from './roles/scopes/global-scopes.ee'; +export * from './scope-information'; export * from './roles/role-maps.ee'; export * from './roles/all-roles'; diff --git a/packages/@n8n/permissions/src/roles/all-roles.ts b/packages/@n8n/permissions/src/roles/all-roles.ts index 79a03ebeb9..a14de59ebb 100644 --- a/packages/@n8n/permissions/src/roles/all-roles.ts +++ b/packages/@n8n/permissions/src/roles/all-roles.ts @@ -26,6 +26,7 @@ const mapToRoleObject = (roles: Record [ + ...operations.map((op) => `${resource}:${op}` as const), + `${resource}:*` as const, + ]) as Scope[]; + + resourceScopes.push('*' as const); // Global wildcard + return resourceScopes; +} + +export const ALL_SCOPES = buildResourceScopes(); + +export const scopeInformation: Partial> = { + 'annotationTag:create': { + displayName: 'Create Annotation Tag', + description: 'Allows creating new annotation tags.', + }, +}; diff --git a/packages/@n8n/permissions/src/types.ee.ts b/packages/@n8n/permissions/src/types.ee.ts index c9dcb8d8bd..5e2ae1c022 100644 --- a/packages/@n8n/permissions/src/types.ee.ts +++ b/packages/@n8n/permissions/src/types.ee.ts @@ -11,6 +11,11 @@ import type { workflowSharingRoleSchema, } from './schemas.ee'; +export type ScopeInformation = { + displayName: string; + description?: string | null; +}; + /** Represents a resource that can have permissions applied to it */ export type Resource = keyof typeof RESOURCES; @@ -59,6 +64,7 @@ export type AllRoleTypes = GlobalRole | ProjectRole | WorkflowSharingRole | Cred type RoleObject = { role: T; name: string; + description?: string | null; scopes: Scope[]; licensed: boolean; }; diff --git a/packages/cli/src/commands/start.ts b/packages/cli/src/commands/start.ts index 0a54f8d5eb..87e6ce8ece 100644 --- a/packages/cli/src/commands/start.ts +++ b/packages/cli/src/commands/start.ts @@ -1,7 +1,7 @@ /* eslint-disable @typescript-eslint/no-unsafe-call */ /* eslint-disable @typescript-eslint/no-unsafe-member-access */ import { LICENSE_FEATURES } from '@n8n/constants'; -import { ExecutionRepository, SettingsRepository } from '@n8n/db'; +import { AuthRolesService, ExecutionRepository, SettingsRepository } from '@n8n/db'; import { Command } from '@n8n/decorators'; import { Container } from '@n8n/di'; import glob from 'fast-glob'; @@ -172,6 +172,9 @@ export class Start extends BaseCommand> { } await super.init(); + + await Container.get(AuthRolesService).init(); + this.activeWorkflowManager = Container.get(ActiveWorkflowManager); const isMultiMainEnabled = diff --git a/packages/cli/test/integration/role.api.test.ts b/packages/cli/test/integration/role.api.test.ts index 3d929aa49d..3e6343feeb 100644 --- a/packages/cli/test/integration/role.api.test.ts +++ b/packages/cli/test/integration/role.api.test.ts @@ -23,17 +23,20 @@ let expectedGlobalRoles: Array<{ role: GlobalRole; scopes: Scope[]; licensed: boolean; + description: string; }>; let expectedProjectRoles: Array<{ name: string; role: ProjectRole; scopes: Scope[]; licensed: boolean; + description: string; }>; let expectedCredentialRoles: Array<{ name: string; role: CredentialSharingRole; scopes: Scope[]; + description: string; licensed: boolean; }>; let expectedWorkflowRoles: Array<{ @@ -41,6 +44,7 @@ let expectedWorkflowRoles: Array<{ role: WorkflowSharingRole; scopes: Scope[]; licensed: boolean; + description: string; }>; beforeAll(async () => { @@ -52,18 +56,21 @@ beforeAll(async () => { role: 'global:owner', scopes: getRoleScopes('global:owner'), licensed: true, + description: 'Owner', }, { name: 'Admin', role: 'global:admin', scopes: getRoleScopes('global:admin'), licensed: false, + description: 'Admin', }, { name: 'Member', role: 'global:member', scopes: getRoleScopes('global:member'), licensed: true, + description: 'Member', }, ]; expectedProjectRoles = [ @@ -72,18 +79,21 @@ beforeAll(async () => { role: 'project:personalOwner', scopes: getRoleScopes('project:personalOwner'), licensed: true, + description: 'Project Owner', }, { name: 'Project Admin', role: 'project:admin', scopes: getRoleScopes('project:admin'), licensed: false, + description: 'Project Admin', }, { name: 'Project Editor', role: 'project:editor', scopes: getRoleScopes('project:editor'), licensed: false, + description: 'Project Editor', }, ]; expectedCredentialRoles = [ @@ -92,12 +102,14 @@ beforeAll(async () => { role: 'credential:owner', scopes: getRoleScopes('credential:owner'), licensed: true, + description: 'Credential Owner', }, { name: 'Credential User', role: 'credential:user', scopes: getRoleScopes('credential:user'), licensed: true, + description: 'Credential User', }, ]; expectedWorkflowRoles = [ @@ -106,12 +118,14 @@ beforeAll(async () => { role: 'workflow:owner', scopes: getRoleScopes('workflow:owner'), licensed: true, + description: 'Workflow Owner', }, { name: 'Workflow Editor', role: 'workflow:editor', scopes: getRoleScopes('workflow:editor'), licensed: true, + description: 'Workflow Editor', }, ]; }); diff --git a/packages/frontend/editor-ui/src/components/CredentialEdit/CredentialSharing.ee.vue b/packages/frontend/editor-ui/src/components/CredentialEdit/CredentialSharing.ee.vue index 6747f5a618..bcf4e1bcf4 100644 --- a/packages/frontend/editor-ui/src/components/CredentialEdit/CredentialSharing.ee.vue +++ b/packages/frontend/editor-ui/src/components/CredentialEdit/CredentialSharing.ee.vue @@ -82,11 +82,12 @@ const credentialRoleTranslations = computed>(() => { }); const credentialRoles = computed(() => { - return rolesStore.processedCredentialRoles.map(({ role, scopes, licensed }) => ({ + return rolesStore.processedCredentialRoles.map(({ role, scopes, licensed, description }) => ({ role, name: credentialRoleTranslations.value[role], scopes, licensed, + description, })); }); diff --git a/packages/frontend/editor-ui/src/components/WorkflowShareModal.ee.test.ts b/packages/frontend/editor-ui/src/components/WorkflowShareModal.ee.test.ts index a1700556ea..d0ca48237a 100644 --- a/packages/frontend/editor-ui/src/components/WorkflowShareModal.ee.test.ts +++ b/packages/frontend/editor-ui/src/components/WorkflowShareModal.ee.test.ts @@ -88,8 +88,14 @@ describe('WorkflowShareModal.ee.vue', () => { workflowsEEStore.getWorkflowOwnerName = vi.fn(() => 'Owner Name'); projectsStore.personalProjects = [createProjectListItem()]; rolesStore.processedWorkflowRoles = [ - { name: 'Editor', role: 'workflow:editor', scopes: [], licensed: false }, - { name: 'Owner', role: 'workflow:owner', scopes: [], licensed: false }, + { + name: 'Editor', + role: 'workflow:editor', + scopes: [], + licensed: false, + description: 'Editor', + }, + { name: 'Owner', role: 'workflow:owner', scopes: [], licensed: false, description: 'Owner' }, ]; workflowSaving = useWorkflowSaving({ router: useRouter() }); diff --git a/packages/frontend/editor-ui/src/components/WorkflowShareModal.ee.vue b/packages/frontend/editor-ui/src/components/WorkflowShareModal.ee.vue index a2c35214f2..71493a7ad7 100644 --- a/packages/frontend/editor-ui/src/components/WorkflowShareModal.ee.vue +++ b/packages/frontend/editor-ui/src/components/WorkflowShareModal.ee.vue @@ -108,11 +108,12 @@ const workflowRoleTranslations = computed(() => ({ })); const workflowRoles = computed(() => - rolesStore.processedWorkflowRoles.map(({ role, scopes, licensed }) => ({ + rolesStore.processedWorkflowRoles.map(({ role, scopes, licensed, description }) => ({ role, name: workflowRoleTranslations.value[role], scopes, licensed, + description, })), ); diff --git a/packages/frontend/editor-ui/src/stores/roles.store.test.ts b/packages/frontend/editor-ui/src/stores/roles.store.test.ts index 906442330f..2e7a89a27b 100644 --- a/packages/frontend/editor-ui/src/stores/roles.store.test.ts +++ b/packages/frontend/editor-ui/src/stores/roles.store.test.ts @@ -19,6 +19,7 @@ describe('roles store', () => { { name: 'Project Admin', role: 'project:admin', + description: 'Project Admin', scopes: [ 'workflow:create', 'workflow:read', @@ -43,6 +44,7 @@ describe('roles store', () => { { name: 'Project Owner', role: 'project:personalOwner', + description: 'Project Owner', scopes: [ 'workflow:create', 'workflow:read', @@ -67,6 +69,7 @@ describe('roles store', () => { { name: 'Project Editor', role: 'project:editor', + description: 'Project Editor', scopes: [ 'workflow:create', 'workflow:read', @@ -87,6 +90,7 @@ describe('roles store', () => { { name: 'Project Viewer', role: 'project:viewer', + description: 'Project Viewer', scopes: [ 'credential:list', 'credential:read',