mirror of
https://github.com/Abdulazizzn/n8n-enterprise-unlocked.git
synced 2025-12-16 09:36:44 +00:00
chore(core): Use dynamic role resolution for access control (#19400)
This commit is contained in:
@@ -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;
|
||||||
|
|||||||
@@ -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';
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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),
|
|
||||||
);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
@@ -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),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
|||||||
@@ -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),
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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' }]);
|
||||||
});
|
});
|
||||||
|
|
||||||
it.each<{ type: 'workflow' | 'credential'; id: string }>([
|
describe('resource not found scenarios', () => {
|
||||||
{
|
it.each<{ type: 'workflow' | 'credential'; id: string }>([
|
||||||
type: 'workflow',
|
{ type: 'workflow', id: 'workflowId' },
|
||||||
id: 'workflowId',
|
{ type: 'credential', id: 'credentialId' },
|
||||||
},
|
])('should throw NotFoundError if $type is not found', async ({ type, id }) => {
|
||||||
{
|
findByWorkflowMock.mockResolvedValue([]);
|
||||||
type: 'credential',
|
findByCredentialMock.mockResolvedValue([]);
|
||||||
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') {
|
|
||||||
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',
|
|
||||||
};
|
|
||||||
if (type === 'credential') {
|
if (type === 'credential') {
|
||||||
params.credentialId = id;
|
params.credentialId = id;
|
||||||
} else {
|
} else {
|
||||||
params.workflowId = id;
|
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);
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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),
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
298
packages/cli/src/services/__tests__/role-cache.service.test.ts
Normal file
298
packages/cli/src/services/__tests__/role-cache.service.test.ts
Normal 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,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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,
|
||||||
|
|||||||
115
packages/cli/src/services/role-cache.service.ts
Normal file
115
packages/cli/src/services/role-cache.service.ts
Normal 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,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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: {
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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
|
||||||
*/
|
*/
|
||||||
|
|||||||
Reference in New Issue
Block a user