|
|
||
|---|---|---|
| .. | ||
| __tests__ | ||
| policy-check-failed.error.ts | ||
| policy-decision-audit.ts | ||
| policy-decision.service.ts | ||
| policy-infrastructure.module.ts | ||
| policy-lifecycle-handler.ts | ||
| README.md | ||
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
PolicedWorkflowwithartifactKind: 'agent'. Itsnodesare its node tools, one node each, built bytoPolicedNodesinsrc/policy/policed-agent-nodes.ts. Each node has the type the tool runs as, which is its…Toolvariant 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 anagentsubject, and the audit line recordsagentIdandagentName. - Agent hosts: config update and create (
workflowSave), revert (workflowSaveover the current draft), and publish (workflowPublish). - Agent node tools run through
EphemeralNodeExecutor, notWorkflowRunner. SoworkflowExecuteBeforenever fires. Each run path of the executor callsworkflowStarton 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.
PolicyEnforcementServiceadds 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 inmeta.violations, so a user fixing a workflow sees the whole list. OnworkflowStartthe run is stored aserrorwith the same violations onresultData.error. - A check throws or overruns its deadline:
PolicyCheckFailedError, HTTP 503. Check internals never reach the response. ThecorrelationIdstie the response to the server log lines that hold the real errors. evaluate*(advisory): never throws and never mints. A failed check lands incheckErrorsnext 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 givesoutcome: "checkFailure"withcorrelationIds, which tie it to the per-check error lines holding the real errors. AcheckFailureline still carries theviolationsandpolicyVersionsthe checks that did answer reported, so a partial run stays diagnosable. evaluate*writes nothing. Previews must not pollute the trail.- The violation
messageis not on the line. It is free text saying what the structured fields already say. - A create logs
workflowId: nulland 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
agentIdandagentNameinstead of the workflow fields, with the samenullrule for a create. warn, notinfo, so the line survives an operator quietening logs. It matches thewarninglevelPolicyViolationErroralready 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, ordebuglevel. The message names the point on its own for that reason. N8N_LOG_SCOPEScan drop the line. A configured scope set drops every line outside it, unscoped lines included, so no log line is immune.N8N_LOG_SCOPES=policyis 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 aPolicyActoras 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,integrationorlog-streaming. - A run never names a user. Not every execution path knows reliably who started the
run, so a block inside a run is
executionand carries theexecutionId. Use the execution to find who started it. This includes manual runs and sub-workflows. - The payload says which.
actorTypeisuserorsystem. A system actor addssystemReasonand hasuserId: null. userIdalways means the accountable human. A future agent actor adds its own fields and fillsuserIdwith the user it acts for, so SIEM rules onuserIdkeep 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 othern8n.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-streamingblocks. 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
signalto 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 |