refactor(core): Decouple RoleService from repositories (#14944)

This commit is contained in:
Iván Ovejero
2025-04-28 13:06:34 +02:00
committed by GitHub
parent a767ce3d8e
commit b7c5521942
27 changed files with 421 additions and 315 deletions

View File

@@ -8,11 +8,11 @@ import nock from 'nock';
import { Time } from '@/constants'; import { Time } from '@/constants';
import { OAuth1CredentialController } from '@/controllers/oauth/oauth1-credential.controller'; import { OAuth1CredentialController } from '@/controllers/oauth/oauth1-credential.controller';
import { CredentialsFinderService } from '@/credentials/credentials-finder.service';
import { CredentialsHelper } from '@/credentials-helper'; import { CredentialsHelper } from '@/credentials-helper';
import type { CredentialsEntity } from '@/databases/entities/credentials-entity'; import type { CredentialsEntity } from '@/databases/entities/credentials-entity';
import type { User } from '@/databases/entities/user'; import type { User } from '@/databases/entities/user';
import { CredentialsRepository } from '@/databases/repositories/credentials.repository'; import { CredentialsRepository } from '@/databases/repositories/credentials.repository';
import { SharedCredentialsRepository } from '@/databases/repositories/shared-credentials.repository';
import { VariablesService } from '@/environments.ee/variables/variables.service.ee'; import { VariablesService } from '@/environments.ee/variables/variables.service.ee';
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';
@@ -38,7 +38,7 @@ describe('OAuth1CredentialController', () => {
Container.set(Cipher, cipher); Container.set(Cipher, cipher);
const credentialsHelper = mockInstance(CredentialsHelper); const credentialsHelper = mockInstance(CredentialsHelper);
const credentialsRepository = mockInstance(CredentialsRepository); const credentialsRepository = mockInstance(CredentialsRepository);
const sharedCredentialsRepository = mockInstance(SharedCredentialsRepository); const credentialsFinderService = mockInstance(CredentialsFinderService);
const csrfSecret = 'csrf-secret'; const csrfSecret = 'csrf-secret';
const user = mock<User>({ const user = mock<User>({
@@ -73,7 +73,7 @@ describe('OAuth1CredentialController', () => {
}); });
it('should throw a NotFoundError when no matching credential is found for the user', async () => { it('should throw a NotFoundError when no matching credential is found for the user', async () => {
sharedCredentialsRepository.findCredentialForUser.mockResolvedValueOnce(null); credentialsFinderService.findCredentialForUser.mockResolvedValueOnce(null);
const req = mock<OAuthRequest.OAuth1Credential.Auth>({ user, query: { id: '1' } }); const req = mock<OAuthRequest.OAuth1Credential.Auth>({ user, query: { id: '1' } });
await expect(controller.getAuthUri(req)).rejects.toThrowError( await expect(controller.getAuthUri(req)).rejects.toThrowError(
@@ -84,7 +84,7 @@ describe('OAuth1CredentialController', () => {
it('should return a valid auth URI', async () => { it('should return a valid auth URI', async () => {
jest.spyOn(Csrf.prototype, 'secretSync').mockReturnValueOnce(csrfSecret); jest.spyOn(Csrf.prototype, 'secretSync').mockReturnValueOnce(csrfSecret);
jest.spyOn(Csrf.prototype, 'create').mockReturnValueOnce('token'); jest.spyOn(Csrf.prototype, 'create').mockReturnValueOnce('token');
sharedCredentialsRepository.findCredentialForUser.mockResolvedValueOnce(credential); credentialsFinderService.findCredentialForUser.mockResolvedValueOnce(credential);
credentialsHelper.getDecrypted.mockResolvedValueOnce({}); credentialsHelper.getDecrypted.mockResolvedValueOnce({});
credentialsHelper.applyDefaultsAndOverwrites.mockResolvedValueOnce({ credentialsHelper.applyDefaultsAndOverwrites.mockResolvedValueOnce({
requestTokenUrl: 'https://example.domain/oauth/request_token', requestTokenUrl: 'https://example.domain/oauth/request_token',

View File

@@ -8,11 +8,11 @@ import nock from 'nock';
import { CREDENTIAL_BLANKING_VALUE, Time } from '@/constants'; import { CREDENTIAL_BLANKING_VALUE, Time } from '@/constants';
import { OAuth2CredentialController } from '@/controllers/oauth/oauth2-credential.controller'; import { OAuth2CredentialController } from '@/controllers/oauth/oauth2-credential.controller';
import { CredentialsFinderService } from '@/credentials/credentials-finder.service';
import { CredentialsHelper } from '@/credentials-helper'; import { CredentialsHelper } from '@/credentials-helper';
import type { CredentialsEntity } from '@/databases/entities/credentials-entity'; import type { CredentialsEntity } from '@/databases/entities/credentials-entity';
import type { User } from '@/databases/entities/user'; import type { User } from '@/databases/entities/user';
import { CredentialsRepository } from '@/databases/repositories/credentials.repository'; import { CredentialsRepository } from '@/databases/repositories/credentials.repository';
import { SharedCredentialsRepository } from '@/databases/repositories/shared-credentials.repository';
import { VariablesService } from '@/environments.ee/variables/variables.service.ee'; import { VariablesService } from '@/environments.ee/variables/variables.service.ee';
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';
@@ -39,7 +39,7 @@ describe('OAuth2CredentialController', () => {
const externalHooks = mockInstance(ExternalHooks); const externalHooks = mockInstance(ExternalHooks);
const credentialsHelper = mockInstance(CredentialsHelper); const credentialsHelper = mockInstance(CredentialsHelper);
const credentialsRepository = mockInstance(CredentialsRepository); const credentialsRepository = mockInstance(CredentialsRepository);
const sharedCredentialsRepository = mockInstance(SharedCredentialsRepository); const credentialsFinderService = mockInstance(CredentialsFinderService);
const csrfSecret = 'csrf-secret'; const csrfSecret = 'csrf-secret';
const user = mock<User>({ const user = mock<User>({
@@ -81,7 +81,7 @@ describe('OAuth2CredentialController', () => {
}); });
it('should throw a NotFoundError when no matching credential is found for the user', async () => { it('should throw a NotFoundError when no matching credential is found for the user', async () => {
sharedCredentialsRepository.findCredentialForUser.mockResolvedValueOnce(null); credentialsFinderService.findCredentialForUser.mockResolvedValueOnce(null);
const req = mock<OAuthRequest.OAuth2Credential.Auth>({ user, query: { id: '1' } }); const req = mock<OAuthRequest.OAuth2Credential.Auth>({ user, query: { id: '1' } });
await expect(controller.getAuthUri(req)).rejects.toThrowError( await expect(controller.getAuthUri(req)).rejects.toThrowError(
@@ -92,7 +92,7 @@ describe('OAuth2CredentialController', () => {
it('should return a valid auth URI', async () => { it('should return a valid auth URI', async () => {
jest.spyOn(Csrf.prototype, 'secretSync').mockReturnValueOnce(csrfSecret); jest.spyOn(Csrf.prototype, 'secretSync').mockReturnValueOnce(csrfSecret);
jest.spyOn(Csrf.prototype, 'create').mockReturnValueOnce('token'); jest.spyOn(Csrf.prototype, 'create').mockReturnValueOnce('token');
sharedCredentialsRepository.findCredentialForUser.mockResolvedValueOnce(credential); credentialsFinderService.findCredentialForUser.mockResolvedValueOnce(credential);
credentialsHelper.getDecrypted.mockResolvedValueOnce({}); credentialsHelper.getDecrypted.mockResolvedValueOnce({});
const req = mock<OAuthRequest.OAuth2Credential.Auth>({ user, query: { id: '1' } }); const req = mock<OAuthRequest.OAuth2Credential.Auth>({ user, query: { id: '1' } });

View File

@@ -7,10 +7,10 @@ import type { ICredentialDataDecryptedObject, IWorkflowExecuteAdditionalData } f
import { jsonParse, UnexpectedError } from 'n8n-workflow'; import { jsonParse, UnexpectedError } from 'n8n-workflow';
import { RESPONSE_ERROR_MESSAGES, Time } from '@/constants'; import { RESPONSE_ERROR_MESSAGES, Time } from '@/constants';
import { CredentialsFinderService } from '@/credentials/credentials-finder.service';
import { CredentialsHelper } from '@/credentials-helper'; import { CredentialsHelper } from '@/credentials-helper';
import type { CredentialsEntity } from '@/databases/entities/credentials-entity'; import type { CredentialsEntity } from '@/databases/entities/credentials-entity';
import { CredentialsRepository } from '@/databases/repositories/credentials.repository'; import { CredentialsRepository } from '@/databases/repositories/credentials.repository';
import { SharedCredentialsRepository } from '@/databases/repositories/shared-credentials.repository';
import { AuthError } from '@/errors/response-errors/auth.error'; import { AuthError } from '@/errors/response-errors/auth.error';
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';
@@ -46,7 +46,7 @@ export abstract class AbstractOAuthController {
protected readonly externalHooks: ExternalHooks, protected readonly externalHooks: ExternalHooks,
private readonly credentialsHelper: CredentialsHelper, private readonly credentialsHelper: CredentialsHelper,
private readonly credentialsRepository: CredentialsRepository, private readonly credentialsRepository: CredentialsRepository,
private readonly sharedCredentialsRepository: SharedCredentialsRepository, private readonly credentialsFinderService: CredentialsFinderService,
private readonly urlService: UrlService, private readonly urlService: UrlService,
private readonly globalConfig: GlobalConfig, private readonly globalConfig: GlobalConfig,
) {} ) {}
@@ -65,7 +65,7 @@ export abstract class AbstractOAuthController {
throw new BadRequestError('Required credential ID is missing'); throw new BadRequestError('Required credential ID is missing');
} }
const credential = await this.sharedCredentialsRepository.findCredentialForUser( const credential = await this.credentialsFinderService.findCredentialForUser(
credentialId, credentialId,
req.user, req.user,
['credential:read'], ['credential:read'],

View File

@@ -4,10 +4,10 @@ import { Logger } from 'n8n-core';
import type { WorkflowStatistics } from '@/databases/entities/workflow-statistics'; import type { WorkflowStatistics } from '@/databases/entities/workflow-statistics';
import { StatisticsNames } from '@/databases/entities/workflow-statistics'; import { StatisticsNames } from '@/databases/entities/workflow-statistics';
import { SharedWorkflowRepository } from '@/databases/repositories/shared-workflow.repository';
import { WorkflowStatisticsRepository } from '@/databases/repositories/workflow-statistics.repository'; import { WorkflowStatisticsRepository } from '@/databases/repositories/workflow-statistics.repository';
import { NotFoundError } from '@/errors/response-errors/not-found.error'; import { NotFoundError } from '@/errors/response-errors/not-found.error';
import type { IWorkflowStatisticsDataLoaded } from '@/interfaces'; import type { IWorkflowStatisticsDataLoaded } from '@/interfaces';
import { WorkflowFinderService } from '@/workflows/workflow-finder.service';
import { StatisticsRequest } from './workflow-statistics.types'; import { StatisticsRequest } from './workflow-statistics.types';
@@ -21,7 +21,7 @@ interface WorkflowStatisticsData<T> {
@RestController('/workflow-stats') @RestController('/workflow-stats')
export class WorkflowStatisticsController { export class WorkflowStatisticsController {
constructor( constructor(
private readonly sharedWorkflowRepository: SharedWorkflowRepository, private readonly workflowFinderService: WorkflowFinderService,
private readonly workflowStatisticsRepository: WorkflowStatisticsRepository, private readonly workflowStatisticsRepository: WorkflowStatisticsRepository,
private readonly logger: Logger, private readonly logger: Logger,
) {} ) {}
@@ -35,7 +35,7 @@ export class WorkflowStatisticsController {
const { user } = req; const { user } = req;
const workflowId = req.params.id; const workflowId = req.params.id;
const workflow = await this.sharedWorkflowRepository.findWorkflowForUser(workflowId, user, [ const workflow = await this.workflowFinderService.findWorkflowForUser(workflowId, user, [
'workflow:read', 'workflow:read',
]); ]);

View File

@@ -25,6 +25,7 @@ describe('CredentialsController', () => {
sharedCredentialsRepository, sharedCredentialsRepository,
mock(), mock(),
eventService, eventService,
mock(),
); );
let req: AuthenticatedRequest; let req: AuthenticatedRequest;

View File

@@ -43,6 +43,7 @@ describe('CredentialsService', () => {
mock(), mock(),
mock(), mock(),
mock(), mock(),
mock(),
); );
beforeEach(() => jest.resetAllMocks()); beforeEach(() => jest.resetAllMocks());

View File

@@ -0,0 +1,140 @@
import type { ProjectRole } from '@n8n/api-types';
import { Service } from '@n8n/di';
import type { Scope } from '@n8n/permissions';
// eslint-disable-next-line n8n-local-rules/misplaced-n8n-typeorm-import
import type { EntityManager, FindOptionsWhere } from '@n8n/typeorm';
// eslint-disable-next-line n8n-local-rules/misplaced-n8n-typeorm-import
import { In } from '@n8n/typeorm';
import type { CredentialsEntity } from '@/databases/entities/credentials-entity';
import type {
SharedCredentials,
CredentialSharingRole,
} from '@/databases/entities/shared-credentials';
import type { User } from '@/databases/entities/user';
import { CredentialsRepository } from '@/databases/repositories/credentials.repository';
import { SharedCredentialsRepository } from '@/databases/repositories/shared-credentials.repository';
import { RoleService } from '@/services/role.service';
@Service()
export class CredentialsFinderService {
constructor(
private readonly sharedCredentialsRepository: SharedCredentialsRepository,
private readonly roleService: RoleService,
private readonly credentialsRepository: CredentialsRepository,
) {}
/**
* Find all credentials that the user has access to taking the scopes into
* account.
*
* This also returns `credentials.shared` which is useful for constructing
* all scopes the user has for the credential using `RoleService.addScopes`.
**/
async findCredentialsForUser(user: User, scopes: Scope[]) {
let where: FindOptionsWhere<CredentialsEntity> = {};
if (!user.hasGlobalScope(scopes, { mode: 'allOf' })) {
const projectRoles = this.roleService.rolesWithScope('project', scopes);
const credentialRoles = this.roleService.rolesWithScope('credential', scopes);
where = {
...where,
shared: {
role: In(credentialRoles),
project: {
projectRelations: {
role: In(projectRoles),
userId: user.id,
},
},
},
};
}
return await this.credentialsRepository.find({ where, relations: { shared: true } });
}
/** Get a credential if it has been shared with a user */
async findCredentialForUser(credentialsId: string, user: User, scopes: Scope[]) {
let where: FindOptionsWhere<SharedCredentials> = { credentialsId };
if (!user.hasGlobalScope(scopes, { mode: 'allOf' })) {
const projectRoles = this.roleService.rolesWithScope('project', scopes);
const credentialRoles = this.roleService.rolesWithScope('credential', scopes);
where = {
...where,
role: In(credentialRoles),
project: {
projectRelations: {
role: In(projectRoles),
userId: user.id,
},
},
};
}
const sharedCredential = await this.sharedCredentialsRepository.findOne({
where,
// TODO: write a small relations merger and use that one here
relations: {
credentials: {
shared: { project: { projectRelations: { user: true } } },
},
},
});
if (!sharedCredential) return null;
return sharedCredential.credentials;
}
/** Get all credentials shared to a user */
async findAllCredentialsForUser(user: User, scopes: Scope[], trx?: EntityManager) {
let where: FindOptionsWhere<SharedCredentials> = {};
if (!user.hasGlobalScope(scopes, { mode: 'allOf' })) {
const projectRoles = this.roleService.rolesWithScope('project', scopes);
const credentialRoles = this.roleService.rolesWithScope('credential', scopes);
where = {
role: In(credentialRoles),
project: {
projectRelations: {
role: In(projectRoles),
userId: user.id,
},
},
};
}
const sharedCredential = await this.sharedCredentialsRepository.findCredentialsWithOptions(
where,
trx,
);
return sharedCredential.map((sc) => ({ ...sc.credentials, projectId: sc.projectId }));
}
async getCredentialIdsByUserAndRole(
userIds: string[],
options:
| { scopes: Scope[] }
| { projectRoles: ProjectRole[]; credentialRoles: CredentialSharingRole[] },
trx?: EntityManager,
) {
const projectRoles =
'scopes' in options
? this.roleService.rolesWithScope('project', options.scopes)
: options.projectRoles;
const credentialRoles =
'scopes' in options
? this.roleService.rolesWithScope('credential', options.scopes)
: options.credentialRoles;
const sharings = await this.sharedCredentialsRepository.findCredentialsByRoles(
userIds,
projectRoles,
credentialRoles,
trx,
);
return sharings.map((s) => s.credentialsId);
}
}

View File

@@ -40,6 +40,7 @@ import { NamingService } from '@/services/naming.service';
import { UserManagementMailer } from '@/user-management/email'; import { UserManagementMailer } from '@/user-management/email';
import * as utils from '@/utils'; import * as utils from '@/utils';
import { CredentialsFinderService } from './credentials-finder.service';
import { CredentialsService } from './credentials.service'; import { CredentialsService } from './credentials.service';
import { EnterpriseCredentialsService } from './credentials.service.ee'; import { EnterpriseCredentialsService } from './credentials.service.ee';
@@ -56,6 +57,7 @@ export class CredentialsController {
private readonly sharedCredentialsRepository: SharedCredentialsRepository, private readonly sharedCredentialsRepository: SharedCredentialsRepository,
private readonly projectRelationRepository: ProjectRelationRepository, private readonly projectRelationRepository: ProjectRelationRepository,
private readonly eventService: EventService, private readonly eventService: EventService,
private readonly credentialsFinderService: CredentialsFinderService,
) {} ) {}
@Get('/', { middlewares: listQueryMiddleware }) @Get('/', { middlewares: listQueryMiddleware })
@@ -131,7 +133,7 @@ export class CredentialsController {
async testCredentials(req: CredentialRequest.Test) { async testCredentials(req: CredentialRequest.Test) {
const { credentials } = req.body; const { credentials } = req.body;
const storedCredential = await this.sharedCredentialsRepository.findCredentialForUser( const storedCredential = await this.credentialsFinderService.findCredentialForUser(
credentials.id, credentials.id,
req.user, req.user,
['credential:read'], ['credential:read'],
@@ -201,7 +203,7 @@ export class CredentialsController {
params: { credentialId }, params: { credentialId },
} = req; } = req;
const credential = await this.sharedCredentialsRepository.findCredentialForUser( const credential = await this.credentialsFinderService.findCredentialForUser(
credentialId, credentialId,
user, user,
['credential:update'], ['credential:update'],
@@ -262,7 +264,7 @@ export class CredentialsController {
async deleteCredentials(req: CredentialRequest.Delete) { async deleteCredentials(req: CredentialRequest.Delete) {
const { credentialId } = req.params; const { credentialId } = req.params;
const credential = await this.sharedCredentialsRepository.findCredentialForUser( const credential = await this.credentialsFinderService.findCredentialForUser(
credentialId, credentialId,
req.user, req.user,
['credential:delete'], ['credential:delete'],
@@ -303,7 +305,7 @@ export class CredentialsController {
throw new BadRequestError('Bad request'); throw new BadRequestError('Bad request');
} }
const credential = await this.sharedCredentialsRepository.findCredentialForUser( const credential = await this.credentialsFinderService.findCredentialForUser(
credentialId, credentialId,
req.user, req.user,
['credential:share'], ['credential:share'],

View File

@@ -14,6 +14,7 @@ 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 { RoleService } from '@/services/role.service';
import { CredentialsFinderService } from './credentials-finder.service';
import { CredentialsService } from './credentials.service'; import { CredentialsService } from './credentials.service';
@Service() @Service()
@@ -24,6 +25,7 @@ export class EnterpriseCredentialsService {
private readonly credentialsService: CredentialsService, private readonly credentialsService: CredentialsService,
private readonly projectService: ProjectService, private readonly projectService: ProjectService,
private readonly roleService: RoleService, private readonly roleService: RoleService,
private readonly credentialsFinderService: CredentialsFinderService,
) {} ) {}
async shareWithProjects( async shareWithProjects(
@@ -83,7 +85,7 @@ export class EnterpriseCredentialsService {
credential = includeDecryptedData credential = includeDecryptedData
? // Try to get the credential with `credential:update` scope, which ? // Try to get the credential with `credential:update` scope, which
// are required for decrypting the data. // are required for decrypting the data.
await this.sharedCredentialsRepository.findCredentialForUser( await this.credentialsFinderService.findCredentialForUser(
credentialId, credentialId,
user, user,
// TODO: replace credential:update with credential:decrypt once it lands // TODO: replace credential:update with credential:decrypt once it lands
@@ -99,11 +101,9 @@ export class EnterpriseCredentialsService {
} else { } else {
// Otherwise try to find them with only the `credential:read` scope. In // Otherwise try to find them with only the `credential:read` scope. In
// that case we return them without the decrypted data. // that case we return them without the decrypted data.
credential = await this.sharedCredentialsRepository.findCredentialForUser( credential = await this.credentialsFinderService.findCredentialForUser(credentialId, user, [
credentialId, 'credential:read',
user, ]);
['credential:read'],
);
} }
if (!credential) { if (!credential) {
@@ -130,7 +130,7 @@ export class EnterpriseCredentialsService {
async transferOne(user: User, credentialId: string, destinationProjectId: string) { async transferOne(user: User, credentialId: string, destinationProjectId: string) {
// 1. get credential // 1. get credential
const credential = await this.sharedCredentialsRepository.findCredentialForUser( const credential = await this.credentialsFinderService.findCredentialForUser(
credentialId, credentialId,
user, user,
['credential:move'], ['credential:move'],

View File

@@ -41,6 +41,8 @@ import { ProjectService } from '@/services/project.service.ee';
import type { ScopesField } from '@/services/role.service'; import type { ScopesField } from '@/services/role.service';
import { RoleService } from '@/services/role.service'; import { RoleService } from '@/services/role.service';
import { CredentialsFinderService } from './credentials-finder.service';
export type CredentialsGetSharedOptions = export type CredentialsGetSharedOptions =
| { allowGlobalScope: true; globalScope: Scope } | { allowGlobalScope: true; globalScope: Scope }
| { allowGlobalScope: false }; | { allowGlobalScope: false };
@@ -64,6 +66,7 @@ export class CredentialsService {
private readonly projectService: ProjectService, private readonly projectService: ProjectService,
private readonly roleService: RoleService, private readonly roleService: RoleService,
private readonly userRepository: UserRepository, private readonly userRepository: UserRepository,
private readonly credentialsFinderService: CredentialsFinderService,
) {} ) {}
async getMany( async getMany(
@@ -145,7 +148,7 @@ export class CredentialsService {
} }
} }
const ids = await this.sharedCredentialsRepository.getCredentialIdsByUserAndRole([user.id], { const ids = await this.credentialsFinderService.getCredentialIdsByUserAndRole([user.id], {
scopes: ['credential:read'], scopes: ['credential:read'],
}); });
@@ -203,7 +206,7 @@ export class CredentialsService {
const projectRelations = await this.projectService.getProjectRelationsForUser(user); const projectRelations = await this.projectService.getProjectRelationsForUser(user);
// get all credentials the user has access to // get all credentials the user has access to
const allCredentials = await this.credentialsRepository.findCredentialsForUser(user, [ const allCredentials = await this.credentialsFinderService.findCredentialsForUser(user, [
'credential:read', 'credential:read',
]); ]);
@@ -434,7 +437,7 @@ export class CredentialsService {
async delete(user: User, credentialId: string) { async delete(user: User, credentialId: string) {
await this.externalHooks.run('credentials.delete', [credentialId]); await this.externalHooks.run('credentials.delete', [credentialId]);
const credential = await this.sharedCredentialsRepository.findCredentialForUser( const credential = await this.credentialsFinderService.findCredentialForUser(
credentialId, credentialId,
user, user,
['credential:delete'], ['credential:delete'],

View File

@@ -1,20 +1,14 @@
import { Service } from '@n8n/di'; import { Service } from '@n8n/di';
import type { Scope } from '@n8n/permissions';
import { DataSource, In, Repository, Like } from '@n8n/typeorm'; import { DataSource, In, Repository, Like } from '@n8n/typeorm';
import type { FindManyOptions, FindOptionsWhere } from '@n8n/typeorm'; import type { FindManyOptions } from '@n8n/typeorm';
import type { ListQuery } from '@/requests'; import type { ListQuery } from '@/requests';
import { RoleService } from '@/services/role.service';
import { CredentialsEntity } from '../entities/credentials-entity'; import { CredentialsEntity } from '../entities/credentials-entity';
import type { User } from '../entities/user';
@Service() @Service()
export class CredentialsRepository extends Repository<CredentialsEntity> { export class CredentialsRepository extends Repository<CredentialsEntity> {
constructor( constructor(dataSource: DataSource) {
dataSource: DataSource,
readonly roleService: RoleService,
) {
super(CredentialsEntity, dataSource.manager); super(CredentialsEntity, dataSource.manager);
} }
@@ -131,34 +125,4 @@ export class CredentialsRepository extends Repository<CredentialsEntity> {
async findAllCredentialsForProject(projectId: string): Promise<CredentialsEntity[]> { async findAllCredentialsForProject(projectId: string): Promise<CredentialsEntity[]> {
return await this.findBy({ shared: { projectId } }); return await this.findBy({ shared: { projectId } });
} }
/**
* Find all credentials that the user has access to taking the scopes into
* account.
*
* This also returns `credentials.shared` which is useful for constructing
* all scopes the user has for the credential using `RoleService.addScopes`.
**/
async findCredentialsForUser(user: User, scopes: Scope[]) {
let where: FindOptionsWhere<CredentialsEntity> = {};
if (!user.hasGlobalScope(scopes, { mode: 'allOf' })) {
const projectRoles = this.roleService.rolesWithScope('project', scopes);
const credentialRoles = this.roleService.rolesWithScope('credential', scopes);
where = {
...where,
shared: {
role: In(credentialRoles),
project: {
projectRelations: {
role: In(projectRoles),
userId: user.id,
},
},
},
};
}
return await this.find({ where, relations: { shared: true } });
}
} }

View File

@@ -1,94 +1,17 @@
import type { ProjectRole } from '@n8n/api-types'; import type { ProjectRole } from '@n8n/api-types';
import { Service } from '@n8n/di'; import { Service } from '@n8n/di';
import type { Scope } from '@n8n/permissions'; import type { EntityManager, FindOptionsWhere } from '@n8n/typeorm';
import type { EntityManager, FindOptionsRelations, FindOptionsWhere } from '@n8n/typeorm';
import { DataSource, In, Not, Repository } from '@n8n/typeorm'; import { DataSource, In, Not, Repository } from '@n8n/typeorm';
import { RoleService } from '@/services/role.service';
import type { Project } from '../entities/project'; import type { Project } from '../entities/project';
import { type CredentialSharingRole, SharedCredentials } from '../entities/shared-credentials'; import { type CredentialSharingRole, SharedCredentials } from '../entities/shared-credentials';
import type { User } from '../entities/user';
@Service() @Service()
export class SharedCredentialsRepository extends Repository<SharedCredentials> { export class SharedCredentialsRepository extends Repository<SharedCredentials> {
constructor( constructor(dataSource: DataSource) {
dataSource: DataSource,
private readonly roleService: RoleService,
) {
super(SharedCredentials, dataSource.manager); super(SharedCredentials, dataSource.manager);
} }
/** Get a credential if it has been shared with a user */
async findCredentialForUser(
credentialsId: string,
user: User,
scopes: Scope[],
_relations?: FindOptionsRelations<SharedCredentials>,
) {
let where: FindOptionsWhere<SharedCredentials> = { credentialsId };
if (!user.hasGlobalScope(scopes, { mode: 'allOf' })) {
const projectRoles = this.roleService.rolesWithScope('project', scopes);
const credentialRoles = this.roleService.rolesWithScope('credential', scopes);
where = {
...where,
role: In(credentialRoles),
project: {
projectRelations: {
role: In(projectRoles),
userId: user.id,
},
},
};
}
const sharedCredential = await this.findOne({
where,
// TODO: write a small relations merger and use that one here
relations: {
credentials: {
shared: { project: { projectRelations: { user: true } } },
},
},
});
if (!sharedCredential) return null;
return sharedCredential.credentials;
}
/** Get all credentials shared to a user */
async findAllCredentialsForUser(user: User, scopes: Scope[], trx?: EntityManager) {
trx = trx ?? this.manager;
let where: FindOptionsWhere<SharedCredentials> = {};
if (!user.hasGlobalScope(scopes, { mode: 'allOf' })) {
const projectRoles = this.roleService.rolesWithScope('project', scopes);
const credentialRoles = this.roleService.rolesWithScope('credential', scopes);
where = {
role: In(credentialRoles),
project: {
projectRelations: {
role: In(projectRoles),
userId: user.id,
},
},
};
}
const sharedCredential = await trx.find(SharedCredentials, {
where,
// TODO: write a small relations merger and use that one here
relations: {
credentials: {
shared: { project: { projectRelations: { user: true } } },
},
},
});
return sharedCredential.map((sc) => ({ ...sc.credentials, projectId: sc.projectId }));
}
async findByCredentialIds(credentialIds: string[], role: CredentialSharingRole) { async findByCredentialIds(credentialIds: string[], role: CredentialSharingRole) {
return await this.find({ return await this.find({
relations: { credentials: true, project: { projectRelations: { user: true } } }, relations: { credentials: true, project: { projectRelations: { user: true } } },
@@ -125,39 +48,6 @@ export class SharedCredentialsRepository extends Repository<SharedCredentials> {
); );
} }
async getCredentialIdsByUserAndRole(
userIds: string[],
options:
| { scopes: Scope[] }
| { projectRoles: ProjectRole[]; credentialRoles: CredentialSharingRole[] },
trx?: EntityManager,
) {
trx = trx ?? this.manager;
const projectRoles =
'scopes' in options
? this.roleService.rolesWithScope('project', options.scopes)
: options.projectRoles;
const credentialRoles =
'scopes' in options
? this.roleService.rolesWithScope('credential', options.scopes)
: options.credentialRoles;
const sharings = await trx.find(SharedCredentials, {
where: {
role: In(credentialRoles),
project: {
projectRelations: {
userId: In(userIds),
role: In(projectRoles),
},
},
},
});
return sharings.map((s) => s.credentialsId);
}
async deleteByIds(sharedCredentialsIds: string[], projectId: string, trx?: EntityManager) { async deleteByIds(sharedCredentialsIds: string[], projectId: string, trx?: EntityManager) {
trx = trx ?? this.manager; trx = trx ?? this.manager;
@@ -199,4 +89,41 @@ export class SharedCredentialsRepository extends Repository<SharedCredentials> {
relations: ['project'], relations: ['project'],
}); });
} }
async findCredentialsWithOptions(
where: FindOptionsWhere<SharedCredentials> = {},
trx?: EntityManager,
) {
trx = trx ?? this.manager;
return await trx.find(SharedCredentials, {
where,
relations: {
credentials: {
shared: { project: { projectRelations: { user: true } } },
},
},
});
}
async findCredentialsByRoles(
userIds: string[],
projectRoles: ProjectRole[],
credentialRoles: CredentialSharingRole[],
trx?: EntityManager,
) {
trx = trx ?? this.manager;
return await trx.find(SharedCredentials, {
where: {
role: In(credentialRoles),
project: {
projectRelations: {
userId: In(userIds),
role: In(projectRoles),
},
},
},
});
}
} }

View File

@@ -1,20 +1,13 @@
import { Service } from '@n8n/di'; import { Service } from '@n8n/di';
import type { Scope } from '@n8n/permissions';
import { DataSource, Repository, In, Not } from '@n8n/typeorm'; import { DataSource, Repository, In, Not } from '@n8n/typeorm';
import type { EntityManager, FindManyOptions, FindOptionsWhere } from '@n8n/typeorm'; import type { EntityManager, FindManyOptions, FindOptionsWhere } from '@n8n/typeorm';
import { RoleService } from '@/services/role.service';
import type { Project } from '../entities/project'; import type { Project } from '../entities/project';
import { SharedWorkflow, type WorkflowSharingRole } from '../entities/shared-workflow'; import { SharedWorkflow, type WorkflowSharingRole } from '../entities/shared-workflow';
import { type User } from '../entities/user';
@Service() @Service()
export class SharedWorkflowRepository extends Repository<SharedWorkflow> { export class SharedWorkflowRepository extends Repository<SharedWorkflow> {
constructor( constructor(dataSource: DataSource) {
dataSource: DataSource,
private roleService: RoleService,
) {
super(SharedWorkflow, dataSource.manager); super(SharedWorkflow, dataSource.manager);
} }
@@ -108,79 +101,6 @@ export class SharedWorkflowRepository extends Repository<SharedWorkflow> {
}); });
} }
async findWorkflowForUser(
workflowId: string,
user: User,
scopes: Scope[],
{ includeTags = false, includeParentFolder = false, em = this.manager } = {},
) {
let where: FindOptionsWhere<SharedWorkflow> = { workflowId };
if (!user.hasGlobalScope(scopes, { mode: 'allOf' })) {
const projectRoles = this.roleService.rolesWithScope('project', scopes);
const workflowRoles = this.roleService.rolesWithScope('workflow', scopes);
where = {
...where,
role: In(workflowRoles),
project: {
projectRelations: {
role: In(projectRoles),
userId: user.id,
},
},
};
}
const sharedWorkflow = await em.findOne(SharedWorkflow, {
where,
relations: {
workflow: {
shared: { project: { projectRelations: { user: true } } },
tags: includeTags,
parentFolder: includeParentFolder,
},
},
});
if (!sharedWorkflow) {
return null;
}
return sharedWorkflow.workflow;
}
async findAllWorkflowsForUser(user: User, scopes: Scope[]) {
let where: FindOptionsWhere<SharedWorkflow> = {};
if (!user.hasGlobalScope(scopes, { mode: 'allOf' })) {
const projectRoles = this.roleService.rolesWithScope('project', scopes);
const workflowRoles = this.roleService.rolesWithScope('workflow', scopes);
where = {
...where,
role: In(workflowRoles),
project: {
projectRelations: {
role: In(projectRoles),
userId: user.id,
},
},
};
}
const sharedWorkflows = await this.find({
where,
relations: {
workflow: {
shared: { project: { projectRelations: { user: true } } },
},
},
});
return sharedWorkflows.map((sw) => ({ ...sw.workflow, projectId: sw.projectId }));
}
/** /**
* Find the IDs of all the projects where a workflow is accessible. * Find the IDs of all the projects where a workflow is accessible.
*/ */
@@ -221,4 +141,35 @@ export class SharedWorkflowRepository extends Repository<SharedWorkflow> {
relations: ['project'], relations: ['project'],
}); });
} }
async findWorkflowWithOptions(
workflowId: string,
options: {
where?: FindOptionsWhere<SharedWorkflow>;
includeTags?: boolean;
includeParentFolder?: boolean;
em?: EntityManager;
} = {},
) {
const {
where = {},
includeTags = false,
includeParentFolder = false,
em = this.manager,
} = options;
return await em.findOne(SharedWorkflow, {
where: {
workflowId,
...where,
},
relations: {
workflow: {
shared: { project: { projectRelations: { user: true } } },
tags: includeTags,
parentFolder: includeParentFolder,
},
},
});
}
} }

View File

@@ -11,12 +11,12 @@ import { z } from 'zod';
import { ActiveWorkflowManager } from '@/active-workflow-manager'; import { ActiveWorkflowManager } from '@/active-workflow-manager';
import { WorkflowEntity } from '@/databases/entities/workflow-entity'; import { WorkflowEntity } from '@/databases/entities/workflow-entity';
import { ProjectRepository } from '@/databases/repositories/project.repository'; import { ProjectRepository } from '@/databases/repositories/project.repository';
import { SharedWorkflowRepository } from '@/databases/repositories/shared-workflow.repository';
import { TagRepository } from '@/databases/repositories/tag.repository'; import { TagRepository } from '@/databases/repositories/tag.repository';
import { WorkflowRepository } from '@/databases/repositories/workflow.repository'; import { WorkflowRepository } from '@/databases/repositories/workflow.repository';
import { EventService } from '@/events/event.service'; import { EventService } from '@/events/event.service';
import { ExternalHooks } from '@/external-hooks'; import { ExternalHooks } from '@/external-hooks';
import { addNodeIds, replaceInvalidCredentials } from '@/workflow-helpers'; import { addNodeIds, replaceInvalidCredentials } from '@/workflow-helpers';
import { WorkflowFinderService } from '@/workflows/workflow-finder.service';
import { WorkflowHistoryService } from '@/workflows/workflow-history.ee/workflow-history.service.ee'; import { WorkflowHistoryService } from '@/workflows/workflow-history.ee/workflow-history.service.ee';
import { WorkflowService } from '@/workflows/workflow.service'; import { WorkflowService } from '@/workflows/workflow.service';
import { EnterpriseWorkflowService } from '@/workflows/workflow.service.ee'; import { EnterpriseWorkflowService } from '@/workflows/workflow.service.ee';
@@ -115,7 +115,7 @@ export = {
const { id } = req.params; const { id } = req.params;
const { excludePinnedData = false } = req.query; const { excludePinnedData = false } = req.query;
const workflow = await Container.get(SharedWorkflowRepository).findWorkflowForUser( const workflow = await Container.get(WorkflowFinderService).findWorkflowForUser(
id, id,
req.user, req.user,
['workflow:read'], ['workflow:read'],
@@ -169,7 +169,7 @@ export = {
} }
if (projectId) { if (projectId) {
const workflows = await Container.get(SharedWorkflowRepository).findAllWorkflowsForUser( const workflows = await Container.get(WorkflowFinderService).findAllWorkflowsForUser(
req.user, req.user,
['workflow:read'], ['workflow:read'],
); );
@@ -189,7 +189,7 @@ export = {
); );
} }
let workflows = await Container.get(SharedWorkflowRepository).findAllWorkflowsForUser( let workflows = await Container.get(WorkflowFinderService).findAllWorkflowsForUser(
req.user, req.user,
['workflow:read'], ['workflow:read'],
); );
@@ -252,7 +252,7 @@ export = {
updateData.id = id; updateData.id = id;
updateData.versionId = uuid(); updateData.versionId = uuid();
const workflow = await Container.get(SharedWorkflowRepository).findWorkflowForUser( const workflow = await Container.get(WorkflowFinderService).findWorkflowForUser(
id, id,
req.user, req.user,
['workflow:update'], ['workflow:update'],
@@ -319,7 +319,7 @@ export = {
async (req: WorkflowRequest.Activate, res: express.Response): Promise<express.Response> => { async (req: WorkflowRequest.Activate, res: express.Response): Promise<express.Response> => {
const { id } = req.params; const { id } = req.params;
const workflow = await Container.get(SharedWorkflowRepository).findWorkflowForUser( const workflow = await Container.get(WorkflowFinderService).findWorkflowForUser(
id, id,
req.user, req.user,
['workflow:update'], ['workflow:update'],
@@ -358,7 +358,7 @@ export = {
async (req: WorkflowRequest.Activate, res: express.Response): Promise<express.Response> => { async (req: WorkflowRequest.Activate, res: express.Response): Promise<express.Response> => {
const { id } = req.params; const { id } = req.params;
const workflow = await Container.get(SharedWorkflowRepository).findWorkflowForUser( const workflow = await Container.get(WorkflowFinderService).findWorkflowForUser(
id, id,
req.user, req.user,
['workflow:update'], ['workflow:update'],
@@ -396,7 +396,7 @@ export = {
return res.status(400).json({ message: 'Workflow Tags Disabled' }); return res.status(400).json({ message: 'Workflow Tags Disabled' });
} }
const workflow = await Container.get(SharedWorkflowRepository).findWorkflowForUser( const workflow = await Container.get(WorkflowFinderService).findWorkflowForUser(
id, id,
req.user, req.user,
['workflow:read'], ['workflow:read'],
@@ -424,7 +424,7 @@ export = {
return res.status(400).json({ message: 'Workflow Tags Disabled' }); return res.status(400).json({ message: 'Workflow Tags Disabled' });
} }
const sharedWorkflow = await Container.get(SharedWorkflowRepository).findWorkflowForUser( const sharedWorkflow = await Container.get(WorkflowFinderService).findWorkflowForUser(
id, id,
req.user, req.user,
['workflow:update'], ['workflow:update'],

View File

@@ -7,17 +7,20 @@ import type { SharedWorkflowRepository } from '@/databases/repositories/shared-w
import type { WorkflowRepository } from '@/databases/repositories/workflow.repository'; import type { WorkflowRepository } from '@/databases/repositories/workflow.repository';
import { BadRequestError } from '@/errors/response-errors/bad-request.error'; import { BadRequestError } from '@/errors/response-errors/bad-request.error';
import { ActiveWorkflowsService } from '@/services/active-workflows.service'; import { ActiveWorkflowsService } from '@/services/active-workflows.service';
import type { WorkflowFinderService } from '@/workflows/workflow-finder.service';
describe('ActiveWorkflowsService', () => { describe('ActiveWorkflowsService', () => {
const user = mock<User>(); const user = mock<User>();
const workflowRepository = mock<WorkflowRepository>(); const workflowRepository = mock<WorkflowRepository>();
const sharedWorkflowRepository = mock<SharedWorkflowRepository>(); const sharedWorkflowRepository = mock<SharedWorkflowRepository>();
const workflowFinderService = mock<WorkflowFinderService>();
const activationErrorsService = mock<ActivationErrorsService>(); const activationErrorsService = mock<ActivationErrorsService>();
const service = new ActiveWorkflowsService( const service = new ActiveWorkflowsService(
mock(), mock(),
workflowRepository, workflowRepository,
sharedWorkflowRepository, sharedWorkflowRepository,
activationErrorsService, activationErrorsService,
workflowFinderService,
); );
const activeIds = ['1', '2', '3', '4']; const activeIds = ['1', '2', '3', '4'];
@@ -63,22 +66,22 @@ describe('ActiveWorkflowsService', () => {
const workflowId = 'workflowId'; const workflowId = 'workflowId';
it('should throw a BadRequestError a user does not have access to the workflow id', async () => { it('should throw a BadRequestError a user does not have access to the workflow id', async () => {
sharedWorkflowRepository.findWorkflowForUser.mockResolvedValue(null); workflowFinderService.findWorkflowForUser.mockResolvedValue(null);
await expect(service.getActivationError(workflowId, user)).rejects.toThrow(BadRequestError); await expect(service.getActivationError(workflowId, user)).rejects.toThrow(BadRequestError);
expect(sharedWorkflowRepository.findWorkflowForUser).toHaveBeenCalledWith(workflowId, user, [ expect(workflowFinderService.findWorkflowForUser).toHaveBeenCalledWith(workflowId, user, [
'workflow:read', 'workflow:read',
]); ]);
expect(activationErrorsService.get).not.toHaveBeenCalled(); expect(activationErrorsService.get).not.toHaveBeenCalled();
}); });
it('should return the error when the user has access', async () => { it('should return the error when the user has access', async () => {
sharedWorkflowRepository.findWorkflowForUser.mockResolvedValue(new WorkflowEntity()); workflowFinderService.findWorkflowForUser.mockResolvedValue(new WorkflowEntity());
activationErrorsService.get.mockResolvedValue('some-error'); activationErrorsService.get.mockResolvedValue('some-error');
const error = await service.getActivationError(workflowId, user); const error = await service.getActivationError(workflowId, user);
expect(error).toEqual('some-error'); expect(error).toEqual('some-error');
expect(sharedWorkflowRepository.findWorkflowForUser).toHaveBeenCalledWith(workflowId, user, [ expect(workflowFinderService.findWorkflowForUser).toHaveBeenCalledWith(workflowId, user, [
'workflow:read', 'workflow:read',
]); ]);
expect(activationErrorsService.get).toHaveBeenCalledWith(workflowId); expect(activationErrorsService.get).toHaveBeenCalledWith(workflowId);

View File

@@ -3,16 +3,16 @@ import { hasScope } from '@n8n/permissions';
import { In } from '@n8n/typeorm'; import { In } from '@n8n/typeorm';
import { mock } from 'jest-mock-extended'; import { mock } from 'jest-mock-extended';
import { CredentialsFinderService } from '@/credentials/credentials-finder.service';
import type { CredentialsEntity } from '@/databases/entities/credentials-entity'; import type { CredentialsEntity } from '@/databases/entities/credentials-entity';
import { SharedCredentials } from '@/databases/entities/shared-credentials'; import { SharedCredentials } from '@/databases/entities/shared-credentials';
import type { User } from '@/databases/entities/user'; import type { User } from '@/databases/entities/user';
import { SharedCredentialsRepository } from '@/databases/repositories/shared-credentials.repository';
import { GLOBAL_MEMBER_SCOPES, GLOBAL_OWNER_SCOPES } from '@/permissions.ee/global-roles'; import { GLOBAL_MEMBER_SCOPES, GLOBAL_OWNER_SCOPES } from '@/permissions.ee/global-roles';
import { mockEntityManager } from '@test/mocking'; import { mockEntityManager } from '@test/mocking';
describe('SharedCredentialsRepository', () => { describe('CredentialsFinderService', () => {
const entityManager = mockEntityManager(SharedCredentials); const entityManager = mockEntityManager(SharedCredentials);
const repository = Container.get(SharedCredentialsRepository); const credentialsFinderService = Container.get(CredentialsFinderService);
describe('findCredentialForUser', () => { describe('findCredentialForUser', () => {
const credentialsId = 'cred_123'; const credentialsId = 'cred_123';
@@ -40,9 +40,11 @@ describe('SharedCredentialsRepository', () => {
test('should allow instance owner access to all credentials', async () => { test('should allow instance owner access to all credentials', async () => {
entityManager.findOne.mockResolvedValueOnce(sharedCredential); entityManager.findOne.mockResolvedValueOnce(sharedCredential);
const credential = await repository.findCredentialForUser(credentialsId, owner, [ const credential = await credentialsFinderService.findCredentialForUser(
'credential:read', credentialsId,
]); owner,
['credential:read'],
);
expect(entityManager.findOne).toHaveBeenCalledWith(SharedCredentials, { expect(entityManager.findOne).toHaveBeenCalledWith(SharedCredentials, {
relations: { credentials: { shared: { project: { projectRelations: { user: true } } } } }, relations: { credentials: { shared: { project: { projectRelations: { user: true } } } } },
where: { credentialsId }, where: { credentialsId },
@@ -52,9 +54,11 @@ describe('SharedCredentialsRepository', () => {
test('should allow members', async () => { test('should allow members', async () => {
entityManager.findOne.mockResolvedValueOnce(sharedCredential); entityManager.findOne.mockResolvedValueOnce(sharedCredential);
const credential = await repository.findCredentialForUser(credentialsId, member, [ const credential = await credentialsFinderService.findCredentialForUser(
'credential:read', credentialsId,
]); member,
['credential:read'],
);
expect(entityManager.findOne).toHaveBeenCalledWith(SharedCredentials, { expect(entityManager.findOne).toHaveBeenCalledWith(SharedCredentials, {
relations: { credentials: { shared: { project: { projectRelations: { user: true } } } } }, relations: { credentials: { shared: { project: { projectRelations: { user: true } } } } },
where: { where: {
@@ -78,9 +82,11 @@ describe('SharedCredentialsRepository', () => {
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); entityManager.findOne.mockResolvedValueOnce(null);
const credential = await repository.findCredentialForUser(credentialsId, member, [ const credential = await credentialsFinderService.findCredentialForUser(
'credential:read', credentialsId,
]); member,
['credential:read'],
);
expect(entityManager.findOne).toHaveBeenCalledWith(SharedCredentials, { expect(entityManager.findOne).toHaveBeenCalledWith(SharedCredentials, {
relations: { credentials: { shared: { project: { projectRelations: { user: true } } } } }, relations: { credentials: { shared: { project: { projectRelations: { user: true } } } } },
where: { where: {

View File

@@ -2,8 +2,8 @@ import { Service } from '@n8n/di';
import type { Workflow } from 'n8n-workflow'; import type { Workflow } from 'n8n-workflow';
import type { User } from '@/databases/entities/user'; import type { User } from '@/databases/entities/user';
import { SharedWorkflowRepository } from '@/databases/repositories/shared-workflow.repository';
import { UserRepository } from '@/databases/repositories/user.repository'; import { UserRepository } from '@/databases/repositories/user.repository';
import { WorkflowFinderService } from '@/workflows/workflow-finder.service';
/** /**
* Responsible for checking whether a user has access to a resource. * Responsible for checking whether a user has access to a resource.
@@ -12,7 +12,7 @@ import { UserRepository } from '@/databases/repositories/user.repository';
export class AccessService { export class AccessService {
constructor( constructor(
private readonly userRepository: UserRepository, private readonly userRepository: UserRepository,
private readonly sharedWorkflowRepository: SharedWorkflowRepository, private readonly workflowFinderService: WorkflowFinderService,
) {} ) {}
/** Whether a user has read access to a workflow based on their project and scope. */ /** Whether a user has read access to a workflow based on their project and scope. */
@@ -21,7 +21,7 @@ export class AccessService {
if (!user) return false; if (!user) return false;
const workflow = await this.sharedWorkflowRepository.findWorkflowForUser(workflowId, user, [ const workflow = await this.workflowFinderService.findWorkflowForUser(workflowId, user, [
'workflow:read', 'workflow:read',
]); ]);

View File

@@ -6,6 +6,7 @@ import type { User } from '@/databases/entities/user';
import { SharedWorkflowRepository } from '@/databases/repositories/shared-workflow.repository'; import { SharedWorkflowRepository } from '@/databases/repositories/shared-workflow.repository';
import { WorkflowRepository } from '@/databases/repositories/workflow.repository'; import { WorkflowRepository } from '@/databases/repositories/workflow.repository';
import { BadRequestError } from '@/errors/response-errors/bad-request.error'; import { BadRequestError } from '@/errors/response-errors/bad-request.error';
import { WorkflowFinderService } from '@/workflows/workflow-finder.service';
@Service() @Service()
export class ActiveWorkflowsService { export class ActiveWorkflowsService {
@@ -14,6 +15,7 @@ export class ActiveWorkflowsService {
private readonly workflowRepository: WorkflowRepository, private readonly workflowRepository: WorkflowRepository,
private readonly sharedWorkflowRepository: SharedWorkflowRepository, private readonly sharedWorkflowRepository: SharedWorkflowRepository,
private readonly activationErrorsService: ActivationErrorsService, private readonly activationErrorsService: ActivationErrorsService,
private readonly workflowFinderService: WorkflowFinderService,
) {} ) {}
async getAllActiveIdsInStorage() { async getAllActiveIdsInStorage() {
@@ -37,7 +39,7 @@ export class ActiveWorkflowsService {
} }
async getActivationError(workflowId: string, user: User) { async getActivationError(workflowId: string, user: User) {
const workflow = await this.sharedWorkflowRepository.findWorkflowForUser(workflowId, user, [ const workflow = await this.workflowFinderService.findWorkflowForUser(workflowId, user, [
'workflow:read', 'workflow:read',
]); ]);
if (!workflow) { if (!workflow) {

View File

@@ -0,0 +1,91 @@
import { Service } from '@n8n/di';
import type { Scope } from '@n8n/permissions';
// eslint-disable-next-line n8n-local-rules/misplaced-n8n-typeorm-import
import type { EntityManager, FindOptionsWhere } from '@n8n/typeorm';
// eslint-disable-next-line n8n-local-rules/misplaced-n8n-typeorm-import
import { In } from '@n8n/typeorm';
import type { SharedWorkflow } from '@/databases/entities/shared-workflow';
import type { User } from '@/databases/entities/user';
import { SharedWorkflowRepository } from '@/databases/repositories/shared-workflow.repository';
import { RoleService } from '@/services/role.service';
@Service()
export class WorkflowFinderService {
constructor(
private readonly sharedWorkflowRepository: SharedWorkflowRepository,
private readonly roleService: RoleService,
) {}
async findWorkflowForUser(
workflowId: string,
user: User,
scopes: Scope[],
options: {
includeTags?: boolean;
includeParentFolder?: boolean;
em?: EntityManager;
} = {},
) {
let where: FindOptionsWhere<SharedWorkflow> = {};
if (!user.hasGlobalScope(scopes, { mode: 'allOf' })) {
const projectRoles = this.roleService.rolesWithScope('project', scopes);
const workflowRoles = this.roleService.rolesWithScope('workflow', scopes);
where = {
role: In(workflowRoles),
project: {
projectRelations: {
role: In(projectRoles),
userId: user.id,
},
},
};
}
const sharedWorkflow = await this.sharedWorkflowRepository.findWorkflowWithOptions(workflowId, {
where,
includeTags: options.includeTags,
includeParentFolder: options.includeParentFolder,
em: options.em,
});
if (!sharedWorkflow) {
return null;
}
return sharedWorkflow.workflow;
}
async findAllWorkflowsForUser(user: User, scopes: Scope[]) {
let where: FindOptionsWhere<SharedWorkflow> = {};
if (!user.hasGlobalScope(scopes, { mode: 'allOf' })) {
const projectRoles = this.roleService.rolesWithScope('project', scopes);
const workflowRoles = this.roleService.rolesWithScope('workflow', scopes);
where = {
...where,
role: In(workflowRoles),
project: {
projectRelations: {
role: In(projectRoles),
userId: user.id,
},
},
};
}
const sharedWorkflows = await this.sharedWorkflowRepository.find({
where,
relations: {
workflow: {
shared: { project: { projectRelations: { user: true } } },
},
},
});
return sharedWorkflows.map((sw) => ({ ...sw.workflow, projectId: sw.projectId }));
}
}

View File

@@ -1,19 +1,19 @@
import { mockClear } from 'jest-mock-extended'; import { mockClear } from 'jest-mock-extended';
import { User } from '@/databases/entities/user'; import { User } from '@/databases/entities/user';
import { SharedWorkflowRepository } from '@/databases/repositories/shared-workflow.repository';
import { WorkflowHistoryRepository } from '@/databases/repositories/workflow-history.repository'; import { WorkflowHistoryRepository } from '@/databases/repositories/workflow-history.repository';
import { WorkflowFinderService } from '@/workflows/workflow-finder.service';
import { WorkflowHistoryService } from '@/workflows/workflow-history.ee/workflow-history.service.ee'; import { WorkflowHistoryService } from '@/workflows/workflow-history.ee/workflow-history.service.ee';
import { mockInstance, mockLogger } from '@test/mocking'; import { mockInstance, mockLogger } from '@test/mocking';
import { getWorkflow } from '@test-integration/workflow'; import { getWorkflow } from '@test-integration/workflow';
const workflowHistoryRepository = mockInstance(WorkflowHistoryRepository); const workflowHistoryRepository = mockInstance(WorkflowHistoryRepository);
const logger = mockLogger(); const logger = mockLogger();
const sharedWorkflowRepository = mockInstance(SharedWorkflowRepository); const workflowFinderService = mockInstance(WorkflowFinderService);
const workflowHistoryService = new WorkflowHistoryService( const workflowHistoryService = new WorkflowHistoryService(
logger, logger,
workflowHistoryRepository, workflowHistoryRepository,
sharedWorkflowRepository, workflowFinderService,
); );
const testUser = Object.assign(new User(), { const testUser = Object.assign(new User(), {
id: '1234', id: '1234',

View File

@@ -5,19 +5,19 @@ import { ensureError } from 'n8n-workflow';
import type { User } from '@/databases/entities/user'; import type { User } from '@/databases/entities/user';
import type { WorkflowHistory } from '@/databases/entities/workflow-history'; import type { WorkflowHistory } from '@/databases/entities/workflow-history';
import { SharedWorkflowRepository } from '@/databases/repositories/shared-workflow.repository';
import { WorkflowHistoryRepository } from '@/databases/repositories/workflow-history.repository'; import { WorkflowHistoryRepository } from '@/databases/repositories/workflow-history.repository';
import { SharedWorkflowNotFoundError } from '@/errors/shared-workflow-not-found.error'; import { SharedWorkflowNotFoundError } from '@/errors/shared-workflow-not-found.error';
import { WorkflowHistoryVersionNotFoundError } from '@/errors/workflow-history-version-not-found.error'; import { WorkflowHistoryVersionNotFoundError } from '@/errors/workflow-history-version-not-found.error';
import { isWorkflowHistoryEnabled } from './workflow-history-helper.ee'; import { isWorkflowHistoryEnabled } from './workflow-history-helper.ee';
import { WorkflowFinderService } from '../workflow-finder.service';
@Service() @Service()
export class WorkflowHistoryService { export class WorkflowHistoryService {
constructor( constructor(
private readonly logger: Logger, private readonly logger: Logger,
private readonly workflowHistoryRepository: WorkflowHistoryRepository, private readonly workflowHistoryRepository: WorkflowHistoryRepository,
private readonly sharedWorkflowRepository: SharedWorkflowRepository, private readonly workflowFinderService: WorkflowFinderService,
) {} ) {}
async getList( async getList(
@@ -26,7 +26,7 @@ export class WorkflowHistoryService {
take: number, take: number,
skip: number, skip: number,
): Promise<Array<Omit<WorkflowHistory, 'nodes' | 'connections'>>> { ): Promise<Array<Omit<WorkflowHistory, 'nodes' | 'connections'>>> {
const workflow = await this.sharedWorkflowRepository.findWorkflowForUser(workflowId, user, [ const workflow = await this.workflowFinderService.findWorkflowForUser(workflowId, user, [
'workflow:read', 'workflow:read',
]); ]);
@@ -46,7 +46,7 @@ export class WorkflowHistoryService {
} }
async getVersion(user: User, workflowId: string, versionId: string): Promise<WorkflowHistory> { async getVersion(user: User, workflowId: string, versionId: string): Promise<WorkflowHistory> {
const workflow = await this.sharedWorkflowRepository.findWorkflowForUser(workflowId, user, [ const workflow = await this.workflowFinderService.findWorkflowForUser(workflowId, user, [
'workflow:read', 'workflow:read',
]); ]);

View File

@@ -7,6 +7,7 @@ import type { IWorkflowBase, WorkflowId } from 'n8n-workflow';
import { NodeOperationError, UserError, WorkflowActivationError } from 'n8n-workflow'; import { NodeOperationError, UserError, WorkflowActivationError } from 'n8n-workflow';
import { ActiveWorkflowManager } from '@/active-workflow-manager'; import { ActiveWorkflowManager } from '@/active-workflow-manager';
import { CredentialsFinderService } from '@/credentials/credentials-finder.service';
import { CredentialsService } from '@/credentials/credentials.service'; import { CredentialsService } from '@/credentials/credentials.service';
import { EnterpriseCredentialsService } from '@/credentials/credentials.service.ee'; import { EnterpriseCredentialsService } from '@/credentials/credentials.service.ee';
import type { CredentialsEntity } from '@/databases/entities/credentials-entity'; import type { CredentialsEntity } from '@/databases/entities/credentials-entity';
@@ -14,7 +15,6 @@ import { Project } from '@/databases/entities/project';
import { SharedWorkflow } from '@/databases/entities/shared-workflow'; import { SharedWorkflow } from '@/databases/entities/shared-workflow';
import type { User } from '@/databases/entities/user'; import type { User } from '@/databases/entities/user';
import { CredentialsRepository } from '@/databases/repositories/credentials.repository'; import { CredentialsRepository } from '@/databases/repositories/credentials.repository';
import { SharedCredentialsRepository } from '@/databases/repositories/shared-credentials.repository';
import { SharedWorkflowRepository } from '@/databases/repositories/shared-workflow.repository'; import { SharedWorkflowRepository } from '@/databases/repositories/shared-workflow.repository';
import { WorkflowRepository } from '@/databases/repositories/workflow.repository'; import { WorkflowRepository } from '@/databases/repositories/workflow.repository';
import { BadRequestError } from '@/errors/response-errors/bad-request.error'; import { BadRequestError } from '@/errors/response-errors/bad-request.error';
@@ -23,6 +23,7 @@ import { TransferWorkflowError } from '@/errors/response-errors/transfer-workflo
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 { WorkflowFinderService } from './workflow-finder.service';
import type { import type {
WorkflowWithSharingsAndCredentials, WorkflowWithSharingsAndCredentials,
WorkflowWithSharingsMetaDataAndCredentials, WorkflowWithSharingsMetaDataAndCredentials,
@@ -39,8 +40,9 @@ export class EnterpriseWorkflowService {
private readonly ownershipService: OwnershipService, private readonly ownershipService: OwnershipService,
private readonly projectService: ProjectService, private readonly projectService: ProjectService,
private readonly activeWorkflowManager: ActiveWorkflowManager, private readonly activeWorkflowManager: ActiveWorkflowManager,
private readonly sharedCredentialsRepository: SharedCredentialsRepository, private readonly credentialsFinderService: CredentialsFinderService,
private readonly enterpriseCredentialsService: EnterpriseCredentialsService, private readonly enterpriseCredentialsService: EnterpriseCredentialsService,
private readonly workflowFinderService: WorkflowFinderService,
) {} ) {}
async shareWithProjects( async shareWithProjects(
@@ -265,7 +267,7 @@ export class EnterpriseWorkflowService {
shareCredentials: string[] = [], shareCredentials: string[] = [],
) { ) {
// 1. get workflow // 1. get workflow
const workflow = await this.sharedWorkflowRepository.findWorkflowForUser(workflowId, user, [ const workflow = await this.workflowFinderService.findWorkflowForUser(workflowId, user, [
'workflow:move', 'workflow:move',
]); ]);
NotFoundError.isDefinedAndNotNull( NotFoundError.isDefinedAndNotNull(
@@ -324,7 +326,7 @@ export class EnterpriseWorkflowService {
// 8. share credentials into the destination project // 8. share credentials into the destination project
await this.workflowRepository.manager.transaction(async (trx) => { await this.workflowRepository.manager.transaction(async (trx) => {
const allCredentials = await this.sharedCredentialsRepository.findAllCredentialsForUser( const allCredentials = await this.credentialsFinderService.findAllCredentialsForUser(
user, user,
['credential:share'], ['credential:share'],
trx, trx,

View File

@@ -36,6 +36,7 @@ import { RoleService } from '@/services/role.service';
import { TagService } from '@/services/tag.service'; import { TagService } from '@/services/tag.service';
import * as WorkflowHelpers from '@/workflow-helpers'; import * as WorkflowHelpers from '@/workflow-helpers';
import { WorkflowFinderService } from './workflow-finder.service';
import { WorkflowHistoryService } from './workflow-history.ee/workflow-history.service.ee'; import { WorkflowHistoryService } from './workflow-history.ee/workflow-history.service.ee';
import { WorkflowSharingService } from './workflow-sharing.service'; import { WorkflowSharingService } from './workflow-sharing.service';
@@ -60,6 +61,7 @@ export class WorkflowService {
private readonly eventService: EventService, private readonly eventService: EventService,
private readonly globalConfig: GlobalConfig, private readonly globalConfig: GlobalConfig,
private readonly folderService: FolderService, private readonly folderService: FolderService,
private readonly workflowFinderService: WorkflowFinderService,
) {} ) {}
async getMany( async getMany(
@@ -185,7 +187,7 @@ export class WorkflowService {
parentFolderId?: string, parentFolderId?: string,
forceSave?: boolean, forceSave?: boolean,
): Promise<WorkflowEntity> { ): Promise<WorkflowEntity> {
const workflow = await this.sharedWorkflowRepository.findWorkflowForUser(workflowId, user, [ const workflow = await this.workflowFinderService.findWorkflowForUser(workflowId, user, [
'workflow:update', 'workflow:update',
]); ]);
@@ -367,7 +369,7 @@ export class WorkflowService {
async delete(user: User, workflowId: string): Promise<WorkflowEntity | undefined> { async delete(user: User, workflowId: string): Promise<WorkflowEntity | undefined> {
await this.externalHooks.run('workflow.delete', [workflowId]); await this.externalHooks.run('workflow.delete', [workflowId]);
const workflow = await this.sharedWorkflowRepository.findWorkflowForUser(workflowId, user, [ const workflow = await this.workflowFinderService.findWorkflowForUser(workflowId, user, [
'workflow:delete', 'workflow:delete',
]); ]);

View File

@@ -55,6 +55,7 @@ import * as utils from '@/utils';
import * as WorkflowHelpers from '@/workflow-helpers'; import * as WorkflowHelpers from '@/workflow-helpers';
import { WorkflowExecutionService } from './workflow-execution.service'; import { WorkflowExecutionService } from './workflow-execution.service';
import { WorkflowFinderService } from './workflow-finder.service';
import { WorkflowHistoryService } from './workflow-history.ee/workflow-history.service.ee'; import { WorkflowHistoryService } from './workflow-history.ee/workflow-history.service.ee';
import { WorkflowRequest } from './workflow.request'; import { WorkflowRequest } from './workflow.request';
import { WorkflowService } from './workflow.service'; import { WorkflowService } from './workflow.service';
@@ -84,6 +85,7 @@ export class WorkflowsController {
private readonly eventService: EventService, private readonly eventService: EventService,
private readonly globalConfig: GlobalConfig, private readonly globalConfig: GlobalConfig,
private readonly folderService: FolderService, private readonly folderService: FolderService,
private readonly workflowFinderService: WorkflowFinderService,
) {} ) {}
@Post('/') @Post('/')
@@ -176,7 +178,7 @@ export class WorkflowsController {
await transactionManager.save<SharedWorkflow>(newSharedWorkflow); await transactionManager.save<SharedWorkflow>(newSharedWorkflow);
return await this.sharedWorkflowRepository.findWorkflowForUser( return await this.workflowFinderService.findWorkflowForUser(
workflow.id, workflow.id,
req.user, req.user,
['workflow:read'], ['workflow:read'],
@@ -292,7 +294,7 @@ export class WorkflowsController {
relations.tags = true; relations.tags = true;
} }
const workflow = await this.sharedWorkflowRepository.findWorkflowForUser( const workflow = await this.workflowFinderService.findWorkflowForUser(
workflowId, workflowId,
req.user, req.user,
['workflow:read'], ['workflow:read'],
@@ -320,7 +322,7 @@ export class WorkflowsController {
// sharing disabled // sharing disabled
const workflow = await this.sharedWorkflowRepository.findWorkflowForUser( const workflow = await this.workflowFinderService.findWorkflowForUser(
workflowId, workflowId,
req.user, req.user,
['workflow:read'], ['workflow:read'],
@@ -442,7 +444,7 @@ export class WorkflowsController {
throw new BadRequestError('Bad request'); throw new BadRequestError('Bad request');
} }
const workflow = await this.sharedWorkflowRepository.findWorkflowForUser(workflowId, req.user, [ const workflow = await this.workflowFinderService.findWorkflowForUser(workflowId, req.user, [
'workflow:share', 'workflow:share',
]); ]);

View File

@@ -1,9 +1,9 @@
import { Container } from '@n8n/di'; import { Container } from '@n8n/di';
import { CredentialsFinderService } from '@/credentials/credentials-finder.service';
import { CredentialsService } from '@/credentials/credentials.service'; import { CredentialsService } from '@/credentials/credentials.service';
import type { CredentialsEntity } from '@/databases/entities/credentials-entity'; import type { CredentialsEntity } from '@/databases/entities/credentials-entity';
import type { User } from '@/databases/entities/user'; import type { User } from '@/databases/entities/user';
import { SharedCredentialsRepository } from '@/databases/repositories/shared-credentials.repository';
import { createTeamProject, linkUserToProject } from '@test-integration/db/projects'; import { createTeamProject, linkUserToProject } from '@test-integration/db/projects';
import { saveCredential, shareCredentialWithUsers } from '../shared/db/credentials'; import { saveCredential, shareCredentialWithUsers } from '../shared/db/credentials';
@@ -32,9 +32,11 @@ beforeAll(async () => {
describe('credentials service', () => { describe('credentials service', () => {
describe('replaceCredentialContentsForSharee', () => { describe('replaceCredentialContentsForSharee', () => {
it('should replace the contents of the credential for sharee', async () => { it('should replace the contents of the credential for sharee', async () => {
const storedCredential = await Container.get( const storedCredential = await Container.get(CredentialsFinderService).findCredentialForUser(
SharedCredentialsRepository, credential.id,
).findCredentialForUser(credential.id, memberWhoDoesNotOwnCredential, ['credential:read']); memberWhoDoesNotOwnCredential,
['credential:read'],
);
const decryptedData = Container.get(CredentialsService).decrypt(storedCredential!); const decryptedData = Container.get(CredentialsService).decrypt(storedCredential!);
@@ -64,10 +66,12 @@ describe('credentials service', () => {
}); });
const storedProjectCredential = await Container.get( const storedProjectCredential = await Container.get(
SharedCredentialsRepository, CredentialsFinderService,
).findCredentialForUser(projectCredential.id, viewerMember, ['credential:read']); ).findCredentialForUser(projectCredential.id, viewerMember, ['credential:read']);
const decryptedData = Container.get(CredentialsService).decrypt(storedProjectCredential!); if (!storedProjectCredential) throw new Error('Could not find credential');
const decryptedData = Container.get(CredentialsService).decrypt(storedProjectCredential);
const mergedCredentials = { const mergedCredentials = {
id: projectCredential.id, id: projectCredential.id,
@@ -78,7 +82,7 @@ describe('credentials service', () => {
await Container.get(CredentialsService).replaceCredentialContentsForSharee( await Container.get(CredentialsService).replaceCredentialContentsForSharee(
viewerMember, viewerMember,
storedProjectCredential!, storedProjectCredential,
decryptedData, decryptedData,
mergedCredentials, mergedCredentials,
); );
@@ -95,10 +99,12 @@ describe('credentials service', () => {
}); });
const storedProjectCredential = await Container.get( const storedProjectCredential = await Container.get(
SharedCredentialsRepository, CredentialsFinderService,
).findCredentialForUser(projectCredential.id, editorMember, ['credential:read']); ).findCredentialForUser(projectCredential.id, editorMember, ['credential:read']);
const decryptedData = Container.get(CredentialsService).decrypt(storedProjectCredential!); if (!storedProjectCredential) throw new Error('Could not find credential');
const decryptedData = Container.get(CredentialsService).decrypt(storedProjectCredential);
const originalData = { accessToken: '' }; const originalData = { accessToken: '' };
const mergedCredentials = { const mergedCredentials = {
@@ -110,7 +116,7 @@ describe('credentials service', () => {
await Container.get(CredentialsService).replaceCredentialContentsForSharee( await Container.get(CredentialsService).replaceCredentialContentsForSharee(
editorMember, editorMember,
storedProjectCredential!, storedProjectCredential,
decryptedData, decryptedData,
mergedCredentials, mergedCredentials,
); );

View File

@@ -35,6 +35,7 @@ describe('EnterpriseWorkflowService', () => {
mock(), mock(),
mock(), mock(),
mock(), mock(),
mock(),
); );
}); });

View File

@@ -7,6 +7,7 @@ import { WorkflowRepository } from '@/databases/repositories/workflow.repository
import { MessageEventBus } from '@/eventbus/message-event-bus/message-event-bus'; import { MessageEventBus } from '@/eventbus/message-event-bus/message-event-bus';
import { OrchestrationService } from '@/services/orchestration.service'; import { OrchestrationService } from '@/services/orchestration.service';
import { Telemetry } from '@/telemetry'; import { Telemetry } from '@/telemetry';
import { WorkflowFinderService } from '@/workflows/workflow-finder.service';
import { WorkflowService } from '@/workflows/workflow.service'; import { WorkflowService } from '@/workflows/workflow.service';
import { mockInstance } from '../../shared/mocking'; import { mockInstance } from '../../shared/mocking';
@@ -42,6 +43,7 @@ beforeAll(async () => {
mock(), mock(),
mock(), mock(),
mock(), mock(),
Container.get(WorkflowFinderService),
); );
}); });