chore(core): Move scopes and roles into database in preparation for custom roles (#17226)

This commit is contained in:
Andreas Fitzek
2025-08-18 06:58:48 +02:00
committed by GitHub
parent 1976a91e5c
commit 18e32fe774
29 changed files with 658 additions and 10 deletions

View File

@@ -17,6 +17,8 @@ import { InvalidAuthToken } from './invalid-auth-token';
import { ProcessedData } from './processed-data'; import { ProcessedData } from './processed-data';
import { Project } from './project'; import { Project } from './project';
import { ProjectRelation } from './project-relation'; import { ProjectRelation } from './project-relation';
import { Role } from './role';
import { Scope } from './scope';
import { Settings } from './settings'; import { Settings } from './settings';
import { SharedCredentials } from './shared-credentials'; import { SharedCredentials } from './shared-credentials';
import { SharedWorkflow } from './shared-workflow'; import { SharedWorkflow } from './shared-workflow';
@@ -46,6 +48,8 @@ export {
Folder, Folder,
Project, Project,
ProjectRelation, ProjectRelation,
Role,
Scope,
SharedCredentials, SharedCredentials,
SharedWorkflow, SharedWorkflow,
TagEntity, TagEntity,
@@ -81,6 +85,7 @@ export const entities = {
Folder, Folder,
Project, Project,
ProjectRelation, ProjectRelation,
Scope,
SharedCredentials, SharedCredentials,
SharedWorkflow, SharedWorkflow,
TagEntity, TagEntity,
@@ -99,4 +104,5 @@ export const entities = {
TestRun, TestRun,
TestCaseExecution, TestCaseExecution,
ExecutionEntity, ExecutionEntity,
Role,
}; };

View File

@@ -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[];
}

View File

@@ -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;
}

View File

@@ -1,5 +1,4 @@
import type { AuthPrincipal } from '@n8n/permissions'; import type { AuthPrincipal, GlobalRole } from '@n8n/permissions';
import { GlobalRole } from '@n8n/permissions';
import { import {
AfterLoad, AfterLoad,
AfterUpdate, AfterUpdate,

View File

@@ -33,3 +33,5 @@ export { wrapMigration } from './migrations/migration-helpers';
export * from './migrations/migration-types'; export * from './migrations/migration-types';
export { DbConnection } from './connection/db-connection'; export { DbConnection } from './connection/db-connection';
export { DbConnectionOptions } from './connection/db-connection-options'; export { DbConnectionOptions } from './connection/db-connection-options';
export { AuthRolesService } from './services/auth.roles.service';

View File

@@ -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');
}
}

View File

@@ -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');
}
}

View File

@@ -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']);
}
}

View File

@@ -88,6 +88,9 @@ import { AddWorkflowArchivedColumn1745934666076 } from '../common/1745934666076-
import { DropRoleTable1745934666077 } from '../common/1745934666077-DropRoleTable'; import { DropRoleTable1745934666077 } from '../common/1745934666077-DropRoleTable';
import { AddProjectDescriptionColumn1747824239000 } from '../common/1747824239000-AddProjectDescriptionColumn'; import { AddProjectDescriptionColumn1747824239000 } from '../common/1747824239000-AddProjectDescriptionColumn';
import { AddLastActiveAtColumnToUser1750252139166 } from '../common/1750252139166-AddLastActiveAtColumnToUser'; 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 { AddInputsOutputsToTestCaseExecution1752669793000 } from '../common/1752669793000-AddInputsOutputsToTestCaseExecution';
import { CreateDataStoreTables1754475614601 } from '../common/1754475614601-CreateDataStoreTables'; import { CreateDataStoreTables1754475614601 } from '../common/1754475614601-CreateDataStoreTables';
import type { Migration } from '../migration-types'; import type { Migration } from '../migration-types';
@@ -185,6 +188,9 @@ export const mysqlMigrations: Migration[] = [
ClearEvaluation1745322634000, ClearEvaluation1745322634000,
AddProjectDescriptionColumn1747824239000, AddProjectDescriptionColumn1747824239000,
AddLastActiveAtColumnToUser1750252139166, AddLastActiveAtColumnToUser1750252139166,
AddScopeTables1750252139166,
AddRolesTables1750252139167,
LinkRoleToUserTable1750252139168,
AddInputsOutputsToTestCaseExecution1752669793000, AddInputsOutputsToTestCaseExecution1752669793000,
CreateDataStoreTables1754475614601, CreateDataStoreTables1754475614601,
]; ];

