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

View File

@@ -20,7 +20,7 @@ export {
export { hasScope } from './utilities/has-scope.ee'; export { hasScope } from './utilities/has-scope.ee';
export { hasGlobalScope } from './utilities/has-global-scope.ee'; export { hasGlobalScope } from './utilities/has-global-scope.ee';
export { combineScopes } from './utilities/combine-scopes.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 { getGlobalScopes } from './utilities/get-global-scopes.ee';
export { getRoleScopes, getAuthPrincipalScopes } from './utilities/get-role-scopes.ee'; export { getRoleScopes, getAuthPrincipalScopes } from './utilities/get-role-scopes.ee';
export { getResourcePermissions } from './utilities/get-resource-permissions.ee'; export { getResourcePermissions } from './utilities/get-resource-permissions.ee';

View File

@@ -1,5 +1,5 @@
import type { GlobalRole, Scope } from '../../types.ee'; 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('rolesWithScope', () => {
describe('global roles', () => { describe('global roles', () => {
@@ -8,14 +8,14 @@ describe('rolesWithScope', () => {
['user:list', ['global:owner', 'global:admin', 'global:member']], ['user:list', ['global:owner', 'global:admin', 'global:member']],
['invalid:scope', []], ['invalid:scope', []],
] as Array<[Scope, GlobalRole[]]>)('%s -> %s', (scope, expected) => { ] as Array<[Scope, GlobalRole[]]>)('%s -> %s', (scope, expected) => {
expect(rolesWithScope('global', scope)).toEqual(expected); expect(staticRolesWithScope('global', scope)).toEqual(expected);
}); });
}); });
describe('multiple scopes', () => { describe('multiple scopes', () => {
test('returns roles with all scopes', () => { test('returns roles with all scopes', () => {
expect( expect(
rolesWithScope('global', [ staticRolesWithScope('global', [
// all global roles have this scope // all global roles have this scope
'tag:create', 'tag:create',
// only owner and admin have this scope // 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 type { CredentialsEntity, SharedCredentials, User } from '@n8n/db';
import { CredentialsRepository, SharedCredentialsRepository } from '@n8n/db'; import { CredentialsRepository, SharedCredentialsRepository } from '@n8n/db';
import { Service } from '@n8n/di'; import { Service } from '@n8n/di';
import { hasGlobalScope, rolesWithScope } from '@n8n/permissions'; import { hasGlobalScope } from '@n8n/permissions';
import type { CredentialSharingRole, ProjectRole, Scope } from '@n8n/permissions'; import type { CredentialSharingRole, ProjectRole, Scope } from '@n8n/permissions';
// eslint-disable-next-line n8n-local-rules/misplaced-n8n-typeorm-import // eslint-disable-next-line n8n-local-rules/misplaced-n8n-typeorm-import
import type { EntityManager, FindOptionsWhere } from '@n8n/typeorm'; import type { EntityManager, FindOptionsWhere } from '@n8n/typeorm';
// eslint-disable-next-line n8n-local-rules/misplaced-n8n-typeorm-import // eslint-disable-next-line n8n-local-rules/misplaced-n8n-typeorm-import
import { In } from '@n8n/typeorm'; import { In } from '@n8n/typeorm';
import { RoleService } from '@/services/role.service';
@Service() @Service()
export class CredentialsFinderService { export class CredentialsFinderService {
constructor( constructor(
private readonly sharedCredentialsRepository: SharedCredentialsRepository, private readonly sharedCredentialsRepository: SharedCredentialsRepository,
private readonly credentialsRepository: CredentialsRepository, private readonly credentialsRepository: CredentialsRepository,
private readonly roleService: RoleService,
) {} ) {}
/** /**
@@ -26,8 +29,10 @@ export class CredentialsFinderService {
let where: FindOptionsWhere<CredentialsEntity> = {}; let where: FindOptionsWhere<CredentialsEntity> = {};
if (!hasGlobalScope(user, scopes, { mode: 'allOf' })) { if (!hasGlobalScope(user, scopes, { mode: 'allOf' })) {
const projectRoles = rolesWithScope('project', scopes); const [projectRoles, credentialRoles] = await Promise.all([
const credentialRoles = rolesWithScope('credential', scopes); this.roleService.rolesWithScope('project', scopes),
this.roleService.rolesWithScope('credential', scopes),
]);
where = { where = {
...where, ...where,
shared: { shared: {
@@ -50,8 +55,10 @@ export class CredentialsFinderService {
let where: FindOptionsWhere<SharedCredentials> = { credentialsId }; let where: FindOptionsWhere<SharedCredentials> = { credentialsId };
if (!hasGlobalScope(user, scopes, { mode: 'allOf' })) { if (!hasGlobalScope(user, scopes, { mode: 'allOf' })) {
const projectRoles = rolesWithScope('project', scopes); const [projectRoles, credentialRoles] = await Promise.all([
const credentialRoles = rolesWithScope('credential', scopes); this.roleService.rolesWithScope('project', scopes),
this.roleService.rolesWithScope('credential', scopes),
]);
where = { where = {
...where, ...where,
role: In(credentialRoles), role: In(credentialRoles),
@@ -82,8 +89,10 @@ export class CredentialsFinderService {
let where: FindOptionsWhere<SharedCredentials> = {}; let where: FindOptionsWhere<SharedCredentials> = {};
if (!hasGlobalScope(user, scopes, { mode: 'allOf' })) { if (!hasGlobalScope(user, scopes, { mode: 'allOf' })) {
const projectRoles = rolesWithScope('project', scopes); const [projectRoles, credentialRoles] = await Promise.all([
const credentialRoles = rolesWithScope('credential', scopes); this.roleService.rolesWithScope('project', scopes),
this.roleService.rolesWithScope('credential', scopes),
]);
where = { where = {
role: In(credentialRoles), role: In(credentialRoles),
project: { project: {
@@ -111,9 +120,13 @@ export class CredentialsFinderService {
trx?: EntityManager, trx?: EntityManager,
) { ) {
const projectRoles = 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 = 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( const sharings = await this.sharedCredentialsRepository.findCredentialsByRoles(
userIds, userIds,

View File

@@ -1,7 +1,7 @@
import type { CredentialsEntity, User } from '@n8n/db'; import type { CredentialsEntity, User } from '@n8n/db';
import { Project, SharedCredentials, SharedCredentialsRepository } from '@n8n/db'; import { Project, SharedCredentials, SharedCredentialsRepository } from '@n8n/db';
import { Service } from '@n8n/di'; 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 // eslint-disable-next-line n8n-local-rules/misplaced-n8n-typeorm-import
import { In, type EntityManager } from '@n8n/typeorm'; import { In, type EntityManager } from '@n8n/typeorm';
import type { ICredentialDataDecryptedObject } from 'n8n-workflow'; 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 { TransferCredentialError } from '@/errors/response-errors/transfer-credential.error';
import { OwnershipService } from '@/services/ownership.service'; import { OwnershipService } from '@/services/ownership.service';
import { ProjectService } from '@/services/project.service.ee'; import { ProjectService } from '@/services/project.service.ee';
import { RoleService } from '@/services/role.service';
import { CredentialsFinderService } from './credentials-finder.service'; import { CredentialsFinderService } from './credentials-finder.service';
import { CredentialsService } from './credentials.service'; import { CredentialsService } from './credentials.service';
@@ -22,6 +23,7 @@ export class EnterpriseCredentialsService {
private readonly credentialsService: CredentialsService, private readonly credentialsService: CredentialsService,
private readonly projectService: ProjectService, private readonly projectService: ProjectService,
private readonly credentialsFinderService: CredentialsFinderService, private readonly credentialsFinderService: CredentialsFinderService,
private readonly roleService: RoleService,
) {} ) {}
async shareWithProjects( async shareWithProjects(
@@ -31,6 +33,7 @@ export class EnterpriseCredentialsService {
entityManager?: EntityManager, entityManager?: EntityManager,
) { ) {
const em = entityManager ?? this.sharedCredentialsRepository.manager; const em = entityManager ?? this.sharedCredentialsRepository.manager;
const roles = await this.roleService.rolesWithScope('project', ['project:list']);
let projects = await em.find(Project, { let projects = await em.find(Project, {
where: [ where: [
@@ -44,7 +47,7 @@ export class EnterpriseCredentialsService {
: { : {
projectRelations: { projectRelations: {
userId: user.id, 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 { manager: dbManager } = this.credentialsRepository;
const result = await dbManager.transaction(async (transactionManager) => { const result = await dbManager.transaction(async (transactionManager) => {
const savedCredential = await transactionManager.save<CredentialsEntity>(newCredential);
savedCredential.data = newCredential.data;
const project = const project =
projectId === undefined projectId === undefined
? await this.projectRepository.getPersonalProjectForUserOrFail( ? await this.projectRepository.getPersonalProjectForUserOrFail(
@@ -437,6 +433,10 @@ export class CredentialsService {
throw new UnexpectedError('No personal project found'); throw new UnexpectedError('No personal project found');
} }
const savedCredential = await transactionManager.save<CredentialsEntity>(newCredential);
savedCredential.data = newCredential.data;
const newSharedCredential = this.sharedCredentialsRepository.create({ const newSharedCredential = this.sharedCredentialsRepository.create({
role: 'credential:owner', role: 'credential:owner',
credentials: savedCredential, credentials: savedCredential,

View File

@@ -6,18 +6,25 @@ import {
type User, type User,
} from '@n8n/db'; } from '@n8n/db';
import { Container } from '@n8n/di'; import { Container } from '@n8n/di';
import type { Scope } from '@n8n/permissions'; import { type Scope } from '@n8n/permissions';
import { mock } from 'jest-mock-extended'; import { mock } from 'jest-mock-extended';
import { NotFoundError } from '@/errors/response-errors/not-found.error'; import { NotFoundError } from '@/errors/response-errors/not-found.error';
import { RoleService } from '@/services/role.service';
import { userHasScopes } from '../check-access'; import { userHasScopes } from '../check-access';
describe('userHasScopes', () => { describe('userHasScopes', () => {
const findByWorkflowMock = jest.fn(); let findByWorkflowMock: jest.Mock;
const findByCredentialMock = jest.fn(); let findByCredentialMock: jest.Mock;
let roleServiceMock: jest.Mock;
let mockQueryBuilder: any;
beforeAll(() => { beforeAll(() => {
findByWorkflowMock = jest.fn();
findByCredentialMock = jest.fn();
roleServiceMock = jest.fn();
Container.set( Container.set(
SharedWorkflowRepository, SharedWorkflowRepository,
mock<SharedWorkflowRepository>({ mock<SharedWorkflowRepository>({
@@ -32,7 +39,7 @@ describe('userHasScopes', () => {
}), }),
); );
const mockQueryBuilder = { mockQueryBuilder = {
innerJoin: jest.fn().mockReturnThis(), innerJoin: jest.fn().mockReturnThis(),
where: jest.fn().mockReturnThis(), where: jest.fn().mockReturnThis(),
andWhere: jest.fn().mockReturnThis(), andWhere: jest.fn().mockReturnThis(),
@@ -48,117 +55,358 @@ describe('userHasScopes', () => {
createQueryBuilder: jest.fn().mockReturnValue(mockQueryBuilder), createQueryBuilder: jest.fn().mockReturnValue(mockQueryBuilder),
}), }),
); );
Container.set(
RoleService,
mock<RoleService>({
rolesWithScope: roleServiceMock,
}),
);
}); });
beforeEach(() => { beforeEach(() => {
jest.clearAllMocks();
findByWorkflowMock.mockReset(); findByWorkflowMock.mockReset();
findByCredentialMock.mockReset(); findByCredentialMock.mockReset();
roleServiceMock.mockReset();
// Default mock responses
mockQueryBuilder.getRawMany.mockResolvedValue([{ id: 'projectId' }]);
}); });
describe('resource not found scenarios', () => {
it.each<{ type: 'workflow' | 'credential'; id: string }>([ it.each<{ type: 'workflow' | 'credential'; id: string }>([
{ { type: 'workflow', id: 'workflowId' },
type: 'workflow', { type: 'credential', id: 'credentialId' },
id: 'workflowId', ])('should throw NotFoundError if $type is not found', async ({ type, id }) => {
}, findByWorkflowMock.mockResolvedValue([]);
{ findByCredentialMock.mockResolvedValue([]);
type: 'credential',
id: 'credentialId',
},
])('should return 404 if the resource is not found', async ({ type, id }) => {
findByWorkflowMock.mockResolvedValueOnce([]);
findByCredentialMock.mockResolvedValueOnce([]);
const user = { id: 'userId', scopes: [], role: GLOBAL_MEMBER_ROLE } as unknown as User; const user = { id: 'userId', scopes: [], role: GLOBAL_MEMBER_ROLE } as unknown as User;
const scopes = ['workflow:read', 'credential:read'] as Scope[]; const scopes = ['workflow:read', 'credential:read'] as Scope[];
const params: { credentialId?: string; workflowId?: string; projectId?: string } = { const params: { credentialId?: string; workflowId?: string; projectId?: string } = {};
projectId: 'projectId',
};
if (type === 'credential') { if (type === 'credential') {
params.credentialId = id; params.credentialId = id;
} else { } else {
params.workflowId = id; params.workflowId = id;
} }
await expect(userHasScopes(user, scopes, false, params)).rejects.toThrow(NotFoundError); await expect(userHasScopes(user, scopes, false, params)).rejects.toThrow(NotFoundError);
}); });
});
test.each<{ describe('RoleService integration', () => {
type: 'workflow' | 'credential'; it('should use RoleService for credential role resolution', async () => {
id: string; const credentialId = 'cred123';
role: string; const mockRoles = ['credential:owner', 'custom:credential-admin'];
scope: Scope;
userScopes: Scope[]; roleServiceMock.mockResolvedValue(mockRoles);
expected: boolean; findByCredentialMock.mockResolvedValue([
}>([
{ {
type: 'workflow', credentialsId: credentialId,
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', projectId: 'projectId',
role, role: 'credential:owner',
}, },
]); ]);
} else {
findByCredentialMock.mockResolvedValueOnce([ 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([
{ {
credentialId: id, workflowId,
projectId: 'projectId', projectId: 'projectId',
role, role: 'workflow:owner',
}, },
]); ]);
}
const user = { const user = { id: 'userId', scopes: [], role: GLOBAL_MEMBER_ROLE } as unknown as User;
id: 'userId', const scopes = ['workflow:read'] as Scope[];
scopes: userScopes,
role: GLOBAL_MEMBER_ROLE, const result = await userHasScopes(user, scopes, false, { workflowId });
} as unknown as User;
const scopes = [scope] as Scope[]; expect(roleServiceMock).toHaveBeenCalledWith('workflow', scopes);
const params: { credentialId?: string; workflowId?: string; projectId?: string } = { 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', projectId: 'projectId',
}; role: 'custom:admin-role-abc123', // Custom role from database
if (type === 'credential') {
params.credentialId = id;
} else {
params.workflowId = id;
}
const result = await userHasScopes(user, scopes, false, params);
expect(result).toBe(expected);
}, },
]);
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 type { User } from '@n8n/db';
import { ProjectRepository, SharedCredentialsRepository, SharedWorkflowRepository } from '@n8n/db'; import { ProjectRepository, SharedCredentialsRepository, SharedWorkflowRepository } from '@n8n/db';
import { Container } from '@n8n/di'; 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 { UnexpectedError } from 'n8n-workflow';
import { NotFoundError } from '@/errors/response-errors/not-found.error'; 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: * 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. // Find which resource roles are defined to contain the required scopes.
// Then find at least one of the above qualifying projects having one of // Then find at least one of the above qualifying projects having one of
// those resource roles over the resource being checked. // those resource roles over the resource being checked.
const roleService = Container.get(RoleService);
if (credentialId) { if (credentialId) {
const credentials = await Container.get(SharedCredentialsRepository).findBy({ const credentials = await Container.get(SharedCredentialsRepository).findBy({
@@ -56,10 +58,10 @@ export async function userHasScopes(
throw new NotFoundError(`Credential with ID "${credentialId}" not found.`); throw new NotFoundError(`Credential with ID "${credentialId}" not found.`);
} }
const validRoles = await roleService.rolesWithScope('credential', scopes);
return credentials.some( return credentials.some(
(c) => (c) => userProjectIds.includes(c.projectId) && validRoles.includes(c.role),
userProjectIds.includes(c.projectId) &&
rolesWithScope('credential', scopes).includes(c.role),
); );
} }
@@ -72,9 +74,10 @@ export async function userHasScopes(
throw new NotFoundError(`Workflow with ID "${workflowId}" not found.`); throw new NotFoundError(`Workflow with ID "${workflowId}" not found.`);
} }
const validRoles = await roleService.rolesWithScope('workflow', scopes);
return workflows.some( return workflows.some(
(w) => (w) => userProjectIds.includes(w.projectId) && validRoles.includes(w.role),
userProjectIds.includes(w.projectId) && rolesWithScope('workflow', scopes).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 type { CredentialsEntity, User } from '@n8n/db';
import { Container } from '@n8n/di'; import { Container } from '@n8n/di';
import { import {
@@ -8,15 +14,43 @@ import {
PROJECT_VIEWER_ROLE_SLUG, PROJECT_VIEWER_ROLE_SLUG,
} from '@n8n/permissions'; } from '@n8n/permissions';
import { In } from '@n8n/typeorm'; import { In } from '@n8n/typeorm';
import { mockEntityManager } from '@test/mocking';
import { mock } from 'jest-mock-extended'; import { mock } from 'jest-mock-extended';
import { mockInstance } from '@n8n/backend-test-utils';
import { CredentialsFinderService } from '@/credentials/credentials-finder.service'; import { CredentialsFinderService } from '@/credentials/credentials-finder.service';
import { RoleService } from '../role.service';
describe('CredentialsFinderService', () => { describe('CredentialsFinderService', () => {
const entityManager = mockEntityManager(SharedCredentials); const roleService = mockInstance(RoleService);
const credentialsRepository = mockInstance(CredentialsRepository);
const sharedCredentialsRepository = mockInstance(SharedCredentialsRepository);
const credentialsFinderService = Container.get(CredentialsFinderService); 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', () => { describe('findCredentialForUser', () => {
const credentialsId = 'cred_123'; const credentialsId = 'cred_123';
const sharedCredential = mock<SharedCredentials>(); const sharedCredential = mock<SharedCredentials>();
@@ -29,33 +63,38 @@ describe('CredentialsFinderService', () => {
id: 'test', id: 'test',
}); });
beforeEach(() => {
jest.resetAllMocks();
});
test('should allow instance owner access to all credentials', async () => { test('should allow instance owner access to all credentials', async () => {
entityManager.findOne.mockResolvedValueOnce(sharedCredential); sharedCredentialsRepository.findOne.mockResolvedValueOnce(sharedCredential);
const credential = await credentialsFinderService.findCredentialForUser( const credential = await credentialsFinderService.findCredentialForUser(
credentialsId, credentialsId,
owner, owner,
['credential:read'], ['credential:read' as const],
); );
expect(entityManager.findOne).toHaveBeenCalledWith(SharedCredentials, { expect(sharedCredentialsRepository.findOne).toHaveBeenCalledWith({
relations: { credentials: { shared: { project: { projectRelations: { user: true } } } } },
where: { credentialsId }, where: { credentialsId },
relations: {
credentials: {
shared: { project: { projectRelations: { user: true } } },
},
},
}); });
expect(roleService.rolesWithScope).not.toHaveBeenCalled();
expect(credential).toEqual(sharedCredential.credentials); expect(credential).toEqual(sharedCredential.credentials);
}); });
test('should allow members', async () => { test('should allow members and call RoleService correctly', async () => {
entityManager.findOne.mockResolvedValueOnce(sharedCredential); sharedCredentialsRepository.findOne.mockResolvedValueOnce(sharedCredential);
const credential = await credentialsFinderService.findCredentialForUser( const credential = await credentialsFinderService.findCredentialForUser(
credentialsId, credentialsId,
member, 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: { where: {
credentialsId, credentialsId,
role: In(['credential:owner', 'credential:user']), role: In(['credential:owner', 'credential:user']),
@@ -71,19 +110,23 @@ describe('CredentialsFinderService', () => {
}, },
}, },
}, },
relations: {
credentials: {
shared: { project: { projectRelations: { user: true } } },
},
},
}); });
expect(credential).toEqual(sharedCredential.credentials); expect(credential).toEqual(sharedCredential.credentials);
}); });
test('should return null when no shared credential is found', async () => { test('should return null when no shared credential is found', async () => {
entityManager.findOne.mockResolvedValueOnce(null); sharedCredentialsRepository.findOne.mockResolvedValueOnce(null);
const credential = await credentialsFinderService.findCredentialForUser( const credential = await credentialsFinderService.findCredentialForUser(
credentialsId, credentialsId,
member, member,
['credential:read'], ['credential:read' as const],
); );
expect(entityManager.findOne).toHaveBeenCalledWith(SharedCredentials, { expect(sharedCredentialsRepository.findOne).toHaveBeenCalledWith({
relations: { credentials: { shared: { project: { projectRelations: { user: true } } } } },
where: { where: {
credentialsId, credentialsId,
role: In(['credential:owner', 'credential:user']), role: In(['credential:owner', 'credential:user']),
@@ -99,8 +142,446 @@ describe('CredentialsFinderService', () => {
}, },
}, },
}, },
relations: {
credentials: {
shared: { project: { projectRelations: { user: true } } },
},
},
}); });
expect(credential).toEqual(null); 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 { Container, Service } from '@n8n/di';
import { import {
hasGlobalScope, hasGlobalScope,
rolesWithScope,
type Scope, type Scope,
type ProjectRole, type ProjectRole,
AssignableProjectRole, AssignableProjectRole,
@@ -445,7 +444,7 @@ export class ProjectService {
}; };
if (!hasGlobalScope(user, scopes, { mode: 'allOf' })) { if (!hasGlobalScope(user, scopes, { mode: 'allOf' })) {
const projectRoles = rolesWithScope('project', scopes); const projectRoles = await this.roleService.rolesWithScope('project', scopes);
where = { where = {
...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, GLOBAL_ADMIN_ROLE,
} from '@n8n/db'; } from '@n8n/db';
import { Service } from '@n8n/di'; 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 { import {
combineScopes, combineScopes,
getAuthPrincipalScopes, getAuthPrincipalScopes,
@@ -29,6 +34,7 @@ import { UnexpectedError, UserError } from 'n8n-workflow';
import { BadRequestError } from '@/errors/response-errors/bad-request.error'; import { BadRequestError } from '@/errors/response-errors/bad-request.error';
import { NotFoundError } from '@/errors/response-errors/not-found.error'; import { NotFoundError } from '@/errors/response-errors/not-found.error';
import { RoleCacheService } from './role-cache.service';
@Service() @Service()
export class RoleService { export class RoleService {
@@ -36,6 +42,7 @@ export class RoleService {
private readonly license: LicenseState, private readonly license: LicenseState,
private readonly roleRepository: RoleRepository, private readonly roleRepository: RoleRepository,
private readonly scopeRepository: ScopeRepository, private readonly scopeRepository: ScopeRepository,
private readonly roleCacheService: RoleCacheService,
) {} ) {}
private dbRoleToRoleDTO(role: Role, usedByUsers?: number): RoleDTO { private dbRoleToRoleDTO(role: Role, usedByUsers?: number): RoleDTO {
@@ -82,6 +89,10 @@ export class RoleService {
throw new BadRequestError('Cannot delete system roles'); throw new BadRequestError('Cannot delete system roles');
} }
await this.roleRepository.removeBySlug(slug); await this.roleRepository.removeBySlug(slug);
// Invalidate cache after role deletion
await this.roleCacheService.invalidateCache();
return this.dbRoleToRoleDTO(role); return this.dbRoleToRoleDTO(role);
} }
@@ -113,6 +124,9 @@ export class RoleService {
scopes: await this.resolveScopes(scopeSlugs), scopes: await this.resolveScopes(scopeSlugs),
}); });
// Invalidate cache after role update
await this.roleCacheService.invalidateCache();
return this.dbRoleToRoleDTO(updatedRole); return this.dbRoleToRoleDTO(updatedRole);
} catch (error) { } catch (error) {
if (error instanceof UserError && error.message === 'Role not found') { if (error instanceof UserError && error.message === 'Role not found') {
@@ -139,6 +153,10 @@ export class RoleService {
role.roleType = newRole.roleType; role.roleType = newRole.roleType;
role.slug = `${newRole.roleType}:${newRole.displayName.toLowerCase().replace(/[^a-z0-9]+/g, '-')}-${Math.random().toString(36).substring(2, 8)}`; 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); const createdRole = await this.roleRepository.save(role);
// Invalidate cache after role creation
await this.roleCacheService.invalidateCache();
return this.dbRoleToRoleDTO(createdRole); return this.dbRoleToRoleDTO(createdRole);
} }
@@ -246,6 +264,18 @@ export class RoleService {
return [...scopesSet].sort(); 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) { isRoleLicensed(role: AssignableProjectRole) {
// TODO: move this info into FrontendSettings // TODO: move this info into FrontendSettings

View File

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

View File

@@ -3,7 +3,6 @@ import { ProjectRelationRepository, SharedWorkflowRepository } from '@n8n/db';
import { Service } from '@n8n/di'; import { Service } from '@n8n/di';
import { import {
hasGlobalScope, hasGlobalScope,
rolesWithScope,
type ProjectRole, type ProjectRole,
type WorkflowSharingRole, type WorkflowSharingRole,
type Scope, type Scope,
@@ -47,9 +46,13 @@ export class WorkflowSharingService {
} }
const projectRoles = 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 = 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({ const sharedWorkflows = await this.sharedWorkflowRepository.find({
where: { where: {

View File

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

View File

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