feat(core): Show a person their own catalog runs

Adds GET /catalog/runs, selected by the marker the run service writes rather
than by workflow, so someone sharing a workflow with colleagues sees only what
they started themselves.

Each entry is projected down from the execution summary. The summary also
carries lastNodeExecuted, per-node statuses and error details, which would hand
node names to someone who was given execute but not read.

Moves the route prefix to /catalog so runs are not addressed as a sub-resource
of workflows.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Yuliia Pominchuk 2026-08-04 20:07:55 +02:00
parent 1c7cde1ae9
commit 32fb2405e5
No known key found for this signature in database
3 changed files with 139 additions and 6 deletions

View File

@ -2,7 +2,13 @@ import type { Logger } from '@n8n/backend-common';
import type { User } from '@n8n/db';
import { mock } from 'vitest-mock-extended';
import { CATALOG_LIST_LIMIT, CatalogService } from '@/workflows/catalog.service';
import type { ExecutionService } from '@/executions/execution.service';
import { CATALOG_RUN_USER_KEY } from '@/workflows/catalog-run.service';
import {
CATALOG_LIST_LIMIT,
CATALOG_RUNS_LIMIT,
CatalogService,
} from '@/workflows/catalog.service';
import type { WorkflowFinderService } from '@/workflows/workflow-finder.service';
import type { WorkflowInputSchemaService } from '@/workflows/workflow-input-schema.service';
@ -19,15 +25,18 @@ describe('CatalogService', () => {
let logger: ReturnType<typeof mock<Logger>>;
let finder: ReturnType<typeof mock<WorkflowFinderService>>;
let schemas: ReturnType<typeof mock<WorkflowInputSchemaService>>;
let executions: ReturnType<typeof mock<ExecutionService>>;
beforeEach(() => {
logger = mock<Logger>();
finder = mock<WorkflowFinderService>();
schemas = mock<WorkflowInputSchemaService>();
executions = mock<ExecutionService>();
schemas.describe.mockResolvedValue({ eligible: true, fields: [] });
executions.findRangeWithCount.mockResolvedValue({ results: [], count: 0, estimated: false });
service = new CatalogService(logger, finder, schemas);
service = new CatalogService(logger, finder, schemas, executions);
});
it('should list only workflows the user may execute', async () => {
@ -95,4 +104,64 @@ describe('CatalogService', () => {
expect(result.workflows).toHaveLength(CATALOG_LIST_LIMIT);
expect(logger.warn).toHaveBeenCalled();
});
describe('listRuns', () => {
it('should select only runs the person started themselves', async () => {
await service.listRuns(user);
expect(executions.findRangeWithCount).toHaveBeenCalledWith(
expect.objectContaining({
user,
metadata: [{ key: CATALOG_RUN_USER_KEY, value: 'user-1', exactMatch: true }],
range: { limit: CATALOG_RUNS_LIMIT },
order: { startedAt: 'DESC' },
}),
);
});
it('should scope visibility to workflows the person may execute', async () => {
executions.buildSharingOptions.mockResolvedValue({ scopes: ['workflow:execute'] });
await service.listRuns(user);
expect(executions.buildSharingOptions).toHaveBeenCalledWith('workflow:execute');
expect(executions.findRangeWithCount).toHaveBeenCalledWith(
expect.objectContaining({ sharingOptions: { scopes: ['workflow:execute'] } }),
);
});
it('should not expose node names through the run summary', async () => {
executions.findRangeWithCount.mockResolvedValue({
results: [
{
id: 'exec-1',
workflowId: 'wf-1',
workflowName: 'Weekly report',
status: 'success',
startedAt: new Date('2026-01-01'),
stoppedAt: new Date('2026-01-02'),
mode: 'trigger',
createdAt: new Date('2026-01-01'),
lastNodeExecuted: 'Send Email',
},
],
count: 1,
estimated: false,
});
const result = await service.listRuns(user);
expect(result.runs).toEqual([
{
id: 'exec-1',
workflowId: 'wf-1',
workflowName: 'Weekly report',
status: 'success',
startedAt: new Date('2026-01-01'),
stoppedAt: new Date('2026-01-02'),
},
]);
expect(result).toEqual(expect.objectContaining({ count: 1, estimated: false }));
});
});
});

View File

@ -18,7 +18,7 @@ type WorkflowParam = { workflowId: string };
* which is the point: the catalog is for people who run workflows, not build
* them, and it never hands out the graph.
*/
@RestController('/catalog/workflows')
@RestController('/catalog')
export class CatalogController {
constructor(
private readonly catalogService: CatalogService,
@ -30,13 +30,23 @@ export class CatalogController {
* Scoped globally rather than per project: the listing spans every project
* the person belongs to, and the per-workflow filtering happens in the query.
*/
@Get('/')
@Get('/workflows')
@GlobalScope('workflow:list')
async list(req: AuthenticatedRequest) {
return await this.catalogService.list(req.user);
}
@Post('/:workflowId/run')
/**
* Scoped to executions the person started themselves, by the marker the run
* service writes — not to the workflows they can reach.
*/
@Get('/runs')
@GlobalScope('workflow:list')
async listRuns(req: AuthenticatedRequest) {
return await this.catalogService.listRuns(req.user);
}
@Post('/workflows/:workflowId/run')
@ProjectScope('workflow:execute')
async run(req: AuthenticatedRequest<WorkflowParam>, _res: unknown, @Body payload: CatalogRunDto) {
const workflow = await this.workflowFinderService.findWorkflowForUser(

View File

@ -1,8 +1,10 @@
import { Logger } from '@n8n/backend-common';
import type { User } from '@n8n/db';
import { Service } from '@n8n/di';
import type { FieldValueOption } from 'n8n-workflow';
import type { ExecutionStatus, FieldValueOption } from 'n8n-workflow';
import { ExecutionService } from '@/executions/execution.service';
import { CATALOG_RUN_USER_KEY } from '@/workflows/catalog-run.service';
import { WorkflowFinderService } from '@/workflows/workflow-finder.service';
import { WorkflowInputSchemaService } from '@/workflows/workflow-input-schema.service';
@ -26,12 +28,31 @@ export type CatalogListing = {
truncated: boolean;
};
/** How many past runs one page of history returns. */
export const CATALOG_RUNS_LIMIT = 50;
export type CatalogRun = {
id: string;
workflowId: string;
workflowName?: string;
status: ExecutionStatus;
startedAt: Date | null;
stoppedAt?: Date;
};
export type CatalogRunListing = {
runs: CatalogRun[];
count: number;
estimated: boolean;
};
@Service()
export class CatalogService {
constructor(
private readonly logger: Logger,
private readonly workflowFinderService: WorkflowFinderService,
private readonly workflowInputSchemaService: WorkflowInputSchemaService,
private readonly executionService: ExecutionService,
) {}
/**
@ -82,4 +103,37 @@ export class CatalogService {
truncated,
};
}
/**
* A person's own catalog runs, most recent first.
*
* Scoped by the run marker rather than by workflow, so someone sharing a
* workflow with colleagues sees only what they themselves started.
*/
async listRuns(user: User): Promise<CatalogRunListing> {
const sharingOptions = await this.executionService.buildSharingOptions('workflow:execute');
const { results, count, estimated } = await this.executionService.findRangeWithCount({
kind: 'range',
user,
sharingOptions,
metadata: [{ key: CATALOG_RUN_USER_KEY, value: user.id, exactMatch: true }],
range: { limit: CATALOG_RUNS_LIMIT },
order: { startedAt: 'DESC' },
});
// Projected down from the execution summary on purpose: it also carries
// `lastNodeExecuted`, per-node statuses and error details, which would
// hand node names to someone who cannot read the workflow.
const runs = results.map(({ id, workflowId, workflowName, status, startedAt, stoppedAt }) => ({
id,
workflowId,
workflowName,
status,
startedAt,
stoppedAt,
}));
return { runs, count, estimated };
}
}