chore(core): Add timestamp fields to Role, and support counting role usages (#19171)

This commit is contained in:
Andreas Fitzek
2025-09-17 09:21:40 +02:00
committed by GitHub
parent 763d17bb1f
commit 69c81a6437
19 changed files with 1575 additions and 15 deletions

View File

@@ -80,6 +80,8 @@ export {
export { UpdateRoleDto } from './roles/update-role.dto';
export { CreateRoleDto } from './roles/create-role.dto';
export { RoleListQueryDto } from './roles/role-list-query.dto';
export { RoleGetQueryDto } from './roles/role-get-query.dto';
export { OidcConfigDto } from './oidc/config.dto';

View File

@@ -0,0 +1,68 @@
import { RoleGetQueryDto } from '../role-get-query.dto';
describe('RoleGetQueryDto', () => {
describe('Valid requests', () => {
test.each([
{
name: 'with "true"',
request: {
withUsageCount: 'true',
},
},
{
name: 'with "false"',
request: {
withUsageCount: 'false',
},
},
{
name: 'without withUsageCount (uses default)',
request: {},
},
])('should pass validation for withUsageCount $name', ({ request }) => {
const result = RoleGetQueryDto.safeParse(request);
expect(result.success).toBe(true);
});
});
describe('Invalid requests', () => {
test.each([
{
name: 'with number',
request: {
withUsageCount: 1,
},
expectedErrorPath: ['withUsageCount'],
},
{
name: 'with boolean (true)',
request: {
withUsageCount: true,
},
expectedErrorPath: ['withUsageCount'],
},
{
name: 'with boolean (false)',
request: {
withUsageCount: false,
},
expectedErrorPath: ['withUsageCount'],
},
{
name: 'with invalid string',
request: {
withUsageCount: 'invalid',
},
expectedErrorPath: ['withUsageCount'],
},
])('should fail validation for withUsageCount $name', ({ request, expectedErrorPath }) => {
const result = RoleGetQueryDto.safeParse(request);
expect(result.success).toBe(false);
if (expectedErrorPath) {
expect(result.error?.issues[0].path).toEqual(expectedErrorPath);
}
});
});
});

View File

@@ -0,0 +1,68 @@
import { RoleListQueryDto } from '../role-list-query.dto';
describe('RoleListQueryDto', () => {
describe('Valid requests', () => {
test.each([
{
name: 'with "true"',
request: {
withUsageCount: 'true',
},
},
{
name: 'with "false"',
request: {
withUsageCount: 'false',
},
},
{
name: 'without withUsageCount (uses default)',
request: {},
},
])('should pass validation for withUsageCount $name', ({ request }) => {
const result = RoleListQueryDto.safeParse(request);
expect(result.success).toBe(true);
});
});
describe('Invalid requests', () => {
test.each([
{
name: 'with number',
request: {
withUsageCount: 1,
},
expectedErrorPath: ['withUsageCount'],
},
{
name: 'with boolean (true)',
request: {
withUsageCount: true,
},
expectedErrorPath: ['withUsageCount'],
},
{
name: 'with boolean (false)',
request: {
withUsageCount: false,
},
expectedErrorPath: ['withUsageCount'],
},
{
name: 'with invalid string',
request: {
withUsageCount: 'invalid',
},
expectedErrorPath: ['withUsageCount'],
},
])('should fail validation for withUsageCount $name', ({ request, expectedErrorPath }) => {
const result = RoleListQueryDto.safeParse(request);
expect(result.success).toBe(false);
if (expectedErrorPath) {
expect(result.error?.issues[0].path).toEqual(expectedErrorPath);
}
});
});
});

View File

@@ -0,0 +1,10 @@
import { Z } from 'zod-class';
import { booleanFromString } from '../../schemas/boolean-from-string';
/**
* Query DTO for retrieving a single role with optional usage count
*/
export class RoleGetQueryDto extends Z.class({
withUsageCount: booleanFromString.optional().default('false'),
}) {}

View File

@@ -0,0 +1,10 @@
import { Z } from 'zod-class';
import { booleanFromString } from '../../schemas/boolean-from-string';
/**
* Query DTO for listing roles with optional usage count
*/
export class RoleListQueryDto extends Z.class({
withUsageCount: booleanFromString.optional().default('false'),
}) {}

View File

@@ -1,12 +1,13 @@
import { Column, Entity, JoinTable, ManyToMany, OneToMany, PrimaryColumn } from '@n8n/typeorm';
import { WithTimestamps } from './abstract-entity';
import type { ProjectRelation } from './project-relation';
import { Scope } from './scope';
@Entity({
name: 'role',
})
export class Role {
export class Role extends WithTimestamps {
@PrimaryColumn({
type: String,
name: 'slug',

View File

@@ -0,0 +1,46 @@
import { Column } from '../dsl/column';
import type { IrreversibleMigration, MigrationContext } from '../migration-types';
const ROLE_TABLE_NAME = 'role';
const PROJECT_RELATION_TABLE_NAME = 'project_relation';
const USER_TABLE_NAME = 'user';
const PROJECT_RELATION_ROLE_IDX_NAME = 'project_relation_role_idx';
const PROJECT_RELATION_ROLE_PROJECT_IDX_NAME = 'project_relation_role_project_idx';
const USER_ROLE_IDX_NAME = 'user_role_idx';
export class AddTimestampsToRoleAndRoleIndexes1756906557570 implements IrreversibleMigration {
async up({ schemaBuilder, queryRunner, tablePrefix }: MigrationContext) {
// This loads the table metadata from the database and
// feeds the query runners cache with the table metadata
// Not doing this, seems to get TypeORM to wrongfully try to
// add the columns twice in the same statement.
await queryRunner.getTable(`${tablePrefix}${USER_TABLE_NAME}`);
await schemaBuilder.addColumns(ROLE_TABLE_NAME, [
new Column('createdAt').timestampTimezone().notNull.default('NOW()'),
new Column('updatedAt').timestampTimezone().notNull.default('NOW()'),
]);
// This index should allow us to efficiently query project relations by their role
// This will be used for counting how many users have a specific project role
await schemaBuilder.createIndex(
PROJECT_RELATION_TABLE_NAME,
['role'],
false,
PROJECT_RELATION_ROLE_IDX_NAME,
);
// This index should allow us to efficiently query project relations by their role and project
// This will be used for counting how many users in a specific project have a specific project role
await schemaBuilder.createIndex(
PROJECT_RELATION_TABLE_NAME,
['projectId', 'role'],
false,
PROJECT_RELATION_ROLE_PROJECT_IDX_NAME,
);
// This index should allow us to efficiently query users by their role slug
// This will be used for counting how many users have a specific global role
await schemaBuilder.createIndex(USER_TABLE_NAME, ['roleSlug'], false, USER_ROLE_IDX_NAME);
}
}

View File

@@ -96,6 +96,7 @@ import { RemoveOldRoleColumn1750252139170 } from '../common/1750252139170-Remove
import { AddInputsOutputsToTestCaseExecution1752669793000 } from '../common/1752669793000-AddInputsOutputsToTestCaseExecution';
import { CreateDataStoreTables1754475614601 } from '../common/1754475614601-CreateDataStoreTables';
import { ReplaceDataStoreTablesWithDataTables1754475614602 } from '../common/1754475614602-ReplaceDataStoreTablesWithDataTables';
import { AddTimestampsToRoleAndRoleIndexes1756906557570 } from '../common/1756906557570-AddTimestampsToRoleAndRoleIndexes';
import type { Migration } from '../migration-types';
import { UpdateParentFolderIdColumn1740445074052 } from '../mysqldb/1740445074052-UpdateParentFolderIdColumn';
@@ -199,4 +200,5 @@ export const mysqlMigrations: Migration[] = [
RemoveOldRoleColumn1750252139170,
ReplaceDataStoreTablesWithDataTables1754475614602,
LinkRoleToProjectRelationTable1753953244168,
AddTimestampsToRoleAndRoleIndexes1756906557570,
];

View File

@@ -96,6 +96,7 @@ import { LinkRoleToUserTable1750252139168 } from '../common/1750252139168-LinkRo
import { RemoveOldRoleColumn1750252139170 } from '../common/1750252139170-RemoveOldRoleColumn';
import { CreateDataStoreTables1754475614601 } from '../common/1754475614601-CreateDataStoreTables';
import { ReplaceDataStoreTablesWithDataTables1754475614602 } from '../common/1754475614602-ReplaceDataStoreTablesWithDataTables';
import { AddTimestampsToRoleAndRoleIndexes1756906557570 } from '../common/1756906557570-AddTimestampsToRoleAndRoleIndexes';
import type { Migration } from '../migration-types';
export const postgresMigrations: Migration[] = [
@@ -197,4 +198,5 @@ export const postgresMigrations: Migration[] = [
RemoveOldRoleColumn1750252139170,
ReplaceDataStoreTablesWithDataTables1754475614602,
LinkRoleToProjectRelationTable1753953244168,
AddTimestampsToRoleAndRoleIndexes1756906557570,
];

View File

@@ -92,6 +92,7 @@ import { RemoveOldRoleColumn1750252139170 } from '../common/1750252139170-Remove
import { AddInputsOutputsToTestCaseExecution1752669793000 } from '../common/1752669793000-AddInputsOutputsToTestCaseExecution';
import { CreateDataStoreTables1754475614601 } from '../common/1754475614601-CreateDataStoreTables';
import { ReplaceDataStoreTablesWithDataTables1754475614602 } from '../common/1754475614602-ReplaceDataStoreTablesWithDataTables';
import { AddTimestampsToRoleAndRoleIndexes1756906557570 } from '../common/1756906557570-AddTimestampsToRoleAndRoleIndexes';
import type { Migration } from '../migration-types';
import { LinkRoleToProjectRelationTable1753953244168 } from './../common/1753953244168-LinkRoleToProjectRelationTable';
@@ -191,6 +192,7 @@ const sqliteMigrations: Migration[] = [
RemoveOldRoleColumn1750252139170,
ReplaceDataStoreTablesWithDataTables1754475614602,
LinkRoleToProjectRelationTable1753953244168,
AddTimestampsToRoleAndRoleIndexes1756906557570,
];
export { sqliteMigrations };

View File

@@ -3,7 +3,7 @@ import { Service } from '@n8n/di';
import { DataSource, EntityManager, In, Repository } from '@n8n/typeorm';
import { UserError } from 'n8n-workflow';
import { Role } from '../entities';
import { ProjectRelation, Role, User } from '../entities';
@Service()
export class RoleRepository extends Repository<Role> {
@@ -18,6 +18,51 @@ export class RoleRepository extends Repository<Role> {
return await this.find({ relations: ['scopes'] });
}
async countUsersWithRole(role: Role): Promise<number> {
if (role.roleType === 'global') {
return await this.manager.getRepository(User).count({
where: {
role: {
slug: role.slug,
},
},
});
} else if (role.roleType === 'project') {
return await this.manager.getRepository(ProjectRelation).count({
where: { role: { slug: role.slug } },
});
}
return 0;
}
async findAllRoleCounts() {
const userCount = await this.manager
.createQueryBuilder(User, 'user')
.select('user.roleSlug', 'roleSlug')
.addSelect('COUNT(user.id)', 'count')
.groupBy('user.roleSlug')
.getRawMany<{ roleSlug: string; count: string }>();
const projectCount = await this.manager
.createQueryBuilder(ProjectRelation, 'projectRelation')
.select('projectRelation.role', 'roleSlug')
.addSelect('COUNT(projectRelation.user)', 'count')
.groupBy('projectRelation.role')
.getRawMany<{ roleSlug: string; count: string }>();
return userCount.concat(projectCount).reduce(
(acc, { roleSlug, count }) => {
if (!acc[roleSlug]) {
acc[roleSlug] = 0;
}
acc[roleSlug] += parseInt(count, 10);
return acc;
},
{} as Record<string, number>,
);
}
async findBySlug(slug: string) {
return await this.findOne({
where: { slug },

View File

@@ -62,6 +62,9 @@ export const roleSchema = z.object({
roleType: roleNamespaceSchema,
licensed: z.boolean(),
scopes: z.array(scopeSchema),
createdAt: z.date().optional(),
updatedAt: z.date().optional(),
usedByUsers: z.number().optional(),
});
export type Role = z.infer<typeof roleSchema>;