chore(core): Use dynamic role resolution for access control (#19400)

This commit is contained in:
Andreas Fitzek
2025-09-17 11:15:31 +02:00
committed by GitHub
parent 8086a21eb2
commit 33a2d5de17
21 changed files with 1581 additions and 201 deletions

View File

@@ -1,5 +1,5 @@
import { Service } from '@n8n/di';
import type { CredentialSharingRole, ProjectRole } from '@n8n/permissions';
import type { CredentialSharingRole } from '@n8n/permissions';
import type { EntityManager, FindOptionsWhere } from '@n8n/typeorm';
import { DataSource, In, Not, Repository } from '@n8n/typeorm';
@@ -108,8 +108,8 @@ export class SharedCredentialsRepository extends Repository<SharedCredentials> {
async findCredentialsByRoles(
userIds: string[],
projectRoles: ProjectRole[],
credentialRoles: CredentialSharingRole[],
projectRoles: string[],
credentialRoles: string[],
trx?: EntityManager,
) {
trx = trx ?? this.manager;

View File

@@ -20,7 +20,7 @@ export {
export { hasScope } from './utilities/has-scope.ee';
export { hasGlobalScope } from './utilities/has-global-scope.ee';
export { combineScopes } from './utilities/combine-scopes.ee';
export { rolesWithScope } from './utilities/roles-with-scope.ee';
export { staticRolesWithScope } from './utilities/static-roles-with-scope.ee';
export { getGlobalScopes } from './utilities/get-global-scopes.ee';
export { getRoleScopes, getAuthPrincipalScopes } from './utilities/get-role-scopes.ee';
export { getResourcePermissions } from './utilities/get-resource-permissions.ee';

View File

@@ -1,5 +1,5 @@
import type { GlobalRole, Scope } from '../../types.ee';
import { rolesWithScope } from '../roles-with-scope.ee';
import { staticRolesWithScope } from '../static-roles-with-scope.ee';
describe('rolesWithScope', () => {
describe('global roles', () => {
@@ -8,14 +8,14 @@ describe('rolesWithScope', () => {
['user:list', ['global:owner', 'global:admin', 'global:member']],
['invalid:scope', []],
] as Array<[Scope, GlobalRole[]]>)('%s -> %s', (scope, expected) => {
expect(rolesWithScope('global', scope)).toEqual(expected);
expect(staticRolesWithScope('global', scope)).toEqual(expected);
});
});
describe('multiple scopes', () => {
test('returns roles with all scopes', () => {
expect(
rolesWithScope('global', [
staticRolesWithScope('global', [
// all global roles have this scope
'tag:create',
// only owner and admin have this scope

View File

@@ -1,37 +0,0 @@
import { ALL_ROLE_MAPS } from '../roles/role-maps.ee';
import type {
CredentialSharingRole,
GlobalRole,
ProjectRole,
RoleNamespace,
Scope,
WorkflowSharingRole,
} from '../types.ee';
/**
* Retrieves roles within a specific namespace that have all the given scopes.
* @param namespace - The role namespace to search in
* @param scopes - Scope(s) to filter by
*/
export function rolesWithScope(namespace: 'global', scopes: Scope | Scope[]): GlobalRole[];
export function rolesWithScope(namespace: 'project', scopes: Scope | Scope[]): ProjectRole[];
export function rolesWithScope(
namespace: 'credential',
scopes: Scope | Scope[],
): CredentialSharingRole[];
export function rolesWithScope(
namespace: 'workflow',
scopes: Scope | Scope[],
): WorkflowSharingRole[];
export function rolesWithScope(namespace: RoleNamespace, scopes: Scope | Scope[]) {
if (!Array.isArray(scopes)) {
scopes = [scopes];
}
return Object.keys(ALL_ROLE_MAPS[namespace]).filter((k) => {
return scopes.every((s) =>
// eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-member-access
((ALL_ROLE_MAPS[namespace] as any)[k] as Scope[]).includes(s),
);
});
}

View File

@@ -0,0 +1,24 @@
import { ALL_ROLE_MAPS } from '../roles/role-maps.ee';
import type { RoleNamespace, Scope } from '../types.ee';
/**
* Retrieves roles within a specific namespace that have all the given scopes.
*
* This is only valid for static roles defined in ALL_ROLE_MAPS, with custom roles
* being handled in the RoleService.
*
* @param namespace - The role namespace to search in
* @param scopes - Scope(s) to filter by
*/
export function staticRolesWithScope(namespace: RoleNamespace, scopes: Scope | Scope[]) {
if (!Array.isArray(scopes)) {
scopes = [scopes];
}
return Object.keys(ALL_ROLE_MAPS[namespace]).filter((k) => {
return scopes.every((s) =>
// eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-member-access
((ALL_ROLE_MAPS[namespace] as any)[k] as Scope[]).includes(s),
);
});
}

View File

@@ -1,18 +1,21 @@
import type { CredentialsEntity, SharedCredentials, User } from '@n8n/db';
import { CredentialsRepository, SharedCredentialsRepository } from '@n8n/db';
import { Service } from '@n8n/di';
import { hasGlobalScope, rolesWithScope } from '@n8n/permissions';
import { hasGlobalScope } from '@n8n/permissions';
import type { CredentialSharingRole, ProjectRole, Scope } from '@n8n/permissions';
// eslint-disable-next-line n8n-local-rules/misplaced-n8n-typeorm-import
import type { EntityManager, FindOptionsWhere } from '@n8n/typeorm';
// eslint-disable-next-line n8n-local-rules/misplaced-n8n-typeorm-import
import { In } from '@n8n/typeorm';
import { RoleService } from '@/services/role.service';
@Service()
export class CredentialsFinderService {
constructor(
private readonly sharedCredentialsRepository: SharedCredentialsRepository,
private readonly credentialsRepository: CredentialsRepository,
private readonly roleService: RoleService,
) {}
/**
@@ -26,8 +29,10 @@ export class CredentialsFinderService {
let where: FindOptionsWhere<CredentialsEntity> = {};
if (!hasGlobalScope(user, scopes, { mode: 'allOf' })) {
const projectRoles = rolesWithScope('project', scopes);
const credentialRoles = rolesWithScope('credential', scopes);
const [projectRoles, credentialRoles] = await Promise.all([
this.roleService.rolesWithScope('project', scopes),
this.roleService.rolesWithScope('credential', scopes),
]);
where = {
...where,
shared: {
@@ -50,8 +55,10 @@ export class CredentialsFinderService {
let where: FindOptionsWhere<SharedCredentials> = { credentialsId };
if (!hasGlobalScope(user, scopes, { mode: 'allOf' })) {
const projectRoles = rolesWithScope('project', scopes);
const credentialRoles = rolesWithScope('credential', scopes);
const [projectRoles, credentialRoles] = await Promise.all([
this.roleService.rolesWithScope('project', scopes),
this.roleService.rolesWithScope('credential', scopes),
]);
where = {
...where,
role: In(credentialRoles),
@@ -82,8 +89,10 @@ export class CredentialsFinderService {
let where: FindOptionsWhere<SharedCredentials> = {};
if (!hasGlobalScope(user, scopes, { mode: 'allOf' })) {
const projectRoles = rolesWithScope('project', scopes);
const credentialRoles = rolesWithScope('credential', scopes);
const [projectRoles, credentialRoles] = await Promise.all([
this.roleService.rolesWithScope('project', scopes),
this.roleService.rolesWithScope('credential', scopes),
]);
where = {
role: In(credentialRoles),
project: {
@@ -111,9 +120,13 @@ export class CredentialsFinderService {
trx?: EntityManager,
) {
const projectRoles =
'scopes' in options ? rolesWithScope('project', options.scopes) : options.projectRoles;
'scopes' in options
? await this.roleService.rolesWithScope('project', options.scopes)
: options.projectRoles;
const credentialRoles =
'scopes' in options ? rolesWithScope('credential', options.scopes) : options.credentialRoles;
'scopes' in options
? await this.roleService.rolesWithScope('credential', options.scopes)
: options.credentialRoles;
const sharings = await this.sharedCredentialsRepository.findCredentialsByRoles(
userIds,

View File

@@ -1,7 +1,7 @@
import type { CredentialsEntity, User } from '@n8n/db';
import { Project, SharedCredentials, SharedCredentialsRepository } from '@n8n/db';
import { Service } from '@n8n/di';
import { hasGlobalScope, rolesWithScope } from '@n8n/permissions';
import { hasGlobalScope } from '@n8n/permissions';
// eslint-disable-next-line n8n-local-rules/misplaced-n8n-typeorm-import
import { In, type EntityManager } from '@n8n/typeorm';
import type { ICredentialDataDecryptedObject } from 'n8n-workflow';
@@ -10,6 +10,7 @@ import { NotFoundError } from '@/errors/response-errors/not-found.error';
import { TransferCredentialError } from '@/errors/response-errors/transfer-credential.error';
import { OwnershipService } from '@/services/ownership.service';
import { ProjectService } from '@/services/project.service.ee';
import { RoleService } from '@/services/role.service';
import { CredentialsFinderService } from './credentials-finder.service';
import { CredentialsService } from './credentials.service';
@@ -22,6 +23,7 @@ export class EnterpriseCredentialsService {
private readonly credentialsService: CredentialsService,
private readonly projectService: ProjectService,
private readonly credentialsFinderService: CredentialsFinderService,
private readonly roleService: RoleService,
) {}
async shareWithProjects(
@@ -31,6 +33,7 @@ export class EnterpriseCredentialsService {
entityManager?: EntityManager,
) {
const em = entityManager ?? this.sharedCredentialsRepository.manager;
const roles = await this.roleService.rolesWithScope('project', ['project:list']);
let projects = await em.find(Project, {
where: [
@@ -44,7 +47,7 @@ export class EnterpriseCredentialsService {
: {
projectRelations: {
userId: user.id,
role: In(rolesWithScope('project', 'project:list')),
role: In(roles),
},
}),
},

View File

@@ -409,10 +409,6 @@ export class CredentialsService {
const { manager: dbManager } = this.credentialsRepository;
const result = await dbManager.transaction(async (transactionManager) => {
const savedCredential = await transactionManager.save<CredentialsEntity>(newCredential);
savedCredential.data = newCredential.data;
const project =
projectId === undefined
? await this.projectRepository.getPersonalProjectForUserOrFail(
@@ -437,6 +433,10 @@ export class CredentialsService {
throw new UnexpectedError('No personal project found');
}
const savedCredential = await transactionManager.save<CredentialsEntity>(newCredential);
savedCredential.data = newCredential.data;
const newSharedCredential = this.sharedCredentialsRepository.create({
role: 'credential:owner',
credentials: savedCredential,

View File

@@ -6,18 +6,25 @@ import {
type User,
} from '@n8n/db';
import { Container } from '@n8n/di';
import type { Scope } from '@n8n/permissions';
import { type Scope } from '@n8n/permissions';
import { mock } from 'jest-mock-extended';
import { NotFoundError } from '@/errors/response-errors/not-found.error';
import { RoleService } from '@/services/role.service';
import { userHasScopes } from '../check-access';
describe('userHasScopes', () => {
const findByWorkflowMock = jest.fn();
const findByCredentialMock = jest.fn();
let findByWorkflowMock: jest.Mock;
let findByCredentialMock: jest.Mock;
let roleServiceMock: jest.Mock;
let mockQueryBuilder: any;
beforeAll(() => {
findByWorkflowMock = jest.fn();
findByCredentialMock = jest.fn();
roleServiceMock = jest.fn();
Container.set(
SharedWorkflowRepository,
mock<SharedWorkflowRepository>({
@@ -32,7 +39,7 @@ describe('userHasScopes', () => {
}),
);
const mockQueryBuilder = {
mockQueryBuilder = {
innerJoin: jest.fn().mockReturnThis(),
where: jest.fn().mockReturnThis(),
andWhere: jest.fn().mockReturnThis(),
@@ -48,117 +55,358 @@ describe('userHasScopes', () => {
createQueryBuilder: jest.fn().mockReturnValue(mockQueryBuilder),
}),
);
Container.set(
RoleService,
mock<RoleService>({
rolesWithScope: roleServiceMock,
}),
);
});
beforeEach(() => {
jest.clearAllMocks();
findByWorkflowMock.mockReset();
findByCredentialMock.mockReset();
roleServiceMock.mockReset();
// Default mock responses
mockQueryBuilder.getRawMany.mockResolvedValue([{ id: 'projectId' }]);
});
it.each<{ type: 'workflow' | 'credential'; id: string }>([
{
type: 'workflow',
id: 'workflowId',
},
{
type: 'credential',
id: 'credentialId',
},
])('should return 404 if the resource is not found', async ({ type, id }) => {
findByWorkflowMock.mockResolvedValueOnce([]);
findByCredentialMock.mockResolvedValueOnce([]);
describe('resource not found scenarios', () => {
it.each<{ type: 'workflow' | 'credential'; id: string }>([
{ type: 'workflow', id: 'workflowId' },
{ type: 'credential', id: 'credentialId' },
])('should throw NotFoundError if $type is not found', async ({ type, id }) => {
findByWorkflowMock.mockResolvedValue([]);
findByCredentialMock.mockResolvedValue([]);
const user = { id: 'userId', scopes: [], role: GLOBAL_MEMBER_ROLE } as unknown as User;
const scopes = ['workflow:read', 'credential:read'] as Scope[];
const user = { id: 'userId', scopes: [], role: GLOBAL_MEMBER_ROLE } as unknown as User;
const scopes = ['workflow:read', 'credential:read'] as Scope[];
const params: { credentialId?: string; workflowId?: string; projectId?: string } = {
projectId: 'projectId',
};
if (type === 'credential') {
params.credentialId = id;
} else {
params.workflowId = id;
}
await expect(userHasScopes(user, scopes, false, params)).rejects.toThrow(NotFoundError);
});
test.each<{
type: 'workflow' | 'credential';
id: string;
role: string;
scope: Scope;
userScopes: Scope[];
expected: boolean;
}>([
{
type: 'workflow',
id: 'workflowId',
role: 'workflow:member',
scope: 'workflow:delete',
userScopes: [],
expected: false,
},
{
type: 'credential',
id: 'credentialId',
role: 'credential:member',
scope: 'credential:delete',
userScopes: [],
expected: false,
},
{
type: 'workflow',
id: 'workflowId',
role: 'workflow:editor',
scope: 'workflow:read',
userScopes: ['workflow:read'],
expected: true,
},
{
type: 'credential',
id: 'credentialId',
role: 'credential:user',
scope: 'credential:read',
userScopes: ['credential:read'],
expected: true,
},
])(
'should return $expected if the user has the required scopes for a $type',
async ({ type, id, role, scope, userScopes, expected }) => {
if (type === 'workflow') {
findByWorkflowMock.mockResolvedValueOnce([
{
workflowId: id,
projectId: 'projectId',
role,
},
]);
} else {
findByCredentialMock.mockResolvedValueOnce([
{
credentialId: id,
projectId: 'projectId',
role,
},
]);
}
const user = {
id: 'userId',
scopes: userScopes,
role: GLOBAL_MEMBER_ROLE,
} as unknown as User;
const scopes = [scope] as Scope[];
const params: { credentialId?: string; workflowId?: string; projectId?: string } = {
projectId: 'projectId',
};
const params: { credentialId?: string; workflowId?: string; projectId?: string } = {};
if (type === 'credential') {
params.credentialId = id;
} else {
params.workflowId = id;
}
const result = await userHasScopes(user, scopes, false, params);
expect(result).toBe(expected);
},
);
await expect(userHasScopes(user, scopes, false, params)).rejects.toThrow(NotFoundError);
});
});
describe('RoleService integration', () => {
it('should use RoleService for credential role resolution', async () => {
const credentialId = 'cred123';
const mockRoles = ['credential:owner', 'custom:credential-admin'];
roleServiceMock.mockResolvedValue(mockRoles);
findByCredentialMock.mockResolvedValue([
{
credentialsId: credentialId,
projectId: 'projectId',
role: 'credential:owner',
},
]);
const user = { id: 'userId', scopes: [], role: GLOBAL_MEMBER_ROLE } as unknown as User;
const scopes = ['credential:read'] as Scope[];
const result = await userHasScopes(user, scopes, false, { credentialId });
expect(roleServiceMock).toHaveBeenCalledWith('credential', scopes);
expect(result).toBe(true);
});
it('should use RoleService for workflow role resolution', async () => {
const workflowId = 'wf123';
const mockRoles = ['workflow:owner', 'custom:workflow-manager'];
roleServiceMock.mockResolvedValue(mockRoles);
findByWorkflowMock.mockResolvedValue([
{
workflowId,
projectId: 'projectId',
role: 'workflow:owner',
},
]);
const user = { id: 'userId', scopes: [], role: GLOBAL_MEMBER_ROLE } as unknown as User;
const scopes = ['workflow:read'] as Scope[];
const result = await userHasScopes(user, scopes, false, { workflowId });
expect(roleServiceMock).toHaveBeenCalledWith('workflow', scopes);
expect(result).toBe(true);
});
it('should handle custom database roles in access control', async () => {
const credentialId = 'cred123';
const customRoles = ['custom:admin-role-abc123', 'credential:owner'];
roleServiceMock.mockResolvedValue(customRoles);
findByCredentialMock.mockResolvedValue([
{
credentialsId: credentialId,
projectId: 'projectId',
role: 'custom:admin-role-abc123', // Custom role from database
},
]);
const user = { id: 'userId', scopes: [], role: GLOBAL_MEMBER_ROLE } as unknown as User;
const scopes = ['credential:update'] as Scope[];
const result = await userHasScopes(user, scopes, false, { credentialId });
expect(roleServiceMock).toHaveBeenCalledWith('credential', scopes);
expect(result).toBe(true);
});
it('should propagate RoleService errors appropriately', async () => {
const credentialId = 'cred123';
const roleServiceError = new Error('Role cache unavailable');
roleServiceMock.mockRejectedValue(roleServiceError);
findByCredentialMock.mockResolvedValue([
{
credentialsId: credentialId,
projectId: 'projectId',
role: 'credential:owner',
},
]);
const user = { id: 'userId', scopes: [], role: GLOBAL_MEMBER_ROLE } as unknown as User;
const scopes = ['credential:read'] as Scope[];
await expect(userHasScopes(user, scopes, false, { credentialId })).rejects.toThrow(
roleServiceError,
);
});
});
describe('namespace isolation', () => {
it('should use correct namespace for credential checks', async () => {
const credentialId = 'cred123';
roleServiceMock.mockResolvedValue(['credential:owner']);
findByCredentialMock.mockResolvedValue([
{
credentialsId: credentialId,
projectId: 'projectId',
role: 'credential:owner',
},
]);
const user = { id: 'userId', scopes: [], role: GLOBAL_MEMBER_ROLE } as unknown as User;
await userHasScopes(user, ['credential:read'], false, { credentialId });
expect(roleServiceMock).toHaveBeenCalledWith('credential', ['credential:read']);
expect(roleServiceMock).not.toHaveBeenCalledWith('workflow', expect.anything());
});
it('should use correct namespace for workflow checks', async () => {
const workflowId = 'wf123';
roleServiceMock.mockResolvedValue(['workflow:owner']);
findByWorkflowMock.mockResolvedValue([
{
workflowId,
projectId: 'projectId',
role: 'workflow:owner',
},
]);
const user = { id: 'userId', scopes: [], role: GLOBAL_MEMBER_ROLE } as unknown as User;
await userHasScopes(user, ['workflow:execute'], false, { workflowId });
expect(roleServiceMock).toHaveBeenCalledWith('workflow', ['workflow:execute']);
expect(roleServiceMock).not.toHaveBeenCalledWith('credential', expect.anything());
});
it('should not allow workflow roles to access credentials', async () => {
const credentialId = 'cred123';
// RoleService returns no matching credential roles
roleServiceMock.mockResolvedValue([]);
findByCredentialMock.mockResolvedValue([
{
credentialsId: credentialId,
projectId: 'projectId',
role: 'workflow:owner', // Wrong namespace role
},
]);
const user = { id: 'userId', scopes: [], role: GLOBAL_MEMBER_ROLE } as unknown as User;
const scopes = ['credential:read'] as Scope[];
const result = await userHasScopes(user, scopes, false, { credentialId });
expect(result).toBe(false);
});
});
describe('access control scenarios', () => {
it('should grant access when user has matching project and resource role', async () => {
const credentialId = 'cred123';
roleServiceMock.mockResolvedValue(['credential:owner', 'credential:user']);
findByCredentialMock.mockResolvedValue([
{
credentialsId: credentialId,
projectId: 'projectId',
role: 'credential:user',
},
]);
const user = { id: 'userId', scopes: [], role: GLOBAL_MEMBER_ROLE } as unknown as User;
const scopes = ['credential:read'] as Scope[];
const result = await userHasScopes(user, scopes, false, { credentialId });
expect(result).toBe(true);
});
it('should deny access when user has project access but insufficient resource role', async () => {
const credentialId = 'cred123';
roleServiceMock.mockResolvedValue(['credential:owner']); // Only owner role has required scopes
findByCredentialMock.mockResolvedValue([
{
credentialsId: credentialId,
projectId: 'projectId',
role: 'credential:viewer', // User has viewer role, but needs owner
},
]);
const user = { id: 'userId', scopes: [], role: GLOBAL_MEMBER_ROLE } as unknown as User;
const scopes = ['credential:delete'] as Scope[];
const result = await userHasScopes(user, scopes, false, { credentialId });
expect(result).toBe(false);
});
it('should deny access when user has no project access', async () => {
const credentialId = 'cred123';
mockQueryBuilder.getRawMany.mockResolvedValue([]); // No project access
roleServiceMock.mockResolvedValue(['credential:owner']);
findByCredentialMock.mockResolvedValue([
{
credentialsId: credentialId,
projectId: 'otherProjectId',
role: 'credential:owner',
},
]);
const user = { id: 'userId', scopes: [], role: GLOBAL_MEMBER_ROLE } as unknown as User;
const scopes = ['credential:read'] as Scope[];
const result = await userHasScopes(user, scopes, false, { credentialId });
expect(result).toBe(false);
});
it('should handle multiple scope requirements', async () => {
const workflowId = 'wf123';
roleServiceMock.mockResolvedValue(['workflow:owner']); // Owner role has multiple scopes
findByWorkflowMock.mockResolvedValue([
{
workflowId,
projectId: 'projectId',
role: 'workflow:owner',
},
]);
const user = { id: 'userId', scopes: [], role: GLOBAL_MEMBER_ROLE } as unknown as User;
const scopes = ['workflow:read', 'workflow:execute'] as Scope[];
const result = await userHasScopes(user, scopes, false, { workflowId });
expect(roleServiceMock).toHaveBeenCalledWith('workflow', scopes);
expect(result).toBe(true);
});
});
describe('edge cases', () => {
it('should handle empty role results from RoleService', async () => {
const credentialId = 'cred123';
roleServiceMock.mockResolvedValue([]); // No roles match the scopes
findByCredentialMock.mockResolvedValue([
{
credentialsId: credentialId,
projectId: 'projectId',
role: 'credential:owner',
},
]);
const user = { id: 'userId', scopes: [], role: GLOBAL_MEMBER_ROLE } as unknown as User;
const scopes = ['*' as const] as Scope[]; // Use wildcard scope for testing
const result = await userHasScopes(user, scopes, false, { credentialId });
expect(result).toBe(false);
});
it('should handle multiple resource shares in same project', async () => {
const workflowId = 'wf123';
roleServiceMock.mockResolvedValue(['workflow:editor']);
findByWorkflowMock.mockResolvedValue([
{
workflowId,
projectId: 'projectId',
role: 'workflow:viewer', // First share - insufficient
},
{
workflowId,
projectId: 'projectId',
role: 'workflow:editor', // Second share - sufficient
},
]);
const user = { id: 'userId', scopes: [], role: GLOBAL_MEMBER_ROLE } as unknown as User;
const scopes = ['workflow:update'] as Scope[];
const result = await userHasScopes(user, scopes, false, { workflowId });
expect(result).toBe(true);
});
it('should handle project-only checks without RoleService', async () => {
const projectId = 'proj123';
// Project checks don't use RoleService
mockQueryBuilder.getRawMany.mockResolvedValue([{ id: projectId }]);
const user = { id: 'userId', scopes: [], role: GLOBAL_MEMBER_ROLE } as unknown as User;
const scopes = ['project:read'] as Scope[];
const result = await userHasScopes(user, scopes, false, { projectId });
expect(roleServiceMock).not.toHaveBeenCalled();
expect(result).toBe(true);
});
it('should handle concurrent permission checks', async () => {
const credentialId1 = 'cred1';
const credentialId2 = 'cred2';
roleServiceMock.mockResolvedValue(['credential:owner']);
findByCredentialMock
.mockResolvedValueOnce([
{ credentialsId: credentialId1, projectId: 'projectId', role: 'credential:owner' },
])
.mockResolvedValueOnce([
{ credentialsId: credentialId2, projectId: 'projectId', role: 'credential:viewer' },
]);
const user = { id: 'userId', scopes: [], role: GLOBAL_MEMBER_ROLE } as unknown as User;
const scopes = ['credential:read'] as Scope[];
const [result1, result2] = await Promise.all([
userHasScopes(user, scopes, false, { credentialId: credentialId1 }),
userHasScopes(user, scopes, false, { credentialId: credentialId2 }),
]);
expect(result1).toBe(true);
expect(result2).toBe(false);
expect(roleServiceMock).toHaveBeenCalledTimes(2);
});
});
});

View File

@@ -1,10 +1,11 @@
import type { User } from '@n8n/db';
import { ProjectRepository, SharedCredentialsRepository, SharedWorkflowRepository } from '@n8n/db';
import { Container } from '@n8n/di';
import { hasGlobalScope, rolesWithScope, type Scope } from '@n8n/permissions';
import { hasGlobalScope, type Scope } from '@n8n/permissions';
import { UnexpectedError } from 'n8n-workflow';
import { NotFoundError } from '@/errors/response-errors/not-found.error';
import { RoleService } from '@/services/role.service';
/**
* Check if a user has the required scopes. The check can be:
@@ -47,6 +48,7 @@ export async function userHasScopes(
// Find which resource roles are defined to contain the required scopes.
// Then find at least one of the above qualifying projects having one of
// those resource roles over the resource being checked.
const roleService = Container.get(RoleService);
if (credentialId) {
const credentials = await Container.get(SharedCredentialsRepository).findBy({
@@ -56,10 +58,10 @@ export async function userHasScopes(
throw new NotFoundError(`Credential with ID "${credentialId}" not found.`);
}
const validRoles = await roleService.rolesWithScope('credential', scopes);
return credentials.some(
(c) =>
userProjectIds.includes(c.projectId) &&
rolesWithScope('credential', scopes).includes(c.role),
(c) => userProjectIds.includes(c.projectId) && validRoles.includes(c.role),
);
}
@@ -72,9 +74,10 @@ export async function userHasScopes(
throw new NotFoundError(`Workflow with ID "${workflowId}" not found.`);
}
const validRoles = await roleService.rolesWithScope('workflow', scopes);
return workflows.some(
(w) =>
userProjectIds.includes(w.projectId) && rolesWithScope('workflow', scopes).includes(w.role),
(w) => userProjectIds.includes(w.projectId) && validRoles.includes(w.role),
);
}

View File

@@ -1,4 +1,10 @@
import { GLOBAL_MEMBER_ROLE, GLOBAL_OWNER_ROLE, SharedCredentials } from '@n8n/db';
import {
GLOBAL_MEMBER_ROLE,
GLOBAL_OWNER_ROLE,
type SharedCredentials,
CredentialsRepository,
SharedCredentialsRepository,
} from '@n8n/db';
import type { CredentialsEntity, User } from '@n8n/db';
import { Container } from '@n8n/di';
import {
@@ -8,15 +14,43 @@ import {
PROJECT_VIEWER_ROLE_SLUG,
} from '@n8n/permissions';
import { In } from '@n8n/typeorm';
import { mockEntityManager } from '@test/mocking';
import { mock } from 'jest-mock-extended';
import { mockInstance } from '@n8n/backend-test-utils';
import { CredentialsFinderService } from '@/credentials/credentials-finder.service';
import { RoleService } from '../role.service';
describe('CredentialsFinderService', () => {
const entityManager = mockEntityManager(SharedCredentials);
const roleService = mockInstance(RoleService);
const credentialsRepository = mockInstance(CredentialsRepository);
const sharedCredentialsRepository = mockInstance(SharedCredentialsRepository);
const credentialsFinderService = Container.get(CredentialsFinderService);
beforeAll(() => {
Container.set(RoleService, roleService);
Container.set(CredentialsRepository, credentialsRepository);
Container.set(SharedCredentialsRepository, sharedCredentialsRepository);
});
beforeEach(() => {
jest.clearAllMocks();
// Default mock implementation for all tests
roleService.rolesWithScope.mockImplementation(async (namespace) => {
if (namespace === 'project') {
return [
PROJECT_ADMIN_ROLE_SLUG,
PROJECT_OWNER_ROLE_SLUG,
PROJECT_EDITOR_ROLE_SLUG,
PROJECT_VIEWER_ROLE_SLUG,
];
} else if (namespace === 'credential') {
return ['credential:owner', 'credential:user'];
}
return [];
});
});
describe('findCredentialForUser', () => {
const credentialsId = 'cred_123';
const sharedCredential = mock<SharedCredentials>();
@@ -29,33 +63,38 @@ describe('CredentialsFinderService', () => {
id: 'test',
});
beforeEach(() => {
jest.resetAllMocks();
});
test('should allow instance owner access to all credentials', async () => {
entityManager.findOne.mockResolvedValueOnce(sharedCredential);
sharedCredentialsRepository.findOne.mockResolvedValueOnce(sharedCredential);
const credential = await credentialsFinderService.findCredentialForUser(
credentialsId,
owner,
['credential:read'],
['credential:read' as const],
);
expect(entityManager.findOne).toHaveBeenCalledWith(SharedCredentials, {
relations: { credentials: { shared: { project: { projectRelations: { user: true } } } } },
expect(sharedCredentialsRepository.findOne).toHaveBeenCalledWith({
where: { credentialsId },
relations: {
credentials: {
shared: { project: { projectRelations: { user: true } } },
},
},
});
expect(roleService.rolesWithScope).not.toHaveBeenCalled();
expect(credential).toEqual(sharedCredential.credentials);
});
test('should allow members', async () => {
entityManager.findOne.mockResolvedValueOnce(sharedCredential);
test('should allow members and call RoleService correctly', async () => {
sharedCredentialsRepository.findOne.mockResolvedValueOnce(sharedCredential);
const credential = await credentialsFinderService.findCredentialForUser(
credentialsId,
member,
['credential:read'],
['credential:read' as const],
);
expect(entityManager.findOne).toHaveBeenCalledWith(SharedCredentials, {
relations: { credentials: { shared: { project: { projectRelations: { user: true } } } } },
expect(roleService.rolesWithScope).toHaveBeenCalledTimes(2);
expect(roleService.rolesWithScope).toHaveBeenCalledWith('project', ['credential:read']);
expect(roleService.rolesWithScope).toHaveBeenCalledWith('credential', ['credential:read']);
expect(sharedCredentialsRepository.findOne).toHaveBeenCalledWith({
where: {
credentialsId,
role: In(['credential:owner', 'credential:user']),
@@ -71,19 +110,23 @@ describe('CredentialsFinderService', () => {
},
},
},
relations: {
credentials: {
shared: { project: { projectRelations: { user: true } } },
},
},
});
expect(credential).toEqual(sharedCredential.credentials);
});
test('should return null when no shared credential is found', async () => {
entityManager.findOne.mockResolvedValueOnce(null);
sharedCredentialsRepository.findOne.mockResolvedValueOnce(null);
const credential = await credentialsFinderService.findCredentialForUser(
credentialsId,
member,
['credential:read'],
['credential:read' as const],
);
expect(entityManager.findOne).toHaveBeenCalledWith(SharedCredentials, {
relations: { credentials: { shared: { project: { projectRelations: { user: true } } } } },
expect(sharedCredentialsRepository.findOne).toHaveBeenCalledWith({
where: {
credentialsId,
role: In(['credential:owner', 'credential:user']),
@@ -99,8 +142,446 @@ describe('CredentialsFinderService', () => {
},
},
},
relations: {
credentials: {
shared: { project: { projectRelations: { user: true } } },
},
},
});
expect(credential).toEqual(null);
});
test('should handle custom roles from RoleService', async () => {
roleService.rolesWithScope.mockImplementation(async (namespace) => {
if (namespace === 'project') {
return ['custom:project-admin-abc123', PROJECT_VIEWER_ROLE_SLUG];
} else if (namespace === 'credential') {
return ['custom:cred-manager-xyz789', 'credential:user'];
}
return [];
});
sharedCredentialsRepository.findOne.mockResolvedValueOnce(sharedCredential);
const credential = await credentialsFinderService.findCredentialForUser(
credentialsId,
member,
['credential:update' as const],
);
expect(sharedCredentialsRepository.findOne).toHaveBeenCalledWith({
where: {
credentialsId,
role: In(['custom:cred-manager-xyz789', 'credential:user']),
project: {
projectRelations: {
role: In(['custom:project-admin-abc123', PROJECT_VIEWER_ROLE_SLUG]),
userId: member.id,
},
},
},
relations: {
credentials: {
shared: { project: { projectRelations: { user: true } } },
},
},
});
expect(credential).toEqual(sharedCredential.credentials);
});
test('should handle RoleService failure gracefully', async () => {
roleService.rolesWithScope.mockRejectedValue(new Error('Role cache unavailable'));
await expect(
credentialsFinderService.findCredentialForUser(credentialsId, member, [
'credential:read' as const,
]),
).rejects.toThrow('Role cache unavailable');
expect(sharedCredentialsRepository.findOne).not.toHaveBeenCalled();
});
});
describe('findCredentialsForUser', () => {
const credentials = [
mock<CredentialsEntity>({ id: 'cred1', shared: [] }),
mock<CredentialsEntity>({ id: 'cred2', shared: [] }),
];
const owner = mock<User>({ role: GLOBAL_OWNER_ROLE });
const member = mock<User>({ role: GLOBAL_MEMBER_ROLE, id: 'user123' });
beforeEach(() => {
jest.clearAllMocks();
});
test('should allow global owner access to all credentials without role filtering', async () => {
credentialsRepository.find.mockResolvedValueOnce(credentials);
const result = await credentialsFinderService.findCredentialsForUser(owner, [
'credential:read' as const,
]);
expect(credentialsRepository.find).toHaveBeenCalledWith({
where: {},
relations: { shared: true },
});
expect(roleService.rolesWithScope).not.toHaveBeenCalled();
expect(result).toEqual(credentials);
});
test('should filter credentials by roles for regular members', async () => {
credentialsRepository.find.mockResolvedValueOnce(credentials);
const result = await credentialsFinderService.findCredentialsForUser(member, [
'credential:update' as const,
]);
expect(roleService.rolesWithScope).toHaveBeenCalledWith('project', ['credential:update']);
expect(roleService.rolesWithScope).toHaveBeenCalledWith('credential', ['credential:update']);
expect(credentialsRepository.find).toHaveBeenCalledWith({
where: {
shared: {
role: In(['credential:owner', 'credential:user']),
project: {
projectRelations: {
role: In([
PROJECT_ADMIN_ROLE_SLUG,
PROJECT_OWNER_ROLE_SLUG,
PROJECT_EDITOR_ROLE_SLUG,
PROJECT_VIEWER_ROLE_SLUG,
]),
userId: member.id,
},
},
},
},
relations: { shared: true },
});
expect(result).toEqual(credentials);
});
test('should handle custom roles in filtering', async () => {
roleService.rolesWithScope.mockImplementation(async (namespace) => {
if (namespace === 'project') return ['custom:project-lead-456'];
if (namespace === 'credential') return ['custom:cred-admin-789'];
return [];
});
const singleCredResult = [credentials[0]];
credentialsRepository.find.mockResolvedValueOnce(singleCredResult);
const result = await credentialsFinderService.findCredentialsForUser(member, [
'credential:delete' as const,
]);
expect(credentialsRepository.find).toHaveBeenCalledWith({
where: {
shared: {
role: In(['custom:cred-admin-789']),
project: {
projectRelations: {
role: In(['custom:project-lead-456']),
userId: member.id,
},
},
},
},
relations: { shared: true },
});
expect(result).toEqual(singleCredResult);
});
});
describe('findAllCredentialsForUser', () => {
const sharedCredentials = [
mock<SharedCredentials>({
credentials: mock<CredentialsEntity>({ id: 'cred1' }),
projectId: 'proj1',
credentialsId: 'cred1',
role: 'credential:owner',
}),
mock<SharedCredentials>({
credentials: mock<CredentialsEntity>({ id: 'cred2' }),
projectId: 'proj2',
credentialsId: 'cred2',
role: 'credential:user',
}),
];
const owner = mock<User>({ role: GLOBAL_OWNER_ROLE });
const member = mock<User>({ role: GLOBAL_MEMBER_ROLE, id: 'user123' });
beforeEach(() => {
jest.clearAllMocks();
// Reset to default implementation for each test
roleService.rolesWithScope.mockImplementation(async (namespace) => {
if (namespace === 'project') {
return [
PROJECT_ADMIN_ROLE_SLUG,
PROJECT_OWNER_ROLE_SLUG,
PROJECT_EDITOR_ROLE_SLUG,
PROJECT_VIEWER_ROLE_SLUG,
];
} else if (namespace === 'credential') {
return ['credential:owner', 'credential:user'];
}
return [];
});
});
test('should allow global owner access without filtering', async () => {
sharedCredentialsRepository.findCredentialsWithOptions.mockResolvedValueOnce(
sharedCredentials,
);
const result = await credentialsFinderService.findAllCredentialsForUser(owner, [
'credential:read' as const,
]);
expect(sharedCredentialsRepository.findCredentialsWithOptions).toHaveBeenCalledWith(
{},
undefined,
);
expect(roleService.rolesWithScope).not.toHaveBeenCalled();
expect(result).toEqual([
{ ...sharedCredentials[0].credentials, projectId: 'proj1' },
{ ...sharedCredentials[1].credentials, projectId: 'proj2' },
]);
});
test('should filter by roles for regular members', async () => {
sharedCredentialsRepository.findCredentialsWithOptions.mockResolvedValueOnce([
sharedCredentials[0],
]);
const result = await credentialsFinderService.findAllCredentialsForUser(member, [
'credential:read' as const,
]);
expect(roleService.rolesWithScope).toHaveBeenCalledWith('project', ['credential:read']);
expect(roleService.rolesWithScope).toHaveBeenCalledWith('credential', ['credential:read']);
expect(sharedCredentialsRepository.findCredentialsWithOptions).toHaveBeenCalledWith(
{
role: In(['credential:owner', 'credential:user']),
project: {
projectRelations: {
role: In([
PROJECT_ADMIN_ROLE_SLUG,
PROJECT_OWNER_ROLE_SLUG,
PROJECT_EDITOR_ROLE_SLUG,
PROJECT_VIEWER_ROLE_SLUG,
]),
userId: member.id,
},
},
},
undefined,
);
expect(result).toEqual([{ ...sharedCredentials[0].credentials, projectId: 'proj1' }]);
});
test('should support transaction manager', async () => {
const mockTrx = mock<any>();
sharedCredentialsRepository.findCredentialsWithOptions.mockResolvedValueOnce([]);
await credentialsFinderService.findAllCredentialsForUser(
member,
['credential:read' as const],
mockTrx,
);
expect(sharedCredentialsRepository.findCredentialsWithOptions).toHaveBeenCalledWith(
expect.any(Object),
mockTrx,
);
});
});
describe('getCredentialIdsByUserAndRole', () => {
const userIds = ['user1', 'user2'];
const mockSharings = [
mock<SharedCredentials>({ credentialsId: 'cred1' }),
mock<SharedCredentials>({ credentialsId: 'cred2' }),
];
beforeEach(() => {
jest.clearAllMocks();
// Reset to default implementation
roleService.rolesWithScope.mockImplementation(async (namespace) => {
if (namespace === 'project') {
return [
PROJECT_ADMIN_ROLE_SLUG,
PROJECT_OWNER_ROLE_SLUG,
PROJECT_EDITOR_ROLE_SLUG,
PROJECT_VIEWER_ROLE_SLUG,
];
} else if (namespace === 'credential') {
return ['credential:owner', 'credential:user'];
}
return [];
});
});
test('should use RoleService when scopes are provided', async () => {
sharedCredentialsRepository.findCredentialsByRoles.mockResolvedValueOnce(mockSharings);
const result = await credentialsFinderService.getCredentialIdsByUserAndRole(userIds, {
scopes: ['credential:read' as const, 'credential:update' as const],
});
expect(roleService.rolesWithScope).toHaveBeenCalledWith('project', [
'credential:read',
'credential:update',
]);
expect(roleService.rolesWithScope).toHaveBeenCalledWith('credential', [
'credential:read',
'credential:update',
]);
expect(sharedCredentialsRepository.findCredentialsByRoles).toHaveBeenCalledWith(
userIds,
[
PROJECT_ADMIN_ROLE_SLUG,
PROJECT_OWNER_ROLE_SLUG,
PROJECT_EDITOR_ROLE_SLUG,
PROJECT_VIEWER_ROLE_SLUG,
],
['credential:owner', 'credential:user'],
undefined,
);
expect(result).toEqual([mockSharings[0].credentialsId, mockSharings[1].credentialsId]);
});
test('should use direct roles when provided', async () => {
const projectRoles = ['custom:project-admin'] as any;
const credentialRoles = ['custom:cred-viewer'] as any;
sharedCredentialsRepository.findCredentialsByRoles.mockResolvedValueOnce([mockSharings[0]]);
const result = await credentialsFinderService.getCredentialIdsByUserAndRole(userIds, {
projectRoles,
credentialRoles,
});
expect(roleService.rolesWithScope).not.toHaveBeenCalled();
expect(sharedCredentialsRepository.findCredentialsByRoles).toHaveBeenCalledWith(
userIds,
projectRoles,
credentialRoles,
undefined,
);
expect(result).toEqual([mockSharings[0].credentialsId]);
});
test('should support transaction manager', async () => {
const mockTrx = mock<any>();
sharedCredentialsRepository.findCredentialsByRoles.mockResolvedValueOnce([]);
await credentialsFinderService.getCredentialIdsByUserAndRole(
userIds,
{ scopes: ['credential:read' as const] },
mockTrx,
);
expect(sharedCredentialsRepository.findCredentialsByRoles).toHaveBeenCalledWith(
expect.any(Array),
expect.any(Array),
expect.any(Array),
mockTrx,
);
});
test('should handle empty results', async () => {
sharedCredentialsRepository.findCredentialsByRoles.mockResolvedValueOnce([]);
const result = await credentialsFinderService.getCredentialIdsByUserAndRole(userIds, {
scopes: ['credential:read' as const],
});
expect(result).toEqual([]);
});
});
describe('RoleService integration edge cases', () => {
const member = mock<User>({ role: GLOBAL_MEMBER_ROLE, id: 'user123' });
beforeEach(() => {
jest.clearAllMocks();
});
test('should handle empty role results from RoleService', async () => {
roleService.rolesWithScope.mockResolvedValue([]);
const emptyResult: CredentialsEntity[] = [];
credentialsRepository.find.mockResolvedValueOnce(emptyResult);
const result = await credentialsFinderService.findCredentialsForUser(member, [
'credential:read' as const,
]);
expect(credentialsRepository.find).toHaveBeenCalledWith({
where: {
shared: {
role: In([]),
project: {
projectRelations: {
role: In([]),
userId: member.id,
},
},
},
},
relations: { shared: true },
});
expect(result).toEqual(emptyResult);
});
test('should handle RoleService failures in findCredentialsForUser', async () => {
roleService.rolesWithScope.mockRejectedValueOnce(new Error('Database connection failed'));
await expect(
credentialsFinderService.findCredentialsForUser(member, ['credential:read' as const]),
).rejects.toThrow('Database connection failed');
expect(credentialsRepository.find).not.toHaveBeenCalled();
});
test('should handle partial RoleService failures', async () => {
roleService.rolesWithScope
.mockResolvedValueOnce(['project:admin']) // First call succeeds
.mockRejectedValueOnce(new Error('Credential role lookup failed')); // Second call fails
await expect(
credentialsFinderService.findCredentialsForUser(member, ['credential:read' as const]),
).rejects.toThrow('Credential role lookup failed');
});
test('should maintain namespace isolation', async () => {
roleService.rolesWithScope.mockImplementation(async (namespace) => {
if (namespace === 'project') return ['workflow:owner']; // Wrong namespace
if (namespace === 'credential') return ['project:admin']; // Wrong namespace
return [];
});
const isolationResult: CredentialsEntity[] = [];
credentialsRepository.find.mockResolvedValueOnce(isolationResult);
const result = await credentialsFinderService.findCredentialsForUser(member, [
'credential:read' as const,
]);
expect(credentialsRepository.find).toHaveBeenCalledWith({
where: {
shared: {
role: In(['project:admin']), // Uses what RoleService returned for credential namespace
project: {
projectRelations: {
role: In(['workflow:owner']), // Uses what RoleService returned for project namespace
userId: member.id,
},
},
},
},
relations: { shared: true },
});
expect(result).toEqual(isolationResult);
});
});
});

View File

@@ -0,0 +1,298 @@
import { Logger } from '@n8n/backend-common';
import { mockInstance } from '@n8n/backend-test-utils';
import { RoleRepository } from '@n8n/db';
import { Container } from '@n8n/di';
import { staticRolesWithScope } from '@n8n/permissions';
import { mock } from 'jest-mock-extended';
import type { CacheService } from '@/services/cache/cache.service';
import { RoleCacheService } from '@/services/role-cache.service';
// Mock static function
jest.mock('@n8n/permissions', () => ({
...jest.requireActual('@n8n/permissions'),
staticRolesWithScope: jest.fn(),
}));
describe('RoleCacheService', () => {
const cacheService = mock<CacheService>();
const logger = mockInstance(Logger);
const roleRepository = mockInstance(RoleRepository);
const staticRolesMock = staticRolesWithScope as jest.MockedFunction<typeof staticRolesWithScope>;
const roleCacheService = new RoleCacheService(cacheService, logger);
const mockRoleScopeMap = {
project: {
'project:admin': {
scopes: ['project:read', 'project:update', 'project:delete', 'credential:read'],
},
'project:editor': {
scopes: ['project:read', 'project:update'],
},
'project:viewer': {
scopes: ['project:read'],
},
},
credential: {
'credential:owner': {
scopes: ['credential:read', 'credential:update'],
},
},
workflow: {
'workflow:owner': {
scopes: ['workflow:read', 'workflow:update', 'credential:read'],
},
},
};
beforeEach(() => {
jest.clearAllMocks();
jest.spyOn(Container, 'get').mockReturnValue(roleRepository);
});
describe('getRolesWithAllScopes', () => {
it('should return empty array when no scopes provided', async () => {
const result = await roleCacheService.getRolesWithAllScopes('project', []);
expect(result).toEqual([]);
expect(cacheService.get).not.toHaveBeenCalled();
});
it('should return roles matching namespace and all required scopes', async () => {
cacheService.get.mockResolvedValue(mockRoleScopeMap);
const result = await roleCacheService.getRolesWithAllScopes('project', [
'project:read',
'project:update',
]);
expect(result).toContain('project:admin');
expect(result).toContain('project:editor');
expect(result).not.toContain('project:viewer'); // Missing 'project:update'
expect(result).not.toContain('credential:owner'); // Wrong namespace
});
it('should exclude roles from different namespaces', async () => {
cacheService.get.mockResolvedValue(mockRoleScopeMap);
const result = await roleCacheService.getRolesWithAllScopes('credential', [
'credential:read',
]);
expect(result).toContain('credential:owner');
expect(result).not.toContain('project:admin');
expect(result).not.toContain('workflow:owner');
});
it('should exclude roles missing any required scope', async () => {
cacheService.get.mockResolvedValue(mockRoleScopeMap);
const result = await roleCacheService.getRolesWithAllScopes('project', [
'project:read',
'project:update',
'project:delete',
]);
expect(result).toContain('project:admin'); // Has all three scopes
expect(result).not.toContain('project:editor'); // Missing 'project:delete'
expect(result).not.toContain('project:viewer'); // Missing 'project:update' and 'project:delete'
});
});
describe('cache behavior', () => {
it('should use cached data when available', async () => {
cacheService.get.mockResolvedValue(mockRoleScopeMap);
const result = await roleCacheService.getRolesWithAllScopes('project', ['project:read']);
expect(cacheService.get).toHaveBeenCalledWith('roles:scope-map', {
refreshFn: expect.any(Function),
fallbackValue: undefined,
});
expect(result).toContain('project:admin');
expect(result).toContain('project:editor');
expect(result).toContain('project:viewer');
});
it('should fallback to static roles when cache returns undefined', async () => {
cacheService.get.mockResolvedValue(undefined);
staticRolesMock.mockReturnValue(['static:role']);
const result = await roleCacheService.getRolesWithAllScopes('project', ['project:read']);
expect(staticRolesMock).toHaveBeenCalledWith('project', ['project:read']);
expect(result).toEqual(['static:role']);
expect(logger.error).toHaveBeenCalledWith(
'Role scope map is undefined, falling back to static roles',
);
});
});
describe('buildRoleScopeMap', () => {
it('should refresh cache with new data from database', async () => {
const mockRoles = [
{
slug: 'project:admin',
displayName: 'Project Admin',
description: 'Admin role for projects',
systemRole: true,
roleType: 'project' as const,
projectRelations: [],
createdAt: new Date(),
updatedAt: new Date(),
setUpdateDate: () => {},
scopes: [
{
slug: 'project:read' as const,
displayName: 'Read Projects',
description: 'Can read project data',
},
{
slug: 'project:update' as const,
displayName: 'Update Projects',
description: 'Can update project data',
},
],
},
{
slug: 'credential:owner',
displayName: 'Credential Owner',
description: 'Owner of credentials',
systemRole: true,
createdAt: new Date(),
setUpdateDate: () => {},
updatedAt: new Date(),
roleType: 'credential' as const,
projectRelations: [],
scopes: [
{
slug: 'credential:read' as const,
displayName: 'Read Credentials',
description: 'Can read credential data',
},
{
slug: 'credential:update' as const,
displayName: 'Update Credentials',
description: 'Can update credential data',
},
],
},
];
roleRepository.findAll.mockResolvedValue(mockRoles);
await roleCacheService.refreshCache();
expect(Container.get).toHaveBeenCalledWith(RoleRepository);
expect(roleRepository.findAll).toHaveBeenCalledTimes(1);
expect(cacheService.set).toHaveBeenCalledWith(
'roles:scope-map',
{
project: {
'project:admin': {
scopes: ['project:read', 'project:update'],
},
},
credential: {
'credential:owner': {
scopes: ['credential:read', 'credential:update'],
},
},
},
300000, // 5 minutes TTL
);
});
it('should handle database errors and rethrow with logging', async () => {
const dbError = new Error('Database connection failed');
roleRepository.findAll.mockRejectedValue(dbError);
await expect(roleCacheService.refreshCache()).rejects.toThrow(dbError);
expect(logger.error).toHaveBeenCalledWith('Failed to build role scope from database', {
error: dbError,
});
});
});
describe('cache management', () => {
it('should invalidate cache correctly', async () => {
await roleCacheService.invalidateCache();
expect(cacheService.delete).toHaveBeenCalledWith('roles:scope-map');
});
it('should refresh cache with new data from database', async () => {
const mockRoles = [
{
slug: 'workflow:custom',
displayName: 'Custom Workflow Role',
description: 'Custom workflow access',
systemRole: false,
roleType: 'workflow' as const,
projectRelations: [],
createdAt: new Date(),
updatedAt: new Date(),
setUpdateDate: () => {},
scopes: [
{
slug: 'workflow:read' as const,
displayName: 'Read Workflows',
description: 'Can read workflow data',
},
],
},
];
roleRepository.findAll.mockResolvedValue(mockRoles);
await roleCacheService.refreshCache();
expect(roleRepository.findAll).toHaveBeenCalledTimes(1);
expect(cacheService.set).toHaveBeenCalledWith(
'roles:scope-map',
{
workflow: {
'workflow:custom': {
scopes: ['workflow:read'],
},
},
},
300000, // 5 minutes TTL
);
});
});
describe('error scenarios', () => {
it('should fail if cache service fails to provide data', async () => {
const cacheError = new Error('Cache service unavailable');
cacheService.get.mockRejectedValue(cacheError);
staticRolesMock.mockReturnValue(['fallback:role']);
// This should not throw, but rather handle the error gracefully
await expect(
roleCacheService.getRolesWithAllScopes('project', ['project:read']),
).rejects.toThrow(cacheError);
});
it('should log errors and rethrow when database fails during refresh', async () => {
const dbError = new Error('Database query timeout');
roleRepository.findAll.mockRejectedValue(dbError);
// Trigger database call through cache refresh
cacheService.get.mockImplementation(async (key, options) => {
if (options?.refreshFn) {
await options.refreshFn(key);
}
return undefined;
});
await expect(
roleCacheService.getRolesWithAllScopes('project', ['project:read']),
).rejects.toThrow(dbError);
expect(logger.error).toHaveBeenCalledWith('Failed to build role scope from database', {
error: dbError,
});
});
});
});

View File

@@ -0,0 +1,162 @@
import type { LicenseState } from '@n8n/backend-common';
import { mockInstance } from '@n8n/backend-test-utils';
import { RoleRepository, ScopeRepository } from '@n8n/db';
import { mock } from 'jest-mock-extended';
import { RoleCacheService } from '@/services/role-cache.service';
import { RoleService } from '@/services/role.service';
describe('RoleService.rolesWithScope', () => {
const licenseState = mock<LicenseState>();
const roleRepository = mockInstance(RoleRepository);
const scopeRepository = mockInstance(ScopeRepository);
const roleCacheService = mockInstance(RoleCacheService);
const roleService = new RoleService(
licenseState,
roleRepository,
scopeRepository,
roleCacheService,
);
beforeEach(() => {
jest.clearAllMocks();
});
describe('core functionality', () => {
it('should convert single scope to array and call cache service', async () => {
const mockRoles = ['project:admin', 'project:editor'];
roleCacheService.getRolesWithAllScopes.mockResolvedValue(mockRoles);
const result = await roleService.rolesWithScope('project', 'project:read');
expect(roleCacheService.getRolesWithAllScopes).toHaveBeenCalledWith('project', [
'project:read',
]);
expect(result).toEqual(mockRoles);
});
it('should pass array scopes through correctly', async () => {
const mockRoles = ['project:admin'];
const inputScopes = ['project:read' as const, 'project:update' as const];
roleCacheService.getRolesWithAllScopes.mockResolvedValue(mockRoles);
const result = await roleService.rolesWithScope('project', inputScopes);
expect(roleCacheService.getRolesWithAllScopes).toHaveBeenCalledWith('project', inputScopes);
expect(result).toEqual(mockRoles);
});
it('should handle empty array input', async () => {
const mockRoles: string[] = [];
roleCacheService.getRolesWithAllScopes.mockResolvedValue(mockRoles);
const result = await roleService.rolesWithScope('project', []);
expect(roleCacheService.getRolesWithAllScopes).toHaveBeenCalledWith('project', []);
expect(result).toEqual([]);
});
});
describe('cache service integration', () => {
it('should work with different namespaces', async () => {
const credentialRoles = ['credential:owner'];
const workflowRoles = ['workflow:owner'];
roleCacheService.getRolesWithAllScopes.mockImplementation(async (namespace) => {
if (namespace === 'credential') return credentialRoles;
if (namespace === 'workflow') return workflowRoles;
return [];
});
const credentialResult = await roleService.rolesWithScope('credential', ['credential:read']);
const workflowResult = await roleService.rolesWithScope('workflow', ['workflow:read']);
expect(credentialResult).toEqual(credentialRoles);
expect(workflowResult).toEqual(workflowRoles);
expect(roleCacheService.getRolesWithAllScopes).toHaveBeenCalledTimes(2);
});
it('should propagate cache service errors', async () => {
const cacheError = new Error('Cache service failed');
roleCacheService.getRolesWithAllScopes.mockRejectedValue(cacheError);
await expect(roleService.rolesWithScope('project', ['project:read'])).rejects.toThrow(
cacheError,
);
});
it('should handle successful cache responses', async () => {
const mockRoles = ['project:admin', 'project:editor', 'project:viewer'];
roleCacheService.getRolesWithAllScopes.mockResolvedValue(mockRoles);
const result = await roleService.rolesWithScope('project', [
'project:read',
'project:update',
]);
expect(roleCacheService.getRolesWithAllScopes).toHaveBeenCalledWith('project', [
'project:read',
'project:update',
]);
expect(result).toEqual(mockRoles);
});
});
describe('input validation', () => {
it('should handle various valid scope formats', async () => {
const mockRoles = ['credential:owner'];
roleCacheService.getRolesWithAllScopes.mockResolvedValue(mockRoles);
// Test different scope formats
await roleService.rolesWithScope('credential', 'credential:read');
await roleService.rolesWithScope('workflow', 'workflow:execute');
await roleService.rolesWithScope('project', 'project:delete');
expect(roleCacheService.getRolesWithAllScopes).toHaveBeenCalledTimes(3);
expect(roleCacheService.getRolesWithAllScopes).toHaveBeenNthCalledWith(1, 'credential', [
'credential:read',
]);
expect(roleCacheService.getRolesWithAllScopes).toHaveBeenNthCalledWith(2, 'workflow', [
'workflow:execute',
]);
expect(roleCacheService.getRolesWithAllScopes).toHaveBeenNthCalledWith(3, 'project', [
'project:delete',
]);
});
it('should handle mixed scope arrays', async () => {
const mockRoles = ['project:admin'];
const mixedScopes = [
'project:read' as const,
'project:update' as const,
'project:delete' as const,
];
roleCacheService.getRolesWithAllScopes.mockResolvedValue(mockRoles);
const result = await roleService.rolesWithScope('project', mixedScopes);
expect(roleCacheService.getRolesWithAllScopes).toHaveBeenCalledWith('project', mixedScopes);
expect(result).toEqual(mockRoles);
});
});
describe('edge cases', () => {
it('should handle empty results from cache service', async () => {
roleCacheService.getRolesWithAllScopes.mockResolvedValue([]);
const result = await roleService.rolesWithScope('global', ['*' as const]);
expect(result).toEqual([]);
});
it('should handle single role result', async () => {
const mockRoles = ['project:viewer'];
roleCacheService.getRolesWithAllScopes.mockResolvedValue(mockRoles);
const result = await roleService.rolesWithScope('project', 'project:read');
expect(result).toEqual(mockRoles);
});
});
});

View File

@@ -14,7 +14,6 @@ import {
import { Container, Service } from '@n8n/di';
import {
hasGlobalScope,
rolesWithScope,
type Scope,
type ProjectRole,
AssignableProjectRole,
@@ -445,7 +444,7 @@ export class ProjectService {
};
if (!hasGlobalScope(user, scopes, { mode: 'allOf' })) {
const projectRoles = rolesWithScope('project', scopes);
const projectRoles = await this.roleService.rolesWithScope('project', scopes);
where = {
...where,

View File

@@ -0,0 +1,115 @@
import { Logger } from '@n8n/backend-common';
import { Time } from '@n8n/constants';
import { RoleRepository } from '@n8n/db';
import { Container, Service } from '@n8n/di';
import { staticRolesWithScope, type Scope } from '@n8n/permissions';
import { CacheService } from './cache/cache.service';
type RoleInfo = {
scopes: string[]; // array of scope slugs
};
interface RoleScopeMap {
global?: {
[roleSlug: string]: RoleInfo;
};
project?: {
[roleSlug: string]: RoleInfo;
};
credential?: {
[roleSlug: string]: RoleInfo;
};
workflow?: {
[roleSlug: string]: RoleInfo;
};
}
@Service()
export class RoleCacheService {
private static readonly CACHE_KEY = 'roles:scope-map';
private static readonly CACHE_TTL = 5 * Time.minutes.toMilliseconds; // 5 minutes TTL
constructor(
private readonly cacheService: CacheService,
private readonly logger: Logger,
) {}
/**
* Get all roles from database and build scope map
*/
private async buildRoleScopeMap(): Promise<RoleScopeMap> {
try {
const roleRepository = Container.get(RoleRepository);
const roles = await roleRepository.findAll();
const roleScopeMap: RoleScopeMap = {};
for (const role of roles) {
roleScopeMap[role.roleType] ??= {};
roleScopeMap[role.roleType]![role.slug] = {
scopes: role.scopes.map((s) => s.slug),
};
}
return roleScopeMap;
} catch (error) {
this.logger.error('Failed to build role scope from database', { error });
throw error;
}
}
/**
* Get roles with all specified scopes (with caching)
*/
async getRolesWithAllScopes(
namespace: 'global' | 'project' | 'credential' | 'workflow',
requiredScopes: Scope[],
): Promise<string[]> {
if (requiredScopes.length === 0) return [];
// Get cached role map with refresh function
const roleScopeMap = await this.cacheService.get<RoleScopeMap>(RoleCacheService.CACHE_KEY, {
refreshFn: async () => await this.buildRoleScopeMap(),
fallbackValue: undefined,
});
if (roleScopeMap === undefined) {
// TODO: actively report this case to sentry or similar system
this.logger.error('Role scope map is undefined, falling back to static roles');
// Fallback to static roles if dynamic data is not available
return staticRolesWithScope(namespace, requiredScopes);
}
// Filter roles by namespace and scopes
const matchingRoles: string[] = [];
for (const [roleSlug, roleInfo] of Object.entries(roleScopeMap[namespace] ?? {})) {
// Check if role has ALL required scopes
const hasAllScopes = requiredScopes.every((scope) => roleInfo.scopes.includes(scope));
if (hasAllScopes) {
matchingRoles.push(roleSlug);
}
}
return matchingRoles;
}
/**
* Invalidate the role cache (call after role changes)
*/
async invalidateCache(): Promise<void> {
await this.cacheService.delete(RoleCacheService.CACHE_KEY);
}
/**
* Force refresh the cache
*/
async refreshCache(): Promise<void> {
const roleScopeMap = await this.buildRoleScopeMap();
await this.cacheService.set(
RoleCacheService.CACHE_KEY,
roleScopeMap,
RoleCacheService.CACHE_TTL,
);
}
}

View File

@@ -15,7 +15,12 @@ import {
GLOBAL_ADMIN_ROLE,
} from '@n8n/db';
import { Service } from '@n8n/di';
import type { Scope, Role as RoleDTO, AssignableProjectRole } from '@n8n/permissions';
import type {
Scope,
Role as RoleDTO,
AssignableProjectRole,
RoleNamespace,
} from '@n8n/permissions';
import {
combineScopes,
getAuthPrincipalScopes,
@@ -29,6 +34,7 @@ import { UnexpectedError, UserError } from 'n8n-workflow';
import { BadRequestError } from '@/errors/response-errors/bad-request.error';
import { NotFoundError } from '@/errors/response-errors/not-found.error';
import { RoleCacheService } from './role-cache.service';
@Service()
export class RoleService {
@@ -36,6 +42,7 @@ export class RoleService {
private readonly license: LicenseState,
private readonly roleRepository: RoleRepository,
private readonly scopeRepository: ScopeRepository,
private readonly roleCacheService: RoleCacheService,
) {}
private dbRoleToRoleDTO(role: Role, usedByUsers?: number): RoleDTO {
@@ -82,6 +89,10 @@ export class RoleService {
throw new BadRequestError('Cannot delete system roles');
}
await this.roleRepository.removeBySlug(slug);
// Invalidate cache after role deletion
await this.roleCacheService.invalidateCache();
return this.dbRoleToRoleDTO(role);
}
@@ -113,6 +124,9 @@ export class RoleService {
scopes: await this.resolveScopes(scopeSlugs),
});
// Invalidate cache after role update
await this.roleCacheService.invalidateCache();
return this.dbRoleToRoleDTO(updatedRole);
} catch (error) {
if (error instanceof UserError && error.message === 'Role not found') {
@@ -139,6 +153,10 @@ export class RoleService {
role.roleType = newRole.roleType;
role.slug = `${newRole.roleType}:${newRole.displayName.toLowerCase().replace(/[^a-z0-9]+/g, '-')}-${Math.random().toString(36).substring(2, 8)}`;
const createdRole = await this.roleRepository.save(role);
// Invalidate cache after role creation
await this.roleCacheService.invalidateCache();
return this.dbRoleToRoleDTO(createdRole);
}
@@ -246,6 +264,18 @@ export class RoleService {
return [...scopesSet].sort();
}
/**
* Enhanced rolesWithScope function that combines static roles with database roles
* This replaces the original rolesWithScope function from @n8n/permissions
*/
async rolesWithScope(namespace: RoleNamespace, scopes: Scope | Scope[]): Promise<string[]> {
if (!Array.isArray(scopes)) {
scopes = [scopes];
}
// Get database roles from cache
return await this.roleCacheService.getRolesWithAllScopes(namespace, scopes);
}
isRoleLicensed(role: AssignableProjectRole) {
// TODO: move this info into FrontendSettings

View File

@@ -1,17 +1,20 @@
import type { SharedWorkflow, User } from '@n8n/db';
import { SharedWorkflowRepository, FolderRepository } from '@n8n/db';
import { Service } from '@n8n/di';
import { hasGlobalScope, rolesWithScope, type Scope } from '@n8n/permissions';
import { hasGlobalScope, type Scope } from '@n8n/permissions';
// eslint-disable-next-line n8n-local-rules/misplaced-n8n-typeorm-import
import type { EntityManager, FindOptionsWhere } from '@n8n/typeorm';
// eslint-disable-next-line n8n-local-rules/misplaced-n8n-typeorm-import
import { In } from '@n8n/typeorm';
import { RoleService } from '@/services/role.service';
@Service()
export class WorkflowFinderService {
constructor(
private readonly sharedWorkflowRepository: SharedWorkflowRepository,
private readonly folderRepository: FolderRepository,
private readonly roleService: RoleService,
) {}
async findWorkflowForUser(
@@ -27,8 +30,10 @@ export class WorkflowFinderService {
let where: FindOptionsWhere<SharedWorkflow> = {};
if (!hasGlobalScope(user, scopes, { mode: 'allOf' })) {
const projectRoles = rolesWithScope('project', scopes);
const workflowRoles = rolesWithScope('workflow', scopes);
const [projectRoles, workflowRoles] = await Promise.all([
this.roleService.rolesWithScope('project', scopes),
this.roleService.rolesWithScope('workflow', scopes),
]);
where = {
role: In(workflowRoles),
@@ -78,8 +83,10 @@ export class WorkflowFinderService {
}
if (!hasGlobalScope(user, scopes, { mode: 'allOf' })) {
const projectRoles = rolesWithScope('project', scopes);
const workflowRoles = rolesWithScope('workflow', scopes);
const [projectRoles, workflowRoles] = await Promise.all([
this.roleService.rolesWithScope('project', scopes),
this.roleService.rolesWithScope('workflow', scopes),
]);
where = {
...where,

View File

@@ -3,7 +3,6 @@ import { ProjectRelationRepository, SharedWorkflowRepository } from '@n8n/db';
import { Service } from '@n8n/di';
import {
hasGlobalScope,
rolesWithScope,
type ProjectRole,
type WorkflowSharingRole,
type Scope,
@@ -47,9 +46,13 @@ export class WorkflowSharingService {
}
const projectRoles =
'scopes' in options ? rolesWithScope('project', options.scopes) : options.projectRoles;
'scopes' in options
? await this.roleService.rolesWithScope('project', options.scopes)
: options.projectRoles;
const workflowRoles =
'scopes' in options ? rolesWithScope('workflow', options.scopes) : options.workflowRoles;
'scopes' in options
? await this.roleService.rolesWithScope('workflow', options.scopes)
: options.workflowRoles;
const sharedWorkflows = await this.sharedWorkflowRepository.find({
where: {

View File

@@ -140,8 +140,6 @@ export class WorkflowsController {
let project: Project | null;
const savedWorkflow = await dbManager.transaction(async (transactionManager) => {
const workflow = await transactionManager.save<WorkflowEntity>(newWorkflow);
const { projectId, parentFolderId } = req.body;
project =
projectId === undefined
@@ -164,6 +162,8 @@ export class WorkflowsController {
throw new UnexpectedError('No personal project found');
}
const workflow = await transactionManager.save<WorkflowEntity>(newWorkflow);
if (parentFolderId) {
try {
const parentFolder = await this.folderService.findFolderInProjectOrFail(

View File

@@ -34,6 +34,7 @@ import {
} from '../shared/db/users';
import type { SaveCredentialFunction, SuperAgentTest } from '../shared/types';
import * as utils from '../shared/utils';
import { RoleCacheService } from '@/services/role-cache.service';
const testServer = utils.setupTestServer({
endpointGroups: ['credentials'],
@@ -59,6 +60,10 @@ const mailer = mockInstance(UserManagementMailer);
let projectService: ProjectService;
let projectRepository: ProjectRepository;
beforeAll(async () => {
await Container.get(RoleCacheService).refreshCache();
});
beforeEach(async () => {
await testDb.truncate(['SharedCredentials', 'CredentialsEntity', 'Project', 'ProjectRelation']);
projectRepository = Container.get(ProjectRepository);

View File

@@ -49,6 +49,32 @@ export async function createCustomRoleWithScopes(
});
}
/**
* Creates a custom role with specific scope slugs (using existing permission system scopes)
*/
export async function createCustomRoleWithScopeSlugs(
scopeSlugs: string[],
overrides: Partial<Role> = {},
): Promise<Role> {
const scopeRepository = Container.get(ScopeRepository);
// Find existing scopes by their slugs
const scopes = await scopeRepository.findByList(scopeSlugs);
if (scopes.length !== scopeSlugs.length) {
const missingScopes = scopeSlugs.filter((slug) => !scopes.some((scope) => scope.slug === slug));
throw new Error(
`Could not find all scopes. Expected ${scopeSlugs.length}, found ${scopes.length}, missing: ${missingScopes.join(', ')}`,
);
}
return await createRole({
scopes,
systemRole: false,
...overrides,
});
}
/**
* Creates a test scope with given parameters
*/