n8n-nodes-gatekeeper
v0.1.0
Published
Human approval gates for n8n. Nothing executes without a signed approval, and tampered tickets are refused.
Maintainers
Readme
Gatekeeper
Provider-neutral approval and execution gating for n8n workflows.
Gatekeeper exists so that automation which touches something outside the workflow cannot act on its own. Every consequential action passes through an explicit human approval, an authenticated ticket, and a deployment-configured execution adapter.
What this package is, and is not
It is:
- an authorization layer: nothing consequential runs without an approved ticket
- an authenticated integrity mechanism for the ticket that was approved
- a provider-neutral boundary between "this action is authorised" and "this action is performed"
It is not:
- a production integration. No account, billing, or messaging provider is bundled or contacted
- something that suspends, resumes, or deactivates anything on its own
- an audit database. An
audit_eventrecord is produced and returned by the workflow; it is not written to durable storage - a guarantee of exactly-once external execution (see Idempotency and Concurrency limitation)
The human approval invariant
The action that executes is the action that was approved. This is enforced, not documented as intent.
- A request creates a ticket, signed with HMAC-SHA-256.
- The ticket is persisted as the authoritative record.
- A human approves by
ticket_idonly. The approver does not supply the ticket. - The approval gate reloads the authoritative ticket, re-verifies its MAC, and checks expiry and lifecycle state.
- The execution boundary reloads the ticket again and re-verifies action, payload, state, MAC, expiry, and approver against the stored record.
- Only then is the configured adapter called.
Caller-supplied substitutions are rejected at these boundaries:
| Attempted substitution | Result |
|---|---|
| action | refused, does not match the authorised action |
| payload | refused, does not match the authorised payload |
| expires_at | ignored, the stored value governs |
| decided_by | refused, does not match the authorised approver |
| state | refused, not APPROVED |
| consumed_at | refused, already consumed |
| ticket_id | refused, no authoritative record |
| endpoint, url, host, target, webhook | refused, execution-control field |
| api_key, token, secret, credential | refused, execution-control field |
Authenticated ticket
Integrity uses HMAC-SHA-256, a keyed MAC. The key is supplied by a credential-backed node and is never written into the workflow JSON, the ticket, or any output.
This is an authenticated MAC, not an asymmetric digital signature. It is not a signature: the same symmetric key both produces and verifies it, and anyone holding the key can produce a valid MAC. Its tamper-detection property depends entirely on keeping that key secret. Protect the credential as you would any signing key.
The canonical authenticated form covers eight fields, in this order:
ticket_id, action, payload, requested_by, requested_at, expires_at, state, decided_bypayload is canonicalised with recursively sorted keys, so key ordering can
never change the MAC. The same eight-field form is used at every stage: ticket
creation, the approval transition, the execution verification, and the consume
transition. There is one canonicalisation scheme, not several.
decided_by is the eighth field. It is null at creation and becomes
authoritative at the moment of approval, at which point it is written into the
record and re-authenticated with it. A caller cannot restate who approved. The
execution boundary verifies that the decided_by in the request matches the
authoritative stored approver; a mismatched or forged approver identity is
refused.
MAC comparison is constant-time (crypto.timingSafeEqual).
Ticket lifecycle
PENDING_APPROVAL -> APPROVED -> CONSUMEDTerminal refusal conditions: REJECTED, EXPIRED, CONSUMED, malformed,
unknown, or tampered.
A ticket is single-use. Once consumed, a second approval is refused, and a second consumption is refused.
Expiry
Expiry is enforced twice:
- at the approval gate, and
- again immediately before the external adapter is called
Both checks read the authoritative stored expires_at. A malformed
expiry is refused rather than assumed valid, and a caller cannot supply a
later expiry to extend an authorisation. The invariant is:
authoritative ticket -> HMAC -> approval -> expiry -> execution -> expiry again -> executeExecution adapter architecture
Gatekeeper deliberately separates three concerns that are usually conflated:
authorization may this action happen?
|
execution configuration which deployment, which destination, which actions?
|
external execution the adapter performs the actionThe production adapter registry is intentionally empty:
const LOCAL_ADAPTERS = {};An unconfigured deployment fails closed. It refuses to execute rather than
simulating success. A host supplies a real adapter by registering an
implementation under __gatekeeperAdapterRegistry, or by adding one to the
local table.
The execution destination is read only from the operator-configured adapter node. It is never taken from the ticket, the payload, the approval request, or any caller input. The adapter receives a request containing only the authorised action, the authoritative payload, the authoritative approver, the idempotency key, and the configured target.
Execution result semantics
The result distinguishes these separately, and they must not be conflated:
| Field | Meaning |
|---|---|
| authorization_succeeded | the ticket was approved, authenticated and current |
| execution_attempted | the adapter boundary was reached and invoked |
| external_call_made | the adapter reports it actually contacted something |
| execution_succeeded | the adapter reported success |
| action_performed | success and an external call was made |
| adapter | which configured adapter ran |
| idempotency_key | the authoritative ticket id |
action_performed is true only when the adapter reports both success and a
real external call. An adapter that is registered but does nothing will report
execution_succeeded: false and action_performed: false.
A failed execution does not consume the ticket. The graph routes failures to a separate branch so the authorisation remains inspectable rather than being silently spent on a call that did not happen.
Idempotency
idempotency_key is derived from the authoritative ticket_id, never from
caller input.
Provider idempotency is required. Gatekeeper sends the key; whether a second identical request is suppressed depends entirely on the external provider honouring it. Providers that ignore the key can double-execute.
Known crash window
If the external call succeeds and the process dies before the local consume
write, the ticket remains APPROVED and a retry will attempt execution again.
Mitigation is the idempotency key, and that is the only mitigation. A datastore
with conditional writes would be the structural fix.
Concurrency limitation
The current implementation stores tickets with
$getWorkflowStaticData('global'), which is a per-instance
read-modify-write and is not transactional.
Two consequences, neither solved:
- Simultaneous approvals of the same
ticket_idcan both readPENDING_APPROVALbefore either writes, and both succeed. - Simultaneous executions of the same ticket carry the same risk.
Do not rely on this implementation for atomic single-use enforcement across concurrent workers. Hardening options, none implemented here:
- serialise per
ticket_id - a datastore with conditional / compare-and-set writes
- a provider-side idempotency guarantee
Audit
Each authorised decision produces an audit_event object recording the
ticket id, action, requester, authoritative approver, decision, MAC, and
timestamp. It is returned in the workflow output and is part of the adapter
request.
This is not durable audit storage. Nothing is written to an audit database
or long-term log by these workflows. If you need a durable audit trail, an
operator-supplied adapter or a datastore node must write it. Do not assume the
audit_event in the output is retained anywhere.
Workflow 02: suspension and resumption authorisation
Workflow 02 is a second, separate authorisation path. It does not suspend or resume anything by itself.
suspension sweep: overdue account
-> suspension ticket (PENDING_APPROVAL, nothing happens)
-> human approval
-> SUSPEND_WORKFLOW adapter
payment sweep: account with payment_status CLEARED
-> resume ticket (PENDING_APPROVAL, nothing happens)
-> human approval
-> RESUME_WORKFLOW adapterKey properties:
- Creating a ticket does not suspend or resume. The ticket output states
suspended: false/resumed: false. - Suspension and resumption are separate actions with separate tickets. Approving one never authorises the other; a cross-action attempt is refused.
- Each requires its own independent approval.
- No production suspension or resumption provider is bundled. Registering an adapter is the operator's responsibility.
- Payment status is an input boundary. It is supplied to the workflow, not
fetched from a payment provider, and the workflow marks it
INPUT_BOUNDARY_UNVERIFIED. Only an exactCLEAREDqualifies; anything else produces no resume ticket, and a ticket cannot be built without it. - Only accounts that are themselves suspended and cleared become candidates.
Fail-closed behaviour
These conditions refuse rather than continue:
- missing or unknown
ticket_id - missing approver identity
- missing or invalid HMAC, including a store edited without re-signing
- malformed
expires_at - expired ticket
- non-
APPROVEDstate - already-consumed ticket
- action not in the operator allowlist
- missing adapter configuration
- missing execution target
- missing or registered-but-unimplemented adapter
- missing or mismatched authoritative payload
- execution-control fields in the decision or payload
- caller-supplied
decided_bythat differs from the stored approver - caller-supplied expiry that would extend an authorisation
The adapter boundary fails closed on missing configuration before it can attempt anything.
Installation
- Import a workflow file into n8n (Workflows > Import from File).
- Create a credential of type
gatekeeperHmacSecretholding a strong random key, and assign it to theResolve HMAC Secretnodes. - Configure the
Resolve Execution Adapternode with youradapter_id,target, andallowed_actions. - Register an implementation for your adapter under
__gatekeeperAdapterRegistry, or accept that execution will refuse. - Wire the approval decision into the gate: POST
{ ticket_id, decision }togatekeeper/approvalfrom wherever your human approver lives.
Steps 3 and 4 are the operator's responsibility. Without them the workflow authorises and then declines to act, by design.
Testing
The suite is Python unittest with no third-party dependencies.
- Behavioural tests execute the real JavaScript embedded in the workflow files through Node, so the shipped logic is what is under test rather than a re-implementation.
- Structural tests inspect wiring, node references, credential wiring and documentation claims.
Current state: 132 tests. Structural checks confirm both workflow files
parse, node ids are unique, every connection endpoint and $() reference
resolves, the canonical form is identical at every site, and no secret, URL or
bare createHash is present.
Known limitations
- No production adapter is bundled; execution refuses until one is configured.
$getWorkflowStaticDatais not transactional, so concurrent approvals and executions can race.- Crash between external success and local consume is mitigated only by provider idempotency.
audit_eventis returned, not durably stored.- Payment status in workflow 02 is unverified caller input, not a provider response.
License
MIT