View File

@@ -89,6 +89,9 @@ import { AddWorkflowArchivedColumn1745934666076 } from '../common/1745934666076-
import { DropRoleTable1745934666077 } from '../common/1745934666077-DropRoleTable'; import { DropRoleTable1745934666077 } from '../common/1745934666077-DropRoleTable';
import { AddProjectDescriptionColumn1747824239000 } from '../common/1747824239000-AddProjectDescriptionColumn'; import { AddProjectDescriptionColumn1747824239000 } from '../common/1747824239000-AddProjectDescriptionColumn';
import { AddLastActiveAtColumnToUser1750252139166 } from '../common/1750252139166-AddLastActiveAtColumnToUser'; 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 { CreateDataStoreTables1754475614601 } from '../common/1754475614601-CreateDataStoreTables';
import type { Migration } from '../migration-types'; import type { Migration } from '../migration-types';
@@ -183,6 +186,9 @@ export const postgresMigrations: Migration[] = [
ClearEvaluation1745322634000, ClearEvaluation1745322634000,
AddProjectDescriptionColumn1747824239000, AddProjectDescriptionColumn1747824239000,
AddLastActiveAtColumnToUser1750252139166, AddLastActiveAtColumnToUser1750252139166,
AddScopeTables1750252139166,
AddRolesTables1750252139167,
LinkRoleToUserTable1750252139168,
AddInputsOutputsToTestCaseExecution1752669793000, AddInputsOutputsToTestCaseExecution1752669793000,
CreateDataStoreTables1754475614601, CreateDataStoreTables1754475614601,
]; ];

View File

@@ -85,6 +85,9 @@ import { AddWorkflowArchivedColumn1745934666076 } from '../common/1745934666076-
import { DropRoleTable1745934666077 } from '../common/1745934666077-DropRoleTable'; import { DropRoleTable1745934666077 } from '../common/1745934666077-DropRoleTable';
import { AddProjectDescriptionColumn1747824239000 } from '../common/1747824239000-AddProjectDescriptionColumn'; import { AddProjectDescriptionColumn1747824239000 } from '../common/1747824239000-AddProjectDescriptionColumn';
import { AddLastActiveAtColumnToUser1750252139166 } from '../common/1750252139166-AddLastActiveAtColumnToUser'; 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 { AddInputsOutputsToTestCaseExecution1752669793000 } from '../common/1752669793000-AddInputsOutputsToTestCaseExecution';
import { CreateDataStoreTables1754475614601 } from '../common/1754475614601-CreateDataStoreTables'; import { CreateDataStoreTables1754475614601 } from '../common/1754475614601-CreateDataStoreTables';
import type { Migration } from '../migration-types'; import type { Migration } from '../migration-types';
@@ -177,6 +180,9 @@ const sqliteMigrations: Migration[] = [
ClearEvaluation1745322634000, ClearEvaluation1745322634000,
AddProjectDescriptionColumn1747824239000, AddProjectDescriptionColumn1747824239000,
AddLastActiveAtColumnToUser1750252139166, AddLastActiveAtColumnToUser1750252139166,
AddScopeTables1750252139166,
AddRolesTables1750252139167,
LinkRoleToUserTable1750252139168,
AddInputsOutputsToTestCaseExecution1752669793000, AddInputsOutputsToTestCaseExecution1752669793000,
CreateDataStoreTables1754475614601, CreateDataStoreTables1754475614601,
]; ];

View File

@@ -11,12 +11,14 @@ export { ExecutionRepository } from './execution.repository';
export { EventDestinationsRepository } from './event-destinations.repository'; export { EventDestinationsRepository } from './event-destinations.repository';
export { FolderRepository } from './folder.repository'; export { FolderRepository } from './folder.repository';
export { FolderTagMappingRepository } from './folder-tag-mapping.repository'; export { FolderTagMappingRepository } from './folder-tag-mapping.repository';
export { ScopeRepository } from './scope.repository';
export { InstalledNodesRepository } from './installed-nodes.repository'; export { InstalledNodesRepository } from './installed-nodes.repository';
export { InstalledPackagesRepository } from './installed-packages.repository'; export { InstalledPackagesRepository } from './installed-packages.repository';
export { InvalidAuthTokenRepository } from './invalid-auth-token.repository'; export { InvalidAuthTokenRepository } from './invalid-auth-token.repository';
export { LicenseMetricsRepository } from './license-metrics.repository'; export { LicenseMetricsRepository } from './license-metrics.repository';
export { ProjectRelationRepository } from './project-relation.repository'; export { ProjectRelationRepository } from './project-relation.repository';
export { ProjectRepository } from './project.repository'; export { ProjectRepository } from './project.repository';
export { RoleRepository } from './role.repository';
export { ProcessedDataRepository } from './processed-data.repository'; export { ProcessedDataRepository } from './processed-data.repository';
export { SettingsRepository } from './settings.repository'; export { SettingsRepository } from './settings.repository';
export { TagRepository } from './tag.repository'; export { TagRepository } from './tag.repository';

