mirror of
https://github.com/Abdulazizzn/n8n-enterprise-unlocked.git
synced 2025-12-16 17:46:45 +00:00
chore(core): Add custom role management service and endpoints (#18717)
This commit is contained in:
@@ -78,6 +78,9 @@ export {
|
||||
USERS_LIST_SORT_OPTIONS,
|
||||
} from './user/users-list-filter.dto';
|
||||
|
||||
export { UpdateRoleDto } from './roles/update-role.dto';
|
||||
export { CreateRoleDto } from './roles/create-role.dto';
|
||||
|
||||
export { OidcConfigDto } from './oidc/config.dto';
|
||||
|
||||
export { CreateDataStoreDto } from './data-store/create-data-store.dto';
|
||||
|
||||
@@ -0,0 +1,362 @@
|
||||
import { ALL_SCOPES } from '@n8n/permissions';
|
||||
|
||||
import { createRoleDtoSchema } from '../create-role.dto';
|
||||
|
||||
describe('createRoleDtoSchema', () => {
|
||||
describe('Valid requests', () => {
|
||||
test.each([
|
||||
{
|
||||
name: 'minimal valid request',
|
||||
request: {
|
||||
displayName: 'My Role',
|
||||
roleType: 'project',
|
||||
scopes: ['project:read'],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'with description',
|
||||
request: {
|
||||
displayName: 'Custom Project Role',
|
||||
description: 'A role for managing specific project tasks',
|
||||
roleType: 'project',
|
||||
scopes: ['project:read', 'project:update'],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'with multiple scopes',
|
||||
request: {
|
||||
displayName: 'Full Access Role',
|
||||
description: 'Complete project access',
|
||||
roleType: 'project',
|
||||
scopes: [
|
||||
'project:create',
|
||||
'project:read',
|
||||
'project:update',
|
||||
'project:delete',
|
||||
'workflow:create',
|
||||
'workflow:read',
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'with wildcard scopes',
|
||||
request: {
|
||||
displayName: 'Admin Role',
|
||||
roleType: 'project',
|
||||
scopes: ['project:*', 'workflow:*'],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'with global wildcard scope',
|
||||
request: {
|
||||
displayName: 'Super Admin',
|
||||
roleType: 'project',
|
||||
scopes: ['*'],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'displayName at minimum length',
|
||||
request: {
|
||||
displayName: 'My',
|
||||
roleType: 'project',
|
||||
scopes: ['project:read'],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'displayName at maximum length',
|
||||
request: {
|
||||
displayName: 'A'.repeat(100),
|
||||
roleType: 'project',
|
||||
scopes: ['project:read'],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'description at maximum length',
|
||||
request: {
|
||||
displayName: 'Test Role',
|
||||
description: 'A'.repeat(500),
|
||||
roleType: 'project',
|
||||
scopes: ['project:read'],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'empty description string',
|
||||
request: {
|
||||
displayName: 'Test Role',
|
||||
description: '',
|
||||
roleType: 'project',
|
||||
scopes: ['project:read'],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'with various resource scopes',
|
||||
request: {
|
||||
displayName: 'Multi-Resource Role',
|
||||
roleType: 'project',
|
||||
scopes: [
|
||||
'credential:read',
|
||||
'workflow:execute',
|
||||
'user:list',
|
||||
'tag:create',
|
||||
'variable:update',
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'with empty scopes array',
|
||||
request: {
|
||||
displayName: 'Role with no scopes',
|
||||
roleType: 'project',
|
||||
scopes: [],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'with extra fields (should be allowed)',
|
||||
request: {
|
||||
displayName: 'Test Role',
|
||||
roleType: 'project',
|
||||
scopes: ['project:read'],
|
||||
extraField: 'this is allowed by default zod behavior',
|
||||
},
|
||||
},
|
||||
])('should validate $name', ({ request }) => {
|
||||
const result = createRoleDtoSchema.safeParse(request);
|
||||
expect(result.success).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Invalid requests', () => {
|
||||
test.each([
|
||||
{
|
||||
name: 'missing displayName',
|
||||
request: {
|
||||
roleType: 'project',
|
||||
scopes: ['project:read'],
|
||||
},
|
||||
expectedErrorPath: ['displayName'],
|
||||
},
|
||||
{
|
||||
name: 'displayName too short',
|
||||
request: {
|
||||
displayName: 'A',
|
||||
roleType: 'project',
|
||||
scopes: ['project:read'],
|
||||
},
|
||||
expectedErrorPath: ['displayName'],
|
||||
},
|
||||
{
|
||||
name: 'displayName too long',
|
||||
request: {
|
||||
displayName: 'A'.repeat(101),
|
||||
roleType: 'project',
|
||||
scopes: ['project:read'],
|
||||
},
|
||||
expectedErrorPath: ['displayName'],
|
||||
},
|
||||
{
|
||||
name: 'empty displayName',
|
||||
request: {
|
||||
displayName: '',
|
||||
roleType: 'project',
|
||||
scopes: ['project:read'],
|
||||
},
|
||||
expectedErrorPath: ['displayName'],
|
||||
},
|
||||
{
|
||||
name: 'displayName as number',
|
||||
request: {
|
||||
displayName: 123,
|
||||
roleType: 'project',
|
||||
scopes: ['project:read'],
|
||||
},
|
||||
expectedErrorPath: ['displayName'],
|
||||
},
|
||||
{
|
||||
name: 'description too long',
|
||||
request: {
|
||||
displayName: 'Test Role',
|
||||
description: 'A'.repeat(501),
|
||||
roleType: 'project',
|
||||
scopes: ['project:read'],
|
||||
},
|
||||
expectedErrorPath: ['description'],
|
||||
},
|
||||
{
|
||||
name: 'description as number',
|
||||
request: {
|
||||
displayName: 'Test Role',
|
||||
description: 123,
|
||||
roleType: 'project',
|
||||
scopes: ['project:read'],
|
||||
},
|
||||
expectedErrorPath: ['description'],
|
||||
},
|
||||
{
|
||||
name: 'missing roleType',
|
||||
request: {
|
||||
displayName: 'Test Role',
|
||||
scopes: ['project:read'],
|
||||
},
|
||||
expectedErrorPath: ['roleType'],
|
||||
},
|
||||
{
|
||||
name: 'invalid roleType',
|
||||
request: {
|
||||
displayName: 'Test Role',
|
||||
roleType: 'invalid',
|
||||
scopes: ['project:read'],
|
||||
},
|
||||
expectedErrorPath: ['roleType'],
|
||||
},
|
||||
{
|
||||
name: 'roleType as number',
|
||||
request: {
|
||||
displayName: 'Test Role',
|
||||
roleType: 123,
|
||||
scopes: ['project:read'],
|
||||
},
|
||||
expectedErrorPath: ['roleType'],
|
||||
},
|
||||
{
|
||||
name: 'missing scopes',
|
||||
request: {
|
||||
displayName: 'Test Role',
|
||||
roleType: 'project',
|
||||
},
|
||||
expectedErrorPath: ['scopes'],
|
||||
},
|
||||
{
|
||||
name: 'scopes as string instead of array',
|
||||
request: {
|
||||
displayName: 'Test Role',
|
||||
roleType: 'project',
|
||||
scopes: 'project:read',
|
||||
},
|
||||
expectedErrorPath: ['scopes'],
|
||||
},
|
||||
{
|
||||
name: 'scopes as number',
|
||||
request: {
|
||||
displayName: 'Test Role',
|
||||
roleType: 'project',
|
||||
scopes: 123,
|
||||
},
|
||||
expectedErrorPath: ['scopes'],
|
||||
},
|
||||
{
|
||||
name: 'invalid scope in array',
|
||||
request: {
|
||||
displayName: 'Test Role',
|
||||
roleType: 'project',
|
||||
scopes: ['project:read', 'invalid:scope'],
|
||||
},
|
||||
expectedErrorPath: ['scopes', 1],
|
||||
},
|
||||
{
|
||||
name: 'scope as number in array',
|
||||
request: {
|
||||
displayName: 'Test Role',
|
||||
roleType: 'project',
|
||||
scopes: ['project:read', 123],
|
||||
},
|
||||
expectedErrorPath: ['scopes', 1],
|
||||
},
|
||||
{
|
||||
name: 'scope as object in array',
|
||||
request: {
|
||||
displayName: 'Test Role',
|
||||
roleType: 'project',
|
||||
scopes: ['project:read', { invalid: 'scope' }],
|
||||
},
|
||||
expectedErrorPath: ['scopes', 1],
|
||||
},
|
||||
{
|
||||
name: 'malformed scope format',
|
||||
request: {
|
||||
displayName: 'Test Role',
|
||||
roleType: 'project',
|
||||
scopes: ['project_read', 'workflow-create'],
|
||||
},
|
||||
expectedErrorPath: ['scopes', 0],
|
||||
},
|
||||
{
|
||||
name: 'empty scope string',
|
||||
request: {
|
||||
displayName: 'Test Role',
|
||||
roleType: 'project',
|
||||
scopes: ['project:read', ''],
|
||||
},
|
||||
expectedErrorPath: ['scopes', 1],
|
||||
},
|
||||
{
|
||||
name: 'null in scopes array',
|
||||
request: {
|
||||
displayName: 'Test Role',
|
||||
roleType: 'project',
|
||||
scopes: ['project:read', null],
|
||||
},
|
||||
expectedErrorPath: ['scopes', 1],
|
||||
},
|
||||
{
|
||||
name: 'undefined in scopes array',
|
||||
request: {
|
||||
displayName: 'Test Role',
|
||||
roleType: 'project',
|
||||
scopes: ['project:read', undefined],
|
||||
},
|
||||
expectedErrorPath: ['scopes', 1],
|
||||
},
|
||||
])('should fail validation for $name', ({ request, expectedErrorPath }) => {
|
||||
const result = createRoleDtoSchema.safeParse(request);
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
|
||||
if (expectedErrorPath) {
|
||||
expect(result.error?.issues[0].path).toEqual(expectedErrorPath);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('Scope validation integration', () => {
|
||||
test('should validate all valid resource scopes', () => {
|
||||
const validScopes = ALL_SCOPES;
|
||||
|
||||
for (const scope of validScopes) {
|
||||
const request = {
|
||||
displayName: 'Test Role',
|
||||
roleType: 'project' as const,
|
||||
scopes: [scope],
|
||||
};
|
||||
|
||||
const result = createRoleDtoSchema.safeParse(request);
|
||||
expect(result.success).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
test('should reject invalid scope formats', () => {
|
||||
const invalidScopes = [
|
||||
'invalid-scope',
|
||||
'project_read',
|
||||
'workflow-create',
|
||||
'project:invalid-operation',
|
||||
'invalid-resource:read',
|
||||
'project:',
|
||||
':read',
|
||||
'project::read',
|
||||
'**',
|
||||
'project:**',
|
||||
];
|
||||
|
||||
for (const scope of invalidScopes) {
|
||||
const request = {
|
||||
displayName: 'Test Role',
|
||||
roleType: 'project' as const,
|
||||
scopes: [scope],
|
||||
};
|
||||
|
||||
const result = createRoleDtoSchema.safeParse(request);
|
||||
expect(result.success).toBe(false);
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,464 @@
|
||||
import { ALL_SCOPES } from '@n8n/permissions';
|
||||
|
||||
import { updateRoleDtoSchema } from '../update-role.dto';
|
||||
|
||||
describe('updateRoleDtoSchema', () => {
|
||||
describe('Valid requests', () => {
|
||||
test.each([
|
||||
{
|
||||
name: 'empty object (all fields optional)',
|
||||
request: {},
|
||||
},
|
||||
{
|
||||
name: 'only displayName',
|
||||
request: {
|
||||
displayName: 'Updated Role Name',
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'only description',
|
||||
request: {
|
||||
description: 'Updated role description',
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'only scopes',
|
||||
request: {
|
||||
scopes: ['project:read', 'workflow:execute'],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'displayName and description',
|
||||
request: {
|
||||
displayName: 'Updated Custom Role',
|
||||
description: 'An updated description for the role',
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'displayName and scopes',
|
||||
request: {
|
||||
displayName: 'Enhanced Role',
|
||||
scopes: ['project:*', 'workflow:read'],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'description and scopes',
|
||||
request: {
|
||||
description: 'Role with updated permissions',
|
||||
scopes: ['credential:read', 'user:list'],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'all fields',
|
||||
request: {
|
||||
displayName: 'Completely Updated Role',
|
||||
description: 'A role with all fields updated',
|
||||
scopes: ['project:create', 'workflow:execute', 'credential:share'],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'displayName at minimum length',
|
||||
request: {
|
||||
displayName: 'Up',
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'displayName at maximum length',
|
||||
request: {
|
||||
displayName: 'B'.repeat(100),
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'description at maximum length',
|
||||
request: {
|
||||
description: 'C'.repeat(500),
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'empty description string',
|
||||
request: {
|
||||
description: '',
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'single scope',
|
||||
request: {
|
||||
scopes: ['project:read'],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'multiple scopes',
|
||||
request: {
|
||||
scopes: [
|
||||
'project:create',
|
||||
'project:read',
|
||||
'project:update',
|
||||
'workflow:execute',
|
||||
'credential:share',
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'wildcard scopes',
|
||||
request: {
|
||||
scopes: ['project:*', 'workflow:*'],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'global wildcard scope',
|
||||
request: {
|
||||
scopes: ['*'],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'empty scopes array',
|
||||
request: {
|
||||
scopes: [],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'various resource scopes',
|
||||
request: {
|
||||
scopes: [
|
||||
'credential:read',
|
||||
'workflow:execute',
|
||||
'user:list',
|
||||
'tag:create',
|
||||
'variable:update',
|
||||
'folder:move',
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'with extra fields (should be allowed)',
|
||||
request: {
|
||||
displayName: 'Test Role',
|
||||
extraField: 'this is allowed by default zod behavior',
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'with roleType field (ignored but allowed)',
|
||||
request: {
|
||||
displayName: 'Test Role',
|
||||
roleType: 'project',
|
||||
},
|
||||
},
|
||||
])('should validate $name', ({ request }) => {
|
||||
const result = updateRoleDtoSchema.safeParse(request);
|
||||
expect(result.success).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Invalid requests', () => {
|
||||
test.each([
|
||||
{
|
||||
name: 'displayName too short',
|
||||
request: {
|
||||
displayName: 'A',
|
||||
},
|
||||
expectedErrorPath: ['displayName'],
|
||||
},
|
||||
{
|
||||
name: 'displayName too long',
|
||||
request: {
|
||||
displayName: 'A'.repeat(101),
|
||||
},
|
||||
expectedErrorPath: ['displayName'],
|
||||
},
|
||||
{
|
||||
name: 'empty displayName',
|
||||
request: {
|
||||
displayName: '',
|
||||
},
|
||||
expectedErrorPath: ['displayName'],
|
||||
},
|
||||
{
|
||||
name: 'displayName as number',
|
||||
request: {
|
||||
displayName: 123,
|
||||
},
|
||||
expectedErrorPath: ['displayName'],
|
||||
},
|
||||
{
|
||||
name: 'displayName as boolean',
|
||||
request: {
|
||||
displayName: true,
|
||||
},
|
||||
expectedErrorPath: ['displayName'],
|
||||
},
|
||||
{
|
||||
name: 'displayName as object',
|
||||
request: {
|
||||
displayName: { name: 'test' },
|
||||
},
|
||||
expectedErrorPath: ['displayName'],
|
||||
},
|
||||
{
|
||||
name: 'displayName as array',
|
||||
request: {
|
||||
displayName: ['test'],
|
||||
},
|
||||
expectedErrorPath: ['displayName'],
|
||||
},
|
||||
{
|
||||
name: 'description too long',
|
||||
request: {
|
||||
description: 'A'.repeat(501),
|
||||
},
|
||||
expectedErrorPath: ['description'],
|
||||
},
|
||||
{
|
||||
name: 'description as number',
|
||||
request: {
|
||||
description: 123,
|
||||
},
|
||||
expectedErrorPath: ['description'],
|
||||
},
|
||||
{
|
||||
name: 'description as boolean',
|
||||
request: {
|
||||
description: false,
|
||||
},
|
||||
expectedErrorPath: ['description'],
|
||||
},
|
||||
{
|
||||
name: 'description as object',
|
||||
request: {
|
||||
description: { desc: 'test' },
|
||||
},
|
||||
expectedErrorPath: ['description'],
|
||||
},
|
||||
{
|
||||
name: 'description as array',
|
||||
request: {
|
||||
description: ['test'],
|
||||
},
|
||||
expectedErrorPath: ['description'],
|
||||
},
|
||||
{
|
||||
name: 'scopes as string instead of array',
|
||||
request: {
|
||||
scopes: 'project:read',
|
||||
},
|
||||
expectedErrorPath: ['scopes'],
|
||||
},
|
||||
{
|
||||
name: 'scopes as number',
|
||||
request: {
|
||||
scopes: 123,
|
||||
},
|
||||
expectedErrorPath: ['scopes'],
|
||||
},
|
||||
{
|
||||
name: 'scopes as boolean',
|
||||
request: {
|
||||
scopes: true,
|
||||
},
|
||||
expectedErrorPath: ['scopes'],
|
||||
},
|
||||
{
|
||||
name: 'scopes as object',
|
||||
request: {
|
||||
scopes: { scope: 'project:read' },
|
||||
},
|
||||
expectedErrorPath: ['scopes'],
|
||||
},
|
||||
{
|
||||
name: 'invalid scope in array',
|
||||
request: {
|
||||
scopes: ['project:read', 'invalid:scope'],
|
||||
},
|
||||
expectedErrorPath: ['scopes', 1],
|
||||
},
|
||||
{
|
||||
name: 'scope as number in array',
|
||||
request: {
|
||||
scopes: ['project:read', 123],
|
||||
},
|
||||
expectedErrorPath: ['scopes', 1],
|
||||
},
|
||||
{
|
||||
name: 'scope as boolean in array',
|
||||
request: {
|
||||
scopes: ['project:read', false],
|
||||
},
|
||||
expectedErrorPath: ['scopes', 1],
|
||||
},
|
||||
{
|
||||
name: 'scope as object in array',
|
||||
request: {
|
||||
scopes: ['project:read', { scope: 'workflow:read' }],
|
||||
},
|
||||
expectedErrorPath: ['scopes', 1],
|
||||
},
|
||||
{
|
||||
name: 'scope as array in array',
|
||||
request: {
|
||||
scopes: ['project:read', ['workflow:read']],
|
||||
},
|
||||
expectedErrorPath: ['scopes', 1],
|
||||
},
|
||||
{
|
||||
name: 'malformed scope format',
|
||||
request: {
|
||||
scopes: ['project_read', 'workflow-create'],
|
||||
},
|
||||
expectedErrorPath: ['scopes', 0],
|
||||
},
|
||||
{
|
||||
name: 'empty scope string',
|
||||
request: {
|
||||
scopes: ['project:read', ''],
|
||||
},
|
||||
expectedErrorPath: ['scopes', 1],
|
||||
},
|
||||
{
|
||||
name: 'null in scopes array',
|
||||
request: {
|
||||
scopes: ['project:read', null],
|
||||
},
|
||||
expectedErrorPath: ['scopes', 1],
|
||||
},
|
||||
{
|
||||
name: 'undefined in scopes array',
|
||||
request: {
|
||||
scopes: ['project:read', undefined],
|
||||
},
|
||||
expectedErrorPath: ['scopes', 1],
|
||||
},
|
||||
])('should fail validation for $name', ({ request, expectedErrorPath }) => {
|
||||
const result = updateRoleDtoSchema.safeParse(request);
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
|
||||
if (expectedErrorPath) {
|
||||
expect(result.error?.issues[0].path).toEqual(expectedErrorPath);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('Scope validation integration', () => {
|
||||
test('should validate all valid resource scopes', () => {
|
||||
const validScopes = ALL_SCOPES;
|
||||
|
||||
for (const scope of validScopes) {
|
||||
const request = {
|
||||
scopes: [scope],
|
||||
};
|
||||
|
||||
const result = updateRoleDtoSchema.safeParse(request);
|
||||
expect(result.success).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
test('should reject invalid scope formats', () => {
|
||||
const invalidScopes = [
|
||||
'invalid-scope',
|
||||
'project_read',
|
||||
'workflow-create',
|
||||
'project:invalid-operation',
|
||||
'invalid-resource:read',
|
||||
'project:',
|
||||
':read',
|
||||
'project::read',
|
||||
'**',
|
||||
'project:**',
|
||||
];
|
||||
|
||||
for (const scope of invalidScopes) {
|
||||
const request = {
|
||||
scopes: [scope],
|
||||
};
|
||||
|
||||
const result = updateRoleDtoSchema.safeParse(request);
|
||||
expect(result.success).toBe(false);
|
||||
}
|
||||
});
|
||||
|
||||
test('should handle mixed valid and invalid scopes', () => {
|
||||
const request = {
|
||||
scopes: ['project:read', 'invalid-scope', 'workflow:execute'],
|
||||
};
|
||||
|
||||
const result = updateRoleDtoSchema.safeParse(request);
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error?.issues[0].path).toEqual(['scopes', 1]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Field combination tests', () => {
|
||||
test('should validate partial updates with different field combinations', () => {
|
||||
const validCombinations = [
|
||||
{ displayName: 'New Name' },
|
||||
{ description: 'New description' },
|
||||
{ scopes: ['project:read'] },
|
||||
{ displayName: 'New Name', description: 'New description' },
|
||||
{ displayName: 'New Name', scopes: ['workflow:execute'] },
|
||||
{ description: 'New description', scopes: ['credential:share'] },
|
||||
{
|
||||
displayName: 'Complete Update',
|
||||
description: 'Full update description',
|
||||
scopes: ['*'],
|
||||
},
|
||||
];
|
||||
|
||||
for (const request of validCombinations) {
|
||||
const result = updateRoleDtoSchema.safeParse(request);
|
||||
expect(result.success).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
test('should handle boundary conditions in combinations', () => {
|
||||
const request = {
|
||||
displayName: 'AB', // minimum length
|
||||
description: 'D'.repeat(500), // maximum length
|
||||
scopes: ['*'], // global wildcard
|
||||
};
|
||||
|
||||
const result = updateRoleDtoSchema.safeParse(request);
|
||||
expect(result.success).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Null and undefined handling', () => {
|
||||
test.each([
|
||||
{
|
||||
name: 'null displayName',
|
||||
request: { displayName: null },
|
||||
expectedErrorPath: ['displayName'],
|
||||
},
|
||||
{
|
||||
name: 'null description',
|
||||
request: { description: null },
|
||||
expectedErrorPath: ['description'],
|
||||
},
|
||||
{
|
||||
name: 'null scopes',
|
||||
request: { scopes: null },
|
||||
expectedErrorPath: ['scopes'],
|
||||
},
|
||||
{
|
||||
name: 'undefined displayName',
|
||||
request: { displayName: undefined },
|
||||
},
|
||||
{
|
||||
name: 'undefined description',
|
||||
request: { description: undefined },
|
||||
},
|
||||
{
|
||||
name: 'undefined scopes',
|
||||
request: { scopes: undefined },
|
||||
},
|
||||
])('should handle $name correctly', ({ request, expectedErrorPath }) => {
|
||||
const result = updateRoleDtoSchema.safeParse(request);
|
||||
|
||||
if (expectedErrorPath) {
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error?.issues[0].path).toEqual(expectedErrorPath);
|
||||
} else {
|
||||
// undefined values should be valid (fields are optional)
|
||||
expect(result.success).toBe(true);
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
11
packages/@n8n/api-types/src/dto/roles/create-role.dto.ts
Normal file
11
packages/@n8n/api-types/src/dto/roles/create-role.dto.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
import { scopeSchema } from '@n8n/permissions';
|
||||
import { z } from 'zod';
|
||||
|
||||
export const createRoleDtoSchema = z.object({
|
||||
displayName: z.string().min(2).max(100),
|
||||
description: z.string().max(500).optional(),
|
||||
roleType: z.enum(['project']),
|
||||
scopes: z.array(scopeSchema),
|
||||
});
|
||||
|
||||
export type CreateRoleDto = z.infer<typeof createRoleDtoSchema>;
|
||||
10
packages/@n8n/api-types/src/dto/roles/update-role.dto.ts
Normal file
10
packages/@n8n/api-types/src/dto/roles/update-role.dto.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
import { scopeSchema } from '@n8n/permissions';
|
||||
import { z } from 'zod';
|
||||
|
||||
export const updateRoleDtoSchema = z.object({
|
||||
displayName: z.string().min(2).max(100).optional(),
|
||||
description: z.string().max(500).optional(),
|
||||
scopes: z.array(scopeSchema).optional(),
|
||||
});
|
||||
|
||||
export type UpdateRoleDto = z.infer<typeof updateRoleDtoSchema>;
|
||||
@@ -1,21 +1,24 @@
|
||||
import {
|
||||
GLOBAL_SCOPE_MAP,
|
||||
PROJECT_ADMIN_ROLE_SLUG,
|
||||
PROJECT_EDITOR_ROLE_SLUG,
|
||||
PROJECT_OWNER_ROLE_SLUG,
|
||||
PROJECT_SCOPE_MAP,
|
||||
PROJECT_VIEWER_ROLE_SLUG,
|
||||
type GlobalRole,
|
||||
type ProjectRole,
|
||||
ALL_ROLES,
|
||||
type GlobalRole,
|
||||
type Role as RoleDTO,
|
||||
} from '@n8n/permissions';
|
||||
|
||||
import type { Role } from 'entities';
|
||||
|
||||
export function buildInRoleToRoleObject(role: GlobalRole): Role {
|
||||
export function builtInRoleToRoleObject(
|
||||
role: RoleDTO,
|
||||
roleType: 'global' | 'project' | 'workflow' | 'credential',
|
||||
): Role {
|
||||
return {
|
||||
slug: role,
|
||||
displayName: role,
|
||||
scopes: GLOBAL_SCOPE_MAP[role].map((scope) => {
|
||||
slug: role.slug,
|
||||
displayName: role.displayName,
|
||||
scopes: role.scopes.map((scope) => {
|
||||
return {
|
||||
slug: scope,
|
||||
displayName: scope,
|
||||
@@ -23,36 +26,36 @@ export function buildInRoleToRoleObject(role: GlobalRole): Role {
|
||||
};
|
||||
}),
|
||||
systemRole: true,
|
||||
roleType: 'global',
|
||||
description: `Built-in global role with ${role} permissions.`,
|
||||
roleType,
|
||||
description: role.description,
|
||||
} as Role;
|
||||
}
|
||||
|
||||
export function buildInProjectRoleToRoleObject(role: ProjectRole): Role {
|
||||
return {
|
||||
slug: role,
|
||||
displayName: role,
|
||||
scopes: PROJECT_SCOPE_MAP[role].map((scope) => {
|
||||
return {
|
||||
slug: scope,
|
||||
displayName: scope,
|
||||
description: null,
|
||||
};
|
||||
}),
|
||||
systemRole: true,
|
||||
roleType: 'project',
|
||||
description: `Built-in project role with ${role} permissions.`,
|
||||
} as Role;
|
||||
function toRoleMap(allRoles: Role[]): Record<string, Role> {
|
||||
return allRoles.reduce(
|
||||
(acc, role) => {
|
||||
acc[role.slug] = role;
|
||||
return acc;
|
||||
},
|
||||
{} as Record<string, Role>,
|
||||
);
|
||||
}
|
||||
|
||||
export const GLOBAL_OWNER_ROLE = buildInRoleToRoleObject('global:owner');
|
||||
export const GLOBAL_ADMIN_ROLE = buildInRoleToRoleObject('global:admin');
|
||||
export const GLOBAL_MEMBER_ROLE = buildInRoleToRoleObject('global:member');
|
||||
export const ALL_BUILTIN_ROLES = toRoleMap([
|
||||
...ALL_ROLES.global.map((role) => builtInRoleToRoleObject(role, 'global')),
|
||||
...ALL_ROLES.project.map((role) => builtInRoleToRoleObject(role, 'project')),
|
||||
...ALL_ROLES.credential.map((role) => builtInRoleToRoleObject(role, 'credential')),
|
||||
...ALL_ROLES.workflow.map((role) => builtInRoleToRoleObject(role, 'workflow')),
|
||||
]);
|
||||
|
||||
export const PROJECT_OWNER_ROLE = buildInProjectRoleToRoleObject(PROJECT_OWNER_ROLE_SLUG);
|
||||
export const PROJECT_ADMIN_ROLE = buildInProjectRoleToRoleObject(PROJECT_ADMIN_ROLE_SLUG);
|
||||
export const PROJECT_EDITOR_ROLE = buildInProjectRoleToRoleObject(PROJECT_EDITOR_ROLE_SLUG);
|
||||
export const PROJECT_VIEWER_ROLE = buildInProjectRoleToRoleObject(PROJECT_VIEWER_ROLE_SLUG);
|
||||
export const GLOBAL_OWNER_ROLE = ALL_BUILTIN_ROLES['global:owner'];
|
||||
export const GLOBAL_ADMIN_ROLE = ALL_BUILTIN_ROLES['global:admin'];
|
||||
export const GLOBAL_MEMBER_ROLE = ALL_BUILTIN_ROLES['global:member'];
|
||||
|
||||
export const PROJECT_OWNER_ROLE = ALL_BUILTIN_ROLES[PROJECT_OWNER_ROLE_SLUG];
|
||||
export const PROJECT_ADMIN_ROLE = ALL_BUILTIN_ROLES[PROJECT_ADMIN_ROLE_SLUG];
|
||||
export const PROJECT_EDITOR_ROLE = ALL_BUILTIN_ROLES[PROJECT_EDITOR_ROLE_SLUG];
|
||||
export const PROJECT_VIEWER_ROLE = ALL_BUILTIN_ROLES[PROJECT_VIEWER_ROLE_SLUG];
|
||||
|
||||
export const GLOBAL_ROLES: Record<GlobalRole, Role> = {
|
||||
'global:owner': GLOBAL_OWNER_ROLE,
|
||||
|
||||
@@ -1,11 +1,80 @@
|
||||
import { DatabaseConfig } from '@n8n/config';
|
||||
import { Service } from '@n8n/di';
|
||||
import { DataSource, Repository } from '@n8n/typeorm';
|
||||
import { DataSource, EntityManager, Repository } from '@n8n/typeorm';
|
||||
import { UserError } from 'n8n-workflow';
|
||||
|
||||
import { Role } from '../entities';
|
||||
|
||||
@Service()
|
||||
export class RoleRepository extends Repository<Role> {
|
||||
constructor(dataSource: DataSource) {
|
||||
constructor(
|
||||
dataSource: DataSource,
|
||||
private readonly databaseConfig: DatabaseConfig,
|
||||
) {
|
||||
super(Role, dataSource.manager);
|
||||
}
|
||||
|
||||
async findAll() {
|
||||
return await this.find({ relations: ['scopes'] });
|
||||
}
|
||||
|
||||
async findBySlug(slug: string) {
|
||||
return await this.findOne({
|
||||
where: { slug },
|
||||
relations: ['scopes'],
|
||||
});
|
||||
}
|
||||
|
||||
async removeBySlug(slug: string) {
|
||||
const result = await this.delete({ slug });
|
||||
if (result.affected !== 1) {
|
||||
throw new Error(`Failed to delete role "${slug}"`);
|
||||
}
|
||||
}
|
||||
|
||||
private async updateEntityWithManager(
|
||||
entityManager: EntityManager,
|
||||
slug: string,
|
||||
newData: Partial<Pick<Role, 'description' | 'scopes' | 'displayName'>>,
|
||||
) {
|
||||
const role = await entityManager.findOne(Role, {
|
||||
where: { slug },
|
||||
relations: ['scopes'],
|
||||
});
|
||||
if (!role) {
|
||||
throw new UserError('Role not found');
|
||||
}
|
||||
if (role.systemRole) {
|
||||
throw new UserError('Cannot update system roles');
|
||||
}
|
||||
|
||||
// Only update fields that are explicitly provided (not undefined)
|
||||
// This preserves existing scopes when scopes is undefined
|
||||
if (newData.displayName !== undefined) {
|
||||
role.displayName = newData.displayName;
|
||||
}
|
||||
|
||||
if (newData.description !== undefined) {
|
||||
role.description = newData.description;
|
||||
}
|
||||
|
||||
if (newData.scopes !== undefined) {
|
||||
role.scopes = newData.scopes;
|
||||
}
|
||||
|
||||
return await entityManager.save<Role>(role);
|
||||
}
|
||||
|
||||
async updateRole(
|
||||
slug: string,
|
||||
newData: Partial<Pick<Role, 'description' | 'scopes' | 'displayName'>>,
|
||||
) {
|
||||
// Do not use transactions for sqlite legacy
|
||||
if (this.databaseConfig.isLegacySqlite) {
|
||||
return await this.updateEntityWithManager(this.manager, slug, newData);
|
||||
}
|
||||
return await this.manager.transaction(async (transactionManager) => {
|
||||
return await this.updateEntityWithManager(transactionManager, slug, newData);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Service } from '@n8n/di';
|
||||
import { DataSource, Repository } from '@n8n/typeorm';
|
||||
import { DataSource, In, Repository } from '@n8n/typeorm';
|
||||
|
||||
import { Scope } from '../entities';
|
||||
|
||||
@@ -8,4 +8,8 @@ export class ScopeRepository extends Repository<Scope> {
|
||||
constructor(dataSource: DataSource) {
|
||||
super(Scope, dataSource.manager);
|
||||
}
|
||||
|
||||
async findByList(slugs: string[]) {
|
||||
return await this.findBy({ slug: In(slugs) });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -85,12 +85,12 @@ export class AuthRolesService {
|
||||
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);
|
||||
const existingRole = existingRolesMap.get(role.slug);
|
||||
|
||||
if (!existingRole) {
|
||||
const newRole = this.roleRepository.create({
|
||||
slug: role.role,
|
||||
displayName: role.name,
|
||||
slug: role.slug,
|
||||
displayName: role.displayName,
|
||||
description: role.description ?? null,
|
||||
roleType: roleNamespace,
|
||||
systemRole: true,
|
||||
@@ -100,14 +100,14 @@ export class AuthRolesService {
|
||||
}
|
||||
|
||||
const needsUpdate =
|
||||
existingRole.displayName !== role.name ||
|
||||
existingRole.displayName !== role.displayName ||
|
||||
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.displayName = role.displayName;
|
||||
existingRole.description = role.description ?? null;
|
||||
existingRole.roleType = roleNamespace;
|
||||
existingRole.scopes = allScopes.filter((scope) => role.scopes.includes(scope.slug));
|
||||
|
||||
@@ -131,6 +131,8 @@ exports[`Scope Information ensure scopes are defined correctly 1`] = `
|
||||
"workflowTags:update",
|
||||
"workflowTags:list",
|
||||
"workflowTags:*",
|
||||
"role:manage",
|
||||
"role:*",
|
||||
"*",
|
||||
]
|
||||
`;
|
||||
|
||||
@@ -29,6 +29,7 @@ export const RESOURCES = {
|
||||
dataStore: [...DEFAULT_OPERATIONS, 'readRow', 'writeRow', 'listProject'] as const,
|
||||
execution: ['delete', 'read', 'list', 'get'] as const,
|
||||
workflowTags: ['update', 'list'] as const,
|
||||
role: ['manage'] as const,
|
||||
} as const;
|
||||
|
||||
export const API_KEY_RESOURCES = {
|
||||
|
||||
@@ -6,7 +6,7 @@ export * from './scope-information';
|
||||
export * from './roles/role-maps.ee';
|
||||
export * from './roles/all-roles';
|
||||
|
||||
export { projectRoleSchema, teamRoleSchema } from './schemas.ee';
|
||||
export { projectRoleSchema, teamRoleSchema, roleSchema, Role, scopeSchema } from './schemas.ee';
|
||||
|
||||
export { hasScope } from './utilities/has-scope.ee';
|
||||
export { hasGlobalScope } from './utilities/has-global-scope.ee';
|
||||
|
||||
@@ -27,18 +27,27 @@ const ROLE_NAMES: Record<AllRoleTypes, string> = {
|
||||
'workflow:editor': 'Workflow Editor',
|
||||
};
|
||||
|
||||
const mapToRoleObject = <T extends keyof typeof ROLE_NAMES>(roles: Record<T, Scope[]>) =>
|
||||
const mapToRoleObject = <T extends keyof typeof ROLE_NAMES>(
|
||||
roles: Record<T, Scope[]>,
|
||||
roleType: 'global' | 'project' | 'credential' | 'workflow',
|
||||
) =>
|
||||
(Object.keys(roles) as T[]).map((role) => ({
|
||||
role,
|
||||
name: ROLE_NAMES[role],
|
||||
slug: role,
|
||||
displayName: ROLE_NAMES[role],
|
||||
scopes: getRoleScopes(role),
|
||||
description: ROLE_NAMES[role],
|
||||
licensed: false,
|
||||
systemRole: true,
|
||||
roleType,
|
||||
}));
|
||||
|
||||
export const ALL_ROLES: AllRolesMap = {
|
||||
global: mapToRoleObject(GLOBAL_SCOPE_MAP),
|
||||
project: mapToRoleObject(PROJECT_SCOPE_MAP),
|
||||
credential: mapToRoleObject(CREDENTIALS_SHARING_SCOPE_MAP),
|
||||
workflow: mapToRoleObject(WORKFLOW_SHARING_SCOPE_MAP),
|
||||
global: mapToRoleObject(GLOBAL_SCOPE_MAP, 'global'),
|
||||
project: mapToRoleObject(PROJECT_SCOPE_MAP, 'project'),
|
||||
credential: mapToRoleObject(CREDENTIALS_SHARING_SCOPE_MAP, 'credential'),
|
||||
workflow: mapToRoleObject(WORKFLOW_SHARING_SCOPE_MAP, 'workflow'),
|
||||
};
|
||||
|
||||
export const isBuiltInRole = (role: string): role is AllRoleTypes => {
|
||||
return Object.prototype.hasOwnProperty.call(ROLE_NAMES, role);
|
||||
};
|
||||
|
||||
@@ -80,6 +80,7 @@ export const GLOBAL_OWNER_SCOPES: Scope[] = [
|
||||
'folder:move',
|
||||
'oidc:manage',
|
||||
'dataStore:list',
|
||||
'role:manage',
|
||||
];
|
||||
|
||||
export const GLOBAL_ADMIN_SCOPES = GLOBAL_OWNER_SCOPES.concat();
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
import { PROJECT_OWNER_ROLE_SLUG } from './constants.ee';
|
||||
import { ALL_SCOPES } from './scope-information';
|
||||
|
||||
export const roleNamespaceSchema = z.enum(['global', 'project', 'credential', 'workflow']);
|
||||
|
||||
@@ -25,3 +26,21 @@ export const projectRoleSchema = z.union([personalRoleSchema, teamRoleSchema]);
|
||||
export const credentialSharingRoleSchema = z.enum(['credential:owner', 'credential:user']);
|
||||
|
||||
export const workflowSharingRoleSchema = z.enum(['workflow:owner', 'workflow:editor']);
|
||||
|
||||
const ALL_SCOPES_LOOKUP_SET = new Set(ALL_SCOPES as string[]);
|
||||
|
||||
export const scopeSchema = z.string().refine((val) => ALL_SCOPES_LOOKUP_SET.has(val), {
|
||||
message: 'Invalid scope',
|
||||
});
|
||||
|
||||
export const roleSchema = z.object({
|
||||
slug: z.string().min(1),
|
||||
displayName: z.string().min(1),
|
||||
description: z.string().nullable(),
|
||||
systemRole: z.boolean(),
|
||||
roleType: roleNamespaceSchema,
|
||||
licensed: z.boolean(),
|
||||
scopes: z.array(scopeSchema),
|
||||
});
|
||||
|
||||
export type Role = z.infer<typeof roleSchema>;
|
||||
|
||||
@@ -6,6 +6,7 @@ import type {
|
||||
credentialSharingRoleSchema,
|
||||
globalRoleSchema,
|
||||
projectRoleSchema,
|
||||
Role,
|
||||
roleNamespaceSchema,
|
||||
teamRoleSchema,
|
||||
workflowSharingRoleSchema,
|
||||
@@ -63,19 +64,11 @@ export type CustomRole = string;
|
||||
/** Union of all possible role types in the system */
|
||||
export type AllRoleTypes = GlobalRole | ProjectRole | WorkflowSharingRole | CredentialSharingRole;
|
||||
|
||||
type RoleObject<T extends AllRoleTypes> = {
|
||||
role: T;
|
||||
name: string;
|
||||
description?: string | null;
|
||||
scopes: Scope[];
|
||||
licensed: boolean;
|
||||
};
|
||||
|
||||
export type AllRolesMap = {
|
||||
global: Array<RoleObject<GlobalRole>>;
|
||||
project: Array<RoleObject<ProjectRole>>;
|
||||
credential: Array<RoleObject<CredentialSharingRole>>;
|
||||
workflow: Array<RoleObject<WorkflowSharingRole>>;
|
||||
global: Role[];
|
||||
project: Role[];
|
||||
credential: Role[];
|
||||
workflow: Role[];
|
||||
};
|
||||
|
||||
export type DbScope = {
|
||||
|
||||
@@ -34,6 +34,7 @@ describe('permissions', () => {
|
||||
folder: {},
|
||||
insights: {},
|
||||
dataStore: {},
|
||||
role: {},
|
||||
});
|
||||
});
|
||||
it('getResourcePermissions', () => {
|
||||
@@ -134,6 +135,7 @@ describe('permissions', () => {
|
||||
dataStore: {},
|
||||
execution: {},
|
||||
workflowTags: {},
|
||||
role: {},
|
||||
};
|
||||
|
||||
expect(getResourcePermissions(scopes)).toEqual(permissionRecord);
|
||||
|
||||
Reference in New Issue
Block a user