From 9141e2a11d180bba80dd4a20a3c86c2a609a9535 Mon Sep 17 00:00:00 2001 From: Jaakko Husso Date: Tue, 27 May 2025 16:44:55 +0300 Subject: [PATCH] feat(core): Add endpoint for querying credentials used in workflows (#15691) (no-changelog) --- .../cli/src/controllers/folder.controller.ts | 23 ++++ .../src/workflows/workflow-finder.service.ts | 29 ++++- .../cli/src/workflows/workflow.service.ee.ts | 24 ++++ .../folder/folder.controller.test.ts | 107 ++++++++++++++++++ 4 files changed, 180 insertions(+), 3 deletions(-) diff --git a/packages/cli/src/controllers/folder.controller.ts b/packages/cli/src/controllers/folder.controller.ts index f5ac40fd96..ae0f1febae 100644 --- a/packages/cli/src/controllers/folder.controller.ts +++ b/packages/cli/src/controllers/folder.controller.ts @@ -72,6 +72,29 @@ export class ProjectController { } } + @Get('/:folderId/credentials') + @ProjectScope('folder:read') + async getFolderUsedCredentials( + req: AuthenticatedRequest<{ projectId: string; folderId: string }>, + _res: Response, + ) { + const { projectId, folderId } = req.params; + + try { + const credentials = await this.enterpriseWorkflowService.getFolderUsedCredentials( + req.user, + folderId, + projectId, + ); + return credentials; + } catch (e) { + if (e instanceof FolderNotFoundError) { + throw new NotFoundError(e.message); + } + throw new InternalServerError(undefined, e); + } + } + @Patch('/:folderId') @ProjectScope('folder:update') async updateFolder( diff --git a/packages/cli/src/workflows/workflow-finder.service.ts b/packages/cli/src/workflows/workflow-finder.service.ts index b350ebe5de..15ccc97c85 100644 --- a/packages/cli/src/workflows/workflow-finder.service.ts +++ b/packages/cli/src/workflows/workflow-finder.service.ts @@ -1,5 +1,5 @@ import type { SharedWorkflow, User } from '@n8n/db'; -import { SharedWorkflowRepository } from '@n8n/db'; +import { SharedWorkflowRepository, FolderRepository } from '@n8n/db'; import { Service } from '@n8n/di'; import { hasGlobalScope, rolesWithScope, type Scope } from '@n8n/permissions'; // eslint-disable-next-line n8n-local-rules/misplaced-n8n-typeorm-import @@ -9,7 +9,10 @@ import { In } from '@n8n/typeorm'; @Service() export class WorkflowFinderService { - constructor(private readonly sharedWorkflowRepository: SharedWorkflowRepository) {} + constructor( + private readonly sharedWorkflowRepository: SharedWorkflowRepository, + private readonly folderRepository: FolderRepository, + ) {} async findWorkflowForUser( workflowId: string, @@ -52,9 +55,28 @@ export class WorkflowFinderService { return sharedWorkflow.workflow; } - async findAllWorkflowsForUser(user: User, scopes: Scope[]) { + async findAllWorkflowsForUser( + user: User, + scopes: Scope[], + folderId?: string, + projectId?: string, + ) { let where: FindOptionsWhere = {}; + if (folderId) { + const subFolderIds = await this.folderRepository.getAllFolderIdsInHierarchy( + folderId, + projectId, + ); + + where = { + ...where, + workflow: { + parentFolder: In([folderId, ...subFolderIds]), + }, + }; + } + if (!hasGlobalScope(user, scopes, { mode: 'allOf' })) { const projectRoles = rolesWithScope('project', scopes); const workflowRoles = rolesWithScope('workflow', scopes); @@ -63,6 +85,7 @@ export class WorkflowFinderService { ...where, role: In(workflowRoles), project: { + ...(projectId && { id: projectId }), projectRelations: { role: In(projectRoles), userId: user.id, diff --git a/packages/cli/src/workflows/workflow.service.ee.ts b/packages/cli/src/workflows/workflow.service.ee.ts index 4728b8ef3b..924cf1e465 100644 --- a/packages/cli/src/workflows/workflow.service.ee.ts +++ b/packages/cli/src/workflows/workflow.service.ee.ts @@ -1,5 +1,6 @@ import type { CredentialsEntity, + CredentialUsedByWorkflow, User, WorkflowEntity, WorkflowWithSharingsAndCredentials, @@ -350,6 +351,29 @@ export class EnterpriseWorkflowService { return; } + async getFolderUsedCredentials(user: User, folderId: string, projectId: string) { + await this.folderService.findFolderInProjectOrFail(folderId, projectId); + + const workflows = await this.workflowFinderService.findAllWorkflowsForUser( + user, + ['workflow:read'], + folderId, + projectId, + ); + + const usedCredentials = new Map(); + + for (const workflow of workflows) { + const workflowWithMetaData = this.addOwnerAndSharings(workflow as unknown as WorkflowEntity); + await this.addCredentialsToWorkflow(workflowWithMetaData, user); + for (const credential of workflowWithMetaData?.usedCredentials ?? []) { + usedCredentials.set(credential.id, credential); + } + } + + return [...usedCredentials.values()]; + } + async transferFolder( user: User, sourceProjectId: string, diff --git a/packages/cli/test/integration/folder/folder.controller.test.ts b/packages/cli/test/integration/folder/folder.controller.test.ts index 5b913266f3..6f7f851c1d 100644 --- a/packages/cli/test/integration/folder/folder.controller.test.ts +++ b/packages/cli/test/integration/folder/folder.controller.test.ts @@ -1,3 +1,4 @@ +import { faker } from '@faker-js/faker'; import type { Project, ProjectRole } from '@n8n/db'; import type { User } from '@n8n/db'; import { FolderRepository } from '@n8n/db'; @@ -10,6 +11,7 @@ import { ApplicationError, PROJECT_ROOT } from 'n8n-workflow'; import { ActiveWorkflowManager } from '@/active-workflow-manager'; import { mockInstance } from '@test/mocking'; import { + createCredentials, getCredentialSharings, saveCredential, shareCredentialWithProjects, @@ -306,6 +308,111 @@ describe('GET /projects/:projectId/folders/:folderId/tree', () => { }); }); +describe('GET /projects/:projectId/folders/:folderId/credentials', () => { + test('should not get folder credentials when project does not exist', async () => { + await authOwnerAgent + .get('/projects/non-existing-id/folders/some-folder-id/credentials') + .expect(403); + }); + + test('should not get folder credentials when folder does not exist', async () => { + const project = await createTeamProject('test project', owner); + + await authOwnerAgent + .get(`/projects/${project.id}/folders/non-existing-folder/credentials`) + .expect(404); + }); + + test('should not get folder credentials if user has no access to project', async () => { + const project = await createTeamProject('test project', owner); + const folder = await createFolder(project); + + await authMemberAgent + .get(`/projects/${project.id}/folders/${folder.id}/credentials`) + .expect(403); + }); + + test("should not allow getting folder credentials from another user's personal project", async () => { + const ownerPersonalProject = await projectRepository.getPersonalProjectForUserOrFail(owner.id); + const folder = await createFolder(ownerPersonalProject); + + await authMemberAgent + .get(`/projects/${ownerPersonalProject.id}/folders/${folder.id}/credentials`) + .expect(403); + }); + + test('should get all used credentials from workflows within the folder and subfolders', async () => { + const project = await createTeamProject('test', owner); + const rootFolder = await createFolder(project, { name: 'Root' }); + + const childFolder1 = await createFolder(project, { + name: 'Child 1', + parentFolder: rootFolder, + }); + + await createFolder(project, { + name: 'Child 2', + parentFolder: rootFolder, + }); + + const grandchildFolder = await createFolder(project, { + name: 'Grandchild', + parentFolder: childFolder1, + }); + + for (const folder of [rootFolder, childFolder1, grandchildFolder]) { + const credential = await createCredentials( + { + name: `Test credential ${folder.name}`, + data: '', + type: 'test', + }, + project, + ); + + await createWorkflow( + { + name: 'Test Workflow', + parentFolder: folder, + active: false, + nodes: [ + { + parameters: {}, + type: '@n8n/n8n-nodes-langchain.lmChatOpenAi', + typeVersion: 1.2, + position: [0, 0], + id: faker.string.uuid(), + name: 'OpenAI Chat Model', + credentials: { + openAiApi: { + id: credential.id, + name: credential.name, + }, + }, + }, + ], + }, + owner, + ); + } + + const response = await authOwnerAgent + .get(`/projects/${project.id}/folders/${childFolder1.id}/credentials`) + .expect(200); + + expect(response.body.data).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + name: expect.stringContaining('Test credential Child 1'), + }), + expect.objectContaining({ + name: expect.stringContaining('Test credential Grandchild'), + }), + ]), + ); + }); +}); + describe('PATCH /projects/:projectId/folders/:folderId', () => { test('should not update folder when project does not exist', async () => { const payload = {