View File

@@ -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<Role> {
constructor(dataSource: DataSource) {
super(Role, dataSource.manager);
}
}

View File

@@ -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<Scope> {
constructor(dataSource: DataSource) {
super(Scope, dataSource.manager);
}
}

View File

@@ -1,6 +1,5 @@
import type { UsersListFilterDto } from '@n8n/api-types'; import type { UsersListFilterDto } from '@n8n/api-types';
import { Service } from '@n8n/di'; import { Service } from '@n8n/di';
import type { GlobalRole } from '@n8n/permissions';
import type { DeepPartial, EntityManager, SelectQueryBuilder } from '@n8n/typeorm'; import type { DeepPartial, EntityManager, SelectQueryBuilder } from '@n8n/typeorm';
import { Brackets, DataSource, In, IsNull, Not, Repository } from '@n8n/typeorm'; import { Brackets, DataSource, In, IsNull, Not, Repository } from '@n8n/typeorm';
@@ -65,13 +64,13 @@ export class UserRepository extends Repository<User> {
const rows = (await this.createQueryBuilder() const rows = (await this.createQueryBuilder()
.select(['role', 'COUNT(role) as count']) .select(['role', 'COUNT(role) as count'])
.groupBy('role') .groupBy('role')
.execute()) as Array<{ role: GlobalRole; count: string }>; .execute()) as Array<{ role: string; count: string }>;
return rows.reduce( return rows.reduce(
(acc, row) => { (acc, row) => {
acc[row.role] = parseInt(row.count, 10); acc[row.role] = parseInt(row.count, 10);
return acc; return acc;
}, },
{} as Record<GlobalRole, number>, {} as Record<string, number>,
); );
} }

View File

@@ -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<keyof typeof ALL_ROLES>) {
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.');
}
}

View File

@@ -0,0 +1 @@
export { AuthRolesService } from './auth.roles.service';

View File

