allow filtering workflows by credential ids

This commit is contained in:
Danny Martini 2024-10-30 11:08:34 +01:00
parent 33a4873bc7
commit ee1b50de3e
No known key found for this signature in database
6 changed files with 155 additions and 9 deletions

View file

@ -4,6 +4,7 @@ import {
Repository,
In,
Like,
Or,
type UpdateResult,
type FindOptionsWhere,
type FindOptionsSelect,
@ -12,6 +13,8 @@ import {
} from '@n8n/typeorm';
import { Service } from 'typedi';
import * as a from 'assert/strict';
import config from '@/config';
import type { ListQuery } from '@/requests';
import { isStringArray } from '@/utils';
@ -95,7 +98,11 @@ export class WorkflowRepository extends Repository<WorkflowEntity> {
.execute();
}
async getMany(sharedWorkflowIds: string[], options?: ListQuery.Options) {
async getMany(
sharedWorkflowIds: string[],
options: ListQuery.Options = {},
credentialIds: string[] = [],
) {
if (sharedWorkflowIds.length === 0) return { workflows: [], count: 0 };
if (typeof options?.filter?.projectId === 'string' && options.filter.projectId !== '') {
@ -147,8 +154,12 @@ export class WorkflowRepository extends Repository<WorkflowEntity> {
where.name = Like(`%${where.name}%`);
}
if (credentialIds.length) {
where.nodes = Or(...credentialIds.map((id) => Like(`%{"id":"${id}"%`)));
}
const findManyOptions: FindManyOptions<WorkflowEntity> = {
select: { ...select, id: true },
select: { ...select, id: true, nodes: true },
where,
};
@ -165,11 +176,43 @@ export class WorkflowRepository extends Repository<WorkflowEntity> {
findManyOptions.take = options.take;
}
const [workflows, count] = (await this.findAndCount(findManyOptions)) as [
let [workflows, count] = (await this.findAndCount(findManyOptions)) as [
ListQuery.Workflow.Plain[] | ListQuery.Workflow.WithSharing[],
number,
];
function workflowUsesCredential(
workflow: ListQuery.Workflow.Plain,
credentialIds: string[],
): boolean {
a.ok(workflow.nodes);
return (
workflow.nodes.findIndex((node) => {
if (node.credentials) {
return (
Object.values(node.credentials).findIndex((credential) => {
a.ok(credential.id);
return credentialIds.includes(credential.id);
}) !== -1
);
} else {
return false;
}
}) !== -1
);
}
if (credentialIds.length) {
workflows = workflows.filter((wf) => workflowUsesCredential(wf, credentialIds));
count = workflows.length;
}
for (const wf of workflows) {
delete wf.nodes;
}
return { workflows, count };
}

View file

@ -81,7 +81,14 @@ export namespace ListQuery {
* Slim workflow returned from a list query operation.
*/
export namespace Workflow {
type OptionalBaseFields = 'name' | 'active' | 'versionId' | 'createdAt' | 'updatedAt' | 'tags';
type OptionalBaseFields =
| 'name'
| 'active'
| 'versionId'
| 'createdAt'
| 'updatedAt'
| 'tags'
| 'nodes';
type BaseFields = Pick<WorkflowEntity, 'id'> &
Partial<Pick<WorkflowEntity, OptionalBaseFields>>;

View file

@ -28,7 +28,12 @@ export declare namespace WorkflowRequest {
type Get = AuthenticatedRequest<{ workflowId: string }>;
type GetMany = AuthenticatedRequest<{}, {}, {}, ListQuery.Params & { includeScopes?: string }> & {
type GetMany = AuthenticatedRequest<
{},
{},
{},
ListQuery.Params & { includeScopes?: string } & { credentialIds?: string }
> & {
listQueryOptions: ListQuery.Options;
};

View file

@ -57,13 +57,22 @@ export class WorkflowService {
private readonly eventService: EventService,
) {}
async getMany(user: User, options?: ListQuery.Options, includeScopes?: boolean) {
async getMany(
user: User,
options?: ListQuery.Options,
includeScopes = false,
credentialIds: string[] = [],
) {
const sharedWorkflowIds = await this.workflowSharingService.getSharedWorkflowIds(user, {
scopes: ['workflow:read'],
});
// eslint-disable-next-line prefer-const
let { workflows, count } = await this.workflowRepository.getMany(sharedWorkflowIds, options);
let { workflows, count } = await this.workflowRepository.getMany(
sharedWorkflowIds,
options,
credentialIds,
);
if (hasSharing(workflows)) {
workflows = workflows.map((w) => this.ownershipService.addOwnedByAndSharedWith(w));
@ -75,8 +84,8 @@ export class WorkflowService {
}
workflows.forEach((w) => {
// @ts-expect-error: This is to emulate the old behaviour of removing the shared
// field as part of `addOwnedByAndSharedWith`. We need this field in `addScopes`
// This is to emulate the old behavior of removing the shared field as
// part of `addOwnedByAndSharedWith`. We need this field in `addScopes`
// though. So to avoid leaking the information we just delete it.
delete w.shared;
});

View file

@ -198,6 +198,7 @@ export class WorkflowsController {
req.user,
req.listQueryOptions,
!!req.query.includeScopes,
req.query.credentialIds?.split(','),
);
res.json({ count, data });

View file

@ -468,6 +468,87 @@ describe('GET /workflows', () => {
expect(found.usedCredentials).toBeUndefined();
});
test('should return workflows filtered by used credentials', async () => {
// ARRANGE
const credential1 = await saveCredential(randomCredentialPayload(), {
user: owner,
role: 'credential:owner',
});
const node1: INode = {
id: uuid(),
name: 'Action Network',
type: 'n8n-nodes-base.actionNetwork',
parameters: {},
typeVersion: 1,
position: [0, 0],
credentials: {
actionNetworkApi: {
id: credential1.id,
name: credential1.name,
},
},
};
const workflow1 = await createWorkflow({ name: 'First', nodes: [node1] }, owner);
const credential2 = await saveCredential(randomCredentialPayload(), {
user: owner,
role: 'credential:owner',
});
const node2: INode = {
id: uuid(),
name: 'Action Network',
type: 'n8n-nodes-base.actionNetwork',
parameters: {},
typeVersion: 1,
position: [0, 0],
credentials: {
actionNetworkApi: {
id: credential2.id,
name: credential2.name,
},
},
};
await createWorkflow({ name: 'Second', nodes: [node2] }, owner);
await createWorkflow({ name: 'Third' }, owner);
// ACT
const response = await authOwnerAgent
.get('/workflows')
.query(`credentialIds=${credential1.id}`)
.expect(200);
// ASSERT
const ownerPersonalProject = await projectRepository.getPersonalProjectForUserOrFail(owner.id);
expect(response.body.count).toBe(1);
expect(response.body.data).toHaveLength(1);
expect(response.body.data[0]).toEqual(
objectContaining({
id: workflow1.id,
name: 'First',
active: workflow1.active,
tags: [],
createdAt: workflow1.createdAt.toISOString(),
updatedAt: workflow1.updatedAt.toISOString(),
versionId: workflow1.versionId,
homeProject: {
id: ownerPersonalProject.id,
name: owner.createPersonalProjectName(),
type: ownerPersonalProject.type,
},
sharedWithProjects: [],
}),
);
const found = response.body.data.find(
(w: ListQuery.Workflow.WithOwnership) => w.name === 'First',
);
expect(found.nodes).toBeUndefined();
expect(found.sharedWithProjects).toHaveLength(0);
expect(found.usedCredentials).toBeUndefined();
});
test('should return workflows with scopes when ?includeScopes=true', async () => {
const [member1, member2] = await createManyUsers(2, {
role: 'global:member',