n8n/packages/cli/src/modules/policy-infrastructure
Guillaume Jacquart e7d8533880
fix(core): Apply node type policies to agent node tools (#39711)
Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-07 12:32:29 +00:00
..
__tests__ fix(core): Apply node type policies to agent node tools (#39711) 2026-10-07 12:32:29 +00:00
policy-check-failed.error.ts
policy-decision-audit.ts fix(core): Apply node type policies to agent node tools (#39711) 2026-10-07 12:32:29 +00:00
policy-decision.service.ts feat(core): Add credentialTransfer policy enforcement point (#40300) 2026-10-06 13:15:19 +00:00
policy-infrastructure.module.ts feat(core): Enable the policy infrastructure module by default (no-changelog) (#38224) 2026-09-09 13:42:18 +00:00
policy-lifecycle-handler.ts feat(core): Send a log streaming audit event when a policy blocks an action (#39581) 2026-09-29 14:02:09 +00:00
README.md fix(core): Apply node type policies to agent node tools (#39711) 2026-10-07 12:32:29 +00:00

Policy infrastructure module

The shared layer that every policy feature is built on. It runs the registered @PolicyCheck() classes at fixed points in n8n and turns their answers into one of three outcomes: cleared, blocked by policy, or blocked because a check failed.

The module holds no policy of its own. A policy feature adds a check class and a store for its rules. It does not add an enforcement path, an error shape, or an audit gap.

Why these eight points, why every check must pass, and why a check that does not answer blocks: read the policy infrastructure RFC in Notion. This README is the working reference for writing a check. It does not restate the RFC.

The module is on by default. N8N_DISABLED_MODULES=policy-infrastructure turns it off: nothing is checked and everything is allowed. That is the documented break-glass lever. An instance with no check registered behaves the same either way — the module by itself changes nothing a user can see.

Architecture

flowchart LR
    subgraph hosts["Host call sites (packages/cli)"]
        save["workflowSave<br/>WorkflowCreationService, WorkflowService,<br/>chat hub, instance AI, public API"]
        publish["workflowPublish<br/>WorkflowService.activateWorkflow,<br/>WorkflowPublicationApplier, ActiveWorkflowManager"]
        start["workflowStart<br/>PolicyLifecycleHandler on<br/>workflowExecuteBefore"]
        other["workflowTransfer<br/>credentialSave<br/>credentialTransfer<br/>contentImport<br/>credentialDecrypt"]
    end

    subgraph pep["Enforcement point · src/policy (always loaded)"]
        pes["PolicyEnforcementService<br/>enforce* · evaluate* · hasChecksFor"]
    end

    subgraph module["policy-infrastructure module (default, disable to opt out)"]
        pds["PolicyDecisionService<br/>deadline per check · all checks must pass<br/>crash or timeout = fail closed<br/>one audit line + one log streaming event per veto"]
        registry["PolicyCheckMetadata<br/>registry in @n8n/decorators"]
        checks["@PolicyCheck() classes<br/>onWorkflowSave · onWorkflowPublish · …"]
    end

    subgraph outcomes["Outcomes"]
        cleared["PolicyCleared token"]
        violation["PolicyViolationError<br/>403 · meta.violations"]
        failed["PolicyCheckFailedError<br/>503 · meta.correlationIds"]
    end

    repo["WorkflowRepository.updateContent<br/>assertClearedFor(token, point, subject)"]

    save & publish & start & other --> pes
    pes -. "setImplementation() at module init" .-> pds
    pds --> registry --> checks
    pes --> cleared --> repo
    pes --> violation
    pds --> failed

Without the module, PolicyEnforcementService has no implementation. enforce* then returns a cleared token and evaluate* returns an empty decision. A feature that is absent is not a security failure.

One save, end to end

sequenceDiagram
    participant H as WorkflowService.update
    participant PES as PolicyEnforcementService
    participant PDS as PolicyDecisionService
    participant C as @PolicyCheck classes
    participant R as WorkflowRepository

    H->>PES: enforceWorkflowSave({ workflow, storedWorkflow, projectId })
    PES->>PDS: enforce('workflowSave', ctx)
    loop every check with onWorkflowSave
        PDS->>C: onWorkflowSave(ctx, signal) under a 1 s deadline
        C-->>PDS: { violations, policyVersions }
    end
    alt a check threw or timed out
        PDS-->>H: PolicyCheckFailedError (503)
    else at least one violation
        PES-->>H: PolicyViolationError (403, meta.violations)
    else clear
        PES-->>H: PolicyCleared token bound to workflowSave and this workflow
        H->>R: updateContent(id, content, { policyCleared })
        R->>R: assertClearedFor(token, 'workflowSave', subject)
    end

storedWorkflow is loaded from the database by the host, never taken from the request. A check can compare it with workflow to judge only what the save adds.

Enforcement points

Point Deadline Subject the token binds to Where it fires
workflowSave 1000 ms row id, or node hash for a create editor, public API, chat hub, instance AI, eval thread restore
workflowPublish 1000 ms row id activate, publication applier, activation on startup
workflowStart 250 ms row id workflowExecuteBefore on main, workers, sub-executions, manual runs
workflowTransfer 1000 ms row id move to another project
credentialSave 1000 ms row id, or type hash for a create editor, public API, package import stubs, provider connections
credentialTransfer 1000 ms row id move to another project
contentImport 1000 ms row id CLI import, source control import, package and git-connection import
credentialDecrypt 250 ms credential id credential resolution during a run or a test

Deadlines are tight on the two points that sit inside a running execution. A wedged policy store there pins worker slots instead of failing one request.

A one-off CLI command registers no check unless it calls BaseCommand.initPolicyEnforcement(). import:workflow, import:credentials, execute and execute-batch call it.

Known gaps: import:entities, publish:workflow and update:workflow run no check. import:entities restores the policy tables in the same run, so a check against the stored rules would judge the content by the wrong policy. The workflowStart check still refuses to run a blocked workflow that one of these commands wrote.

Contexts

Each point hands its check a different context. The types are in @n8n/decorators/src/policy-check/policy-check.ts.

Point Context type Fields
workflowSave WorkflowSaveContext workflow, storedWorkflow (null for a create), projectId
workflowPublish WorkflowPublishContext workflow, projectId
workflowStart WorkflowStartContext workflow, projectId
workflowTransfer WorkflowTransferContext workflow, targetProjectId — the project it moves into, whose policy applies
credentialSave CredentialSaveContext credential, storedCredential (null for a create), projectId
credentialTransfer CredentialTransferContext credential, targetProjectId — the project it moves into, whose policy applies
contentImport ContentImportContext workflow, projectId, transport
credentialDecrypt CredentialDecryptContext credentialType, credentialId, consumer (null for a credential test), projectId

workflow is a PolicedWorkflow: id (null before the first save), name, nodes. Nothing else, so a check cannot start to depend on unrelated fields. credential is a PolicedCredential: id (null before the first save) and type. The name and the data never reach a check. Every field is readonly — a check reads, it never writes.

transport is cli, source-control, package, or git-connection. Read it to hold an unattended sync to a different standard than a hand-run import. The host reads it too, to pick its fail posture: a package import refuses the whole package, a source-control pull skips and reports the workflow.

Agents and embedded nodes

An agent has no enforcement point of its own. It goes through the workflow points, so every check that reads nodes covers agents with no change.

  • An agent is a PolicedWorkflow with artifactKind: 'agent'. Its nodes are its node tools, one node each, built by toPolicedNodes in src/policy/policed-agent-nodes.ts. Each node has the type the tool runs as, which is its …Tool variant when one exists, and the tool's own parameters. The mapper reads tools through the agent config schema, so a renamed field breaks the build. A tool that fails the schema is still policed by what it names. The token binds to an agent subject, and the audit line records agentId and agentName.
  • Agent hosts: config update and create (workflowSave), revert (workflowSave over the current draft), and publish (workflowPublish).
  • Agent node tools run through EphemeralNodeExecutor, not WorkflowRunner. So workflowExecuteBefore never fires. Each run path of the executor calls workflowStart on the one-node workflow it builds, and returns a refusal as a tool error. Expression evaluation for a workflow tool runs no node, so it is not policed there.
  • Inline agents. PolicyEnforcementService adds the node tools of an inline agent in a Message an Agent node to the nodes it gives the checks, at every workflow point. An agent tool that is itself a Message an Agent node adds its inline tools the same way, at any depth. The added nodes are for the checks only. The token binds to the host's own subject, and the host does not write the added nodes. An inline agent set by an expression is not expanded; the executor polices its tools when they run.

Fail posture

  • No check registered for a point: allowed.
  • A check returns violations: PolicyViolationError, HTTP 403. Every violation from every check is in meta.violations, so a user fixing a workflow sees the whole list. On workflowStart the run is stored as error with the same violations on resultData.error.
  • A check throws or overruns its deadline: PolicyCheckFailedError, HTTP 503. Check internals never reach the response. The correlationIds tie the response to the server log lines that hold the real errors.
  • evaluate* (advisory): never throws and never mints. A failed check lands in checkErrors next to the other results, so a crash never reads as "no violations".

The decision audit line

Every veto writes one line, from one place: PolicyDecisionService.audit. A check never logs its own — it reports violations and the line follows.

warn  Policy blocked workflowSave  {
  "point": "workflowSave", "outcome": "violation", "durationMs": 12,
  "checkIds": ["node-type-availability"],
  "violations": [{ "checkId": "node-type-availability", "kind": "node-type-unavailable",
                   "subject": "n8n-nodes-base.slack", "subjectType": "nodeType",
                   "scope": "instance", "matchedRuleId": "rule-7" }],
  "policyVersions": [{ "scope": "instance", "version": 4 }],
  "workflowId": "wf-1", "workflowName": "My workflow", "projectId": "proj-1",
  "scopes": ["policy"]
}
  • Both ways of blocking write a line. A violation gives outcome: "violation". A check that threw or overran gives outcome: "checkFailure" with correlationIds, which tie it to the per-check error lines holding the real errors. A checkFailure line still carries the violations and policyVersions the checks that did answer reported, so a partial run stays diagnosable.
  • evaluate* writes nothing. Previews must not pollute the trail.
  • The violation message is not on the line. It is free text saying what the structured fields already say.
  • A create logs workflowId: null and the name. A save is a create until it has a stored row, and any id on its payload is the client's claim rather than a committed row — the seal discards it for the same reason, binding a create to its content. The line does not reproduce that content subject: computing it is the enforcement point's job, and mirroring it here would let the two drift.
  • An agent logs agentId and agentName instead of the workflow fields, with the same null rule for a create.
  • warn, not info, so the line survives an operator quietening logs. It matches the warning level PolicyViolationError already gives itself.

Two logging facts to know before relying on this:

  • The default text format prints the message only. The structured half needs N8N_LOG_FORMAT=json, file output, or debug level. The message names the point on its own for that reason.
  • N8N_LOG_SCOPES can drop the line. A configured scope set drops every line outside it, unscoped lines included, so no log line is immune. N8N_LOG_SCOPES=policy is the switch that keeps only these.

Policy mutation audit — who changed a policy — is a different surface, owned by the feature that has a policy to mutate, on the existing audit-event infrastructure.

The log streaming event

The same emit site also sends n8n.audit.policy.decision.blocked to log streaming, so a SIEM sees every block but one kind (see below). The payload is the audit line plus the actor. It does not depend on the log format or on N8N_LOG_SCOPES.

  • The host names the actor. Every enforce* call takes a PolicyActor as its second argument. Checks never see it. It is a user when the host is a request with an authenticated user. Otherwise it is the system with a reason: execution, cli-import, activation, publication, integration or log-streaming.
  • A run never names a user. Not every execution path knows reliably who started the run, so a block inside a run is execution and carries the executionId. Use the execution to find who started it. This includes manual runs and sub-workflows.
  • The payload says which. actorType is user or system. A system actor adds systemReason and has userId: null.
  • userId always means the accountable human. A future agent actor adds its own fields and fills userId with the user it acts for, so SIEM rules on userId keep their meaning.
  • The user fields are the same on every block. A host that knows only the user id passes { id }, and the relay reads the rest of the user before it sends the event. The fields are redactable, the same as on every other n8n.audit.* event.
  • A block on a destination credential sends no event. A webhook destination decrypts its credential on every delivery. If a policy blocks it, the event would go to the same destination and block again. So the relay drops log-streaming blocks. The audit line still records them.
  • Violations have fixed keys. A field a check left out is null, not missing.
  • Workers and webhook processes send it too. The log streaming module runs on every instance type, so an event from a worker goes straight to the destinations.

The seal

A cleared write needs a PolicyCleared token minted by the enforcement point. WorkflowRepository.updateContent and its siblings call assertClearedFor, which checks that the token exists, was minted for this point, and binds to this subject. Only PolicyEnforcementService may import the minter; a lint rule enforces that.

The workflow entity subscriber rejects inserts and node-bearing updates outside the repository's scoped write context. Policy-cleared repository methods open that context after they validate the matching token. Standalone node execution is the documented exception: it opens the context without a save token, then enforces the policy before the temporary workflow starts.

Add a check

@PolicyCheck()
export class NodeTypePolicyCheck implements RegisteredPolicyCheck {
	readonly id = 'node-type-availability';

	async onWorkflowSave(
		{ workflow, storedWorkflow, projectId }: WorkflowSaveContext,
		signal: AbortSignal,
	) {
		return { violations: await this.violationsFor(workflow, storedWorkflow, projectId, signal) };
	}
}

The node type availability policy is the first feature built this way. Read ../type-availability-policies/ for a working check and its store, and that module's README for what it decides at each point.

Rules:

  • Report violations. Do not throw for them. A throw means the check broke.
  • Read state only. evaluate* must be safe to call from advisory UI.
  • Implement only the points you need. An on* method that matches no point is a startup error.
  • Pass signal to anything that accepts it. The deadline holds either way, but the signal lets the check stop its own work.

Import the file for its side effect from the module that owns the feature. The registry is read on every decision, so load order cannot hide a check.

Files

File Role
policy-infrastructure.module.ts Registers PolicyDecisionService into the enforcement point and loads the lifecycle handler
policy-decision.service.ts Runs the checks with deadlines, combines their results, and emits the audit line and event
policy-decision-audit.ts The audit line's shape and how it reads a target off each context
policy-lifecycle-handler.ts The workflowStart host, one hook for every way an execution starts
policy-check-failed.error.ts The 503 for a check that did not answer
../../policy/policy-enforcement.service.ts The enforcement point the hosts call, always loaded
../../policy/policy-violation.error.ts The 403 that carries meta.violations
../../policy/policy-enforcement-backend.ts The interface PolicyDecisionService implements and the module registers
@n8n/decorators/src/policy-check/ @PolicyCheck(), the registry, the contexts, the PolicyCleared token