@@ -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:*",
"*",
]
`;

View File

@@ -0,0 +1,7 @@
import { ALL_SCOPES } from '@/scope-information';
describe('Scope Information', () => {
it('ensure scopes are defined correctly', () => {
expect(ALL_SCOPES).toMatchSnapshot();
});
});

View File

@@ -2,6 +2,7 @@ export type * from './types.ee';
export * from './constants.ee'; export * from './constants.ee';
export * from './roles/scopes/global-scopes.ee'; export * from './roles/scopes/global-scopes.ee';
export * from './scope-information';
export * from './roles/role-maps.ee'; export * from './roles/role-maps.ee';
export * from './roles/all-roles'; export * from './roles/all-roles';

View File

@@ -26,6 +26,7 @@ const mapToRoleObject = <T extends keyof typeof ROLE_NAMES>(roles: Record<T, Sco
role, role,
name: ROLE_NAMES[role], name: ROLE_NAMES[role],
scopes: getRoleScopes(role), scopes: getRoleScopes(role),
description: ROLE_NAMES[role],
licensed: false, licensed: false,
})); }));

View File

@@ -0,0 +1,21 @@
import { RESOURCES } from './constants.ee';
import type { Scope, ScopeInformation } from './types.ee';
function buildResourceScopes() {
const resourceScopes = Object.entries(RESOURCES).flatMap(([resource, operations]) => [
...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<Record<Scope, ScopeInformation>> = {
'annotationTag:create': {
displayName: 'Create Annotation Tag',
description: 'Allows creating new annotation tags.',
},
};

View File

@@ -11,6 +11,11 @@ import type {
workflowSharingRoleSchema, workflowSharingRoleSchema,
} from './schemas.ee'; } from './schemas.ee';
export type ScopeInformation = {
displayName: string;
description?: string | null;
};
/** Represents a resource that can have permissions applied to it */ /** Represents a resource that can have permissions applied to it */
export type Resource = keyof typeof RESOURCES; export type Resource = keyof typeof RESOURCES;
@@ -59,6 +64,7 @@ export type AllRoleTypes = GlobalRole | ProjectRole | WorkflowSharingRole | Cred
type RoleObject<T extends AllRoleTypes> = { type RoleObject<T extends AllRoleTypes> = {
role: T; role: T;
name: string; name: string;
description?: string | null;
scopes: Scope[]; scopes: Scope[];
licensed: boolean; licensed: boolean;
}; };

View File

@@ -1,7 +1,7 @@
/* eslint-disable @typescript-eslint/no-unsafe-call */ /* eslint-disable @typescript-eslint/no-unsafe-call */
/* eslint-disable @typescript-eslint/no-unsafe-member-access */ /* eslint-disable @typescript-eslint/no-unsafe-member-access */
import { LICENSE_FEATURES } from '@n8n/constants'; import { LICENSE_FEATURES } from '@n8n/constants';
import { ExecutionRepository, SettingsRepository } from '@n8n/db'; import { AuthRolesService, ExecutionRepository, SettingsRepository } from '@n8n/db';
import { Command } from '@n8n/decorators'; import { Command } from '@n8n/decorators';
import { Container } from '@n8n/di'; import { Container } from '@n8n/di';
import glob from 'fast-glob'; import glob from 'fast-glob';
@@ -172,6 +172,9 @@ export class Start extends BaseCommand<z.infer<typeof flagsSchema>> {
} }
await super.init(); await super.init();
await Container.get(AuthRolesService).init();
this.activeWorkflowManager = Container.get(ActiveWorkflowManager); this.activeWorkflowManager = Container.get(ActiveWorkflowManager);
const isMultiMainEnabled = const isMultiMainEnabled =

View File

@@ -23,17 +23,20 @@ let expectedGlobalRoles: Array<{
role: GlobalRole; role: GlobalRole;
scopes: Scope[]; scopes: Scope[];
licensed: boolean; licensed: boolean;
description: string;
}>; }>;
let expectedProjectRoles: Array<{ let expectedProjectRoles: Array<{
name: string; name: string;
role: ProjectRole; role: ProjectRole;
scopes: Scope[]; scopes: Scope[];
licensed: boolean; licensed: boolean;
description: string;
}>; }>;
let expectedCredentialRoles: Array<{ let expectedCredentialRoles: Array<{
name: string; name: string;
role: CredentialSharingRole; role: CredentialSharingRole;
scopes: Scope[]; scopes: Scope[];
description: string;
licensed: boolean; licensed: boolean;
}>; }>;
let expectedWorkflowRoles: Array<{ let expectedWorkflowRoles: Array<{
@@ -41,6 +44,7 @@ let expectedWorkflowRoles: Array<{
role: WorkflowSharingRole; role: WorkflowSharingRole;
scopes: Scope[]; scopes: Scope[];
licensed: boolean; licensed: boolean;
description: string;
}>; }>;
beforeAll(async () => { beforeAll(async () => {
@@ -52,18 +56,21 @@ beforeAll(async () => {
role: 'global:owner', role: 'global:owner',
scopes: getRoleScopes('global:owner'), scopes: getRoleScopes('global:owner'),
licensed: true, licensed: true,
description: 'Owner',
}, },
{ {
name: 'Admin', name: 'Admin',
role: 'global:admin', role: 'global:admin',
scopes: getRoleScopes('global:admin'), scopes: getRoleScopes('global:admin'),
licensed: false, licensed: false,
description: 'Admin',
}, },
{ {
name: 'Member', name: 'Member',
role: 'global:member', role: 'global:member',
scopes: getRoleScopes('global:member'), scopes: getRoleScopes('global:member'),
licensed: true, licensed: true,
description: 'Member',
}, },
]; ];
expectedProjectRoles = [ expectedProjectRoles = [
@@ -72,18 +79,21 @@ beforeAll(async () => {
role: 'project:personalOwner', role: 'project:personalOwner',
scopes: getRoleScopes('project:personalOwner'), scopes: getRoleScopes('project:personalOwner'),
licensed: true, licensed: true,
description: 'Project Owner',
}, },
{ {
name: 'Project Admin', name: 'Project Admin',
role: 'project:admin', role: 'project:admin',
scopes: getRoleScopes('project:admin'), scopes: getRoleScopes('project:admin'),
licensed: false, licensed: false,
description: 'Project Admin',
}, },
{ {
name: 'Project Editor', name: 'Project Editor',
role: 'project:editor', role: 'project:editor',
scopes: getRoleScopes('project:editor'), scopes: getRoleScopes('project:editor'),
licensed: false, licensed: false,
description: 'Project Editor',
}, },
]; ];
expectedCredentialRoles = [ expectedCredentialRoles = [
@@ -92,12 +102,14 @@ beforeAll(async () => {
role: 'credential:owner', role: 'credential:owner',
scopes: getRoleScopes('credential:owner'), scopes: getRoleScopes('credential:owner'),
licensed: true, licensed: true,
description: 'Credential Owner',
}, },
{ {
name: 'Credential User', name: 'Credential User',
role: 'credential:user', role: 'credential:user',
scopes: getRoleScopes('credential:user'), scopes: getRoleScopes('credential:user'),
licensed: true, licensed: true,
description: 'Credential User',
}, },
]; ];
expectedWorkflowRoles = [ expectedWorkflowRoles = [
@@ -106,12 +118,14 @@ beforeAll(async () => {
role: 'workflow:owner', role: 'workflow:owner',
scopes: getRoleScopes('workflow:owner'), scopes: getRoleScopes('workflow:owner'),
licensed: true, licensed: true,
description: 'Workflow Owner',
}, },
{ {
name: 'Workflow Editor', name: 'Workflow Editor',
role: 'workflow:editor', role: 'workflow:editor',
scopes: getRoleScopes('workflow:editor'), scopes: getRoleScopes('workflow:editor'),
licensed: true, licensed: true,
description: 'Workflow Editor',
}, },
]; ];
}); });

View File

@@ -82,11 +82,12 @@ const credentialRoleTranslations = computed<Record<string, string>>(() => {
}); });
const credentialRoles = computed<AllRolesMap['credential']>(() => { const credentialRoles = computed<AllRolesMap['credential']>(() => {
return rolesStore.processedCredentialRoles.map(({ role, scopes, licensed }) => ({ return rolesStore.processedCredentialRoles.map(({ role, scopes, licensed, description }) => ({
role, role,
name: credentialRoleTranslations.value[role], name: credentialRoleTranslations.value[role],
scopes, scopes,
licensed, licensed,
description,
})); }));
}); });

View File

@@ -88,8 +88,14 @@ describe('WorkflowShareModal.ee.vue', () => {
workflowsEEStore.getWorkflowOwnerName = vi.fn(() => 'Owner Name'); workflowsEEStore.getWorkflowOwnerName = vi.fn(() => 'Owner Name');
projectsStore.personalProjects = [createProjectListItem()]; projectsStore.personalProjects = [createProjectListItem()];
rolesStore.processedWorkflowRoles = [ 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() }); workflowSaving = useWorkflowSaving({ router: useRouter() });

View File

@@ -108,11 +108,12 @@ const workflowRoleTranslations = computed(() => ({
})); }));
const workflowRoles = computed(() => const workflowRoles = computed(() =>
rolesStore.processedWorkflowRoles.map(({ role, scopes, licensed }) => ({ rolesStore.processedWorkflowRoles.map(({ role, scopes, licensed, description }) => ({
role, role,
name: workflowRoleTranslations.value[role], name: workflowRoleTranslations.value[role],
scopes, scopes,
licensed, licensed,
description,
})), })),
); );

View File

@@ -19,6 +19,7 @@ describe('roles store', () => {
{ {
name: 'Project Admin', name: 'Project Admin',
role: 'project:admin', role: 'project:admin',
description: 'Project Admin',
scopes: [ scopes: [
'workflow:create', 'workflow:create',
'workflow:read', 'workflow:read',
@@ -43,6 +44,7 @@ describe('roles store', () => {
{ {
name: 'Project Owner', name: 'Project Owner',
role: 'project:personalOwner', role: 'project:personalOwner',
description: 'Project Owner',
scopes: [ scopes: [
'workflow:create', 'workflow:create',
'workflow:read', 'workflow:read',
@@ -67,6 +69,7 @@ describe('roles store', () => {
{ {
name: 'Project Editor', name: 'Project Editor',
role: 'project:editor', role: 'project:editor',
description: 'Project Editor',
scopes: [ scopes: [
'workflow:create', 'workflow:create',
'workflow:read', 'workflow:read',
@@ -87,6 +90,7 @@ describe('roles store', () => {
{ {
name: 'Project Viewer', name: 'Project Viewer',
role: 'project:viewer', role: 'project:viewer',
description: 'Project Viewer',
scopes: [ scopes: [
'credential:list', 'credential:list',
'credential:read', 'credential:read',