diff --git a/packages/cli/docs/adr/ADR-20261012-require-create-and-execute-scopes-for-standalone-node-runs.md b/packages/cli/docs/adr/ADR-20261012-require-create-and-execute-scopes-for-standalone-node-runs.md new file mode 100644 index 00000000000..2174bd52bf6 --- /dev/null +++ b/packages/cli/docs/adr/ADR-20261012-require-create-and-execute-scopes-for-standalone-node-runs.md @@ -0,0 +1,59 @@ +# Require create and execute scopes for standalone node runs + +Date: 2026-10-12 + +Status: Active + +Decision Owner: Assistant + +Source: https://linear.app/n8n/issue/AST-1705 + +## Context + +The n8n Assistant can run one node standalone. The caller supplies the node type, the node +parameters, the credentials, and the input items. The service puts the node in a temporary +workflow in the target project, runs it, and deletes the workflow. + +`workflow:execute` lets a user run a workflow that already exists. The manual run endpoint +loads the workflow from the database and ignores a definition in the request body. A user +who has `workflow:execute` but not `workflow:create` or `workflow:update` can only run +workflows that someone else wrote. + +A standalone node run does not run a stored workflow. The caller writes the node, so the +run is workflow authoring and execution in one step. + +## Decision + +A standalone node run requires both `workflow:create` and `workflow:execute` in the target +project. The user must hold both scopes. One of the two is not sufficient. + +The main reason: the caller defines the node. That is the same power as creating a workflow +and running it. The required scopes match those two actions. + +The check runs before the service resolves the node or reads a credential. + +## Alternatives Considered + +1. **Keep `workflow:execute` alone.** We rejected it because it lets a role that cannot + author workflows run any node with any parameters. That breaks the contract of an + execute-only role. +2. **Require `workflow:update` with `workflow:execute`.** We rejected it because + `workflow:update` applies to a workflow that exists, and this technically creates a new + workflow it runs. + +## Consequences + +1. A custom role that has `workflow:execute` but not `workflow:create` cannot use standalone + node runs. Such a user can still run stored workflows through the Assistant. +2. The built-in project roles do not change in behavior. Each role that has + `workflow:execute` also has `workflow:create`. A personal project owner has both scopes. +3. Any new path that runs a caller-defined node or workflow must require `workflow:create` + with `workflow:execute`. + +## Links + +RFC: - + +Documentation: - + +Related ADRs: - diff --git a/packages/cli/src/modules/instance-ai/__tests__/instance-ai.adapter.service.test.ts b/packages/cli/src/modules/instance-ai/__tests__/instance-ai.adapter.service.test.ts index 59947b46396..608f4984ab3 100644 --- a/packages/cli/src/modules/instance-ai/__tests__/instance-ai.adapter.service.test.ts +++ b/packages/cli/src/modules/instance-ai/__tests__/instance-ai.adapter.service.test.ts @@ -1970,7 +1970,7 @@ describe('executeNodeService adapter', () => { mockedUserHasScopes.mockReset(); }); - it('runs the node in the bound project after asserting execute scope there', async () => { + it('runs the node in the bound project after asserting create and execute scopes there', async () => { mockedUserHasScopes.mockResolvedValue(true); const executeNodeService = { run: vi.fn().mockResolvedValue({ status: 'success', output: [] }), @@ -1980,16 +1980,19 @@ describe('executeNodeService adapter', () => { const context = service.createContext(mockUser, { projectId: 'team-project-1' }); await context.executeNodeService?.execute(executeRequest); - expect(mockedUserHasScopes).toHaveBeenCalledWith(mockUser, ['workflow:execute'], false, { - projectId: 'team-project-1', - }); + expect(mockedUserHasScopes).toHaveBeenCalledWith( + mockUser, + ['workflow:create', 'workflow:execute'], + false, + { projectId: 'team-project-1' }, + ); expect(executeNodeService.run).toHaveBeenCalledWith( mockUser, expect.objectContaining({ projectId: 'team-project-1' }), ); }); - it('does not run when the user lacks execute scope in the bound project', async () => { + it('does not run when the user lacks the required scopes in the bound project', async () => { mockedUserHasScopes.mockResolvedValue(false); const executeNodeService = { run: vi.fn() }; const { service, mockUser } = createNodeAdapterServiceForTests([], { executeNodeService }); diff --git a/packages/cli/src/modules/instance-ai/instance-ai.adapter.service.ts b/packages/cli/src/modules/instance-ai/instance-ai.adapter.service.ts index 431e723cbdb..310e54cb345 100644 --- a/packages/cli/src/modules/instance-ai/instance-ai.adapter.service.ts +++ b/packages/cli/src/modules/instance-ai/instance-ai.adapter.service.ts @@ -888,7 +888,9 @@ export class InstanceAiAdapterService { return { execute: async (request) => { this.assertInstanceNotReadOnly('executions'); - const projectId = await resolveProjectId(['workflow:execute']); + // The caller supplies the node type and parameters, so a standalone run + // authors a workflow. See ADR-20261012-require-create-and-execute-scopes-for-standalone-node-runs. + const projectId = await resolveProjectId(['workflow:create', 'workflow:execute']); const result = await executeNodeService.run(user, { type: request.type, version: request.version, diff --git a/packages/quality/policy/code-health/src/index.ts b/packages/quality/policy/code-health/src/index.ts index 566538bf9a9..6c29cd40362 100644 --- a/packages/quality/policy/code-health/src/index.ts +++ b/packages/quality/policy/code-health/src/index.ts @@ -34,7 +34,7 @@ const defaultRuleSettings: RuleSettingsMap = { 'adr-conventions': { enabled: true, severity: 'error', - options: { allowedOwners: ['API & OEM', 'Catalysts'] }, + options: { allowedOwners: ['API & OEM', 'Assistant', 'Catalysts'] }, }, 'catalog-violations': { enabled: true,