mirror of
https://github.com/Abdulazizzn/n8n-enterprise-unlocked.git
synced 2025-12-16 09:36:44 +00:00
chore(core): Add timestamp fields to Role, and support counting role usages (#19171)
This commit is contained in:
@@ -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';
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
10
packages/@n8n/api-types/src/dto/roles/role-get-query.dto.ts
Normal file
10
packages/@n8n/api-types/src/dto/roles/role-get-query.dto.ts
Normal 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'),
|
||||
}) {}
|
||||
10
packages/@n8n/api-types/src/dto/roles/role-list-query.dto.ts
Normal file
10
packages/@n8n/api-types/src/dto/roles/role-list-query.dto.ts
Normal 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'),
|
||||
}) {}
|
||||
@@ -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',
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
];
|
||||
|
||||
@@ -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,
|
||||
];
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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 },
|
||||
|
||||
@@ -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>;
|
||||
|
||||
Reference in New Issue
Block a user