mirror of
https://github.com/n8n-io/n8n.git
synced 2026-10-19 16:55:02 +02:00
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:
parent
1c7cde1ae9
commit
32fb2405e5
|
|
@ -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 }));
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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 };
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user