mirror of
https://github.com/n8n-io/n8n.git
synced 2026-10-17 05:29:44 +02:00
docs(engine): Record the resume-token decision (no-changelog) (#39089)
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
c9cb3ee3ab
commit
26d93b601a
|
|
@ -0,0 +1,226 @@
|
|||
# A derived token authorizes a resume request
|
||||
|
||||
Date: 2026-09-04
|
||||
|
||||
Status: Active
|
||||
|
||||
Decision Owner: Catalysts
|
||||
|
||||
Source: CAT-2928
|
||||
|
||||
## Context
|
||||
|
||||
A caller outside n8n ends a wait that accepts a resume request. The caller can be a webhook client,
|
||||
a person who submits a form, or a person who approves a message. That caller uses a URL. The URL
|
||||
travels through channels that the engine does not control. It stays available for the length of the
|
||||
wait, which can be several months.
|
||||
|
||||
This record uses these terms:
|
||||
|
||||
- **Resume request:** a request that ends a wait. It carries a token.
|
||||
- **Execution-bound resume URL:** a resume URL that names the execution and no step.
|
||||
`$execution.resumeUrl`, `$execution.resumeFormUrl`, and `$resumeWebhookUrl` hold it. Any node can
|
||||
read it, also before the step that waits exists. Its caller chooses the query and the body.
|
||||
- **Step-bound resume URL:** a resume URL that names one step. The waiting node builds it with
|
||||
`getSignedResumeUrl` while its step runs. The node fixes its query, for example `approved=true`.
|
||||
- **Approval callback:** a step-bound resume request that Slack or Telegram sends to one fixed URL
|
||||
when a person presses a button. The reference that names the step is in the request body.
|
||||
- **Execution-bound wait:** a wait that accepts an execution-bound resume URL, for example the Wait
|
||||
node in webhook or form mode.
|
||||
- **Step-bound wait:** a wait that accepts a step-bound resume URL or an approval callback, for
|
||||
example a send-and-wait node.
|
||||
|
||||
ADR-20260902-steps-declare-waits sends every resume request to a data-plane endpoint that checks it
|
||||
against the waiting step. That endpoint accepts every request, so the request must authorize itself.
|
||||
A resume request also runs the `webhook` method of the node that waits. That method needs the HTTP
|
||||
request and the HTTP response, and only the control plane holds them.
|
||||
|
||||
The engine has two kinds of shared-secret token. Neither kind fits. `IDENTITY_TOKEN` goes from the
|
||||
control plane to the data plane. `ACTION_TOKEN` goes from the data plane to the control plane. Their
|
||||
issuer and audience values are opposite. This prevents the replay of one token at the endpoints of
|
||||
the other. Both tokens live for 60 seconds. That length is correct for a call between two services.
|
||||
It is too short for a URL in an email.
|
||||
|
||||
In engine v1, `$execution.resumeUrl` and `$execution.resumeFormUrl` carry a random `resumeToken`
|
||||
from the data of the execution. Engine v1 compares it with a timing-safe equality check. The check
|
||||
is optional: an execution without a stored token accepts any caller.
|
||||
|
||||
In engine v1, `getSignedResumeUrl` builds a URL whose path holds the execution id and the node id,
|
||||
and whose query holds the parameters that the node sets. Engine v1 signs the path and the query
|
||||
together with an HMAC secret, so a caller cannot change a parameter. Engine v1 derives that secret
|
||||
from the encryption key of the instance, so the control plane can build a URL. The node id in the
|
||||
path also selects the webhook of the waiting node. Therefore the URL from `$execution.resumeUrl`
|
||||
cannot end the wait of a send-and-wait node.
|
||||
|
||||
The data plane can run as its own process. That process has no access to the database or the
|
||||
encryption key of the control plane. Node code runs in the data plane, so every node builds its
|
||||
resume URLs there. The control plane verifies one resume request in engine v1: the approval callback
|
||||
of Slack and Telegram. It carries a signed reference in the request body to a fixed URL, and
|
||||
Telegram limits that reference to 64 bytes.
|
||||
|
||||
## Decision
|
||||
|
||||
A **separate kind of capability token** authorizes a resume request. The engine derives the token
|
||||
from a secret that only the data plane holds, and does not store the token. The token is the only
|
||||
control between an unknown caller and a paused workflow. Therefore every resume request carries a
|
||||
token, and a request without one is rejected. Engine v1 skips its check for executions from before
|
||||
it stored tokens. Engine v2 has no such executions.
|
||||
|
||||
The bar this decision must meet is parity with engine v1: a resume request is as hard to forge here
|
||||
as it is there. The decision meets that bar. It goes past v1 in two places. Only the runtime of the
|
||||
data plane can build a token. A step-bound resume URL and an approval callback end one wait only.
|
||||
|
||||
1. **The token has its own spec.** That spec holds its own issuer and audience. Therefore a resume
|
||||
token is not valid at the other endpoints of either plane, and the tokens of those endpoints are
|
||||
not valid at the resume endpoint.
|
||||
2. **The engine derives the token and does not persist it.** The engine calculates the token from
|
||||
its claims and the resume secret. It does this each time it needs a token. This needs no column
|
||||
and no migration. Any data-plane code that holds the claims can build the token. The
|
||||
send-and-wait nodes need this when they compose the message that they send.
|
||||
3. **The claims name what the builder knows.** An execution-bound resume URL names the execution.
|
||||
Whichever node reads `$execution.resumeUrl` or `$execution.resumeFormUrl` evaluates it, usually
|
||||
before the step that waits exists. The data plane therefore picks the waiting step of the
|
||||
execution when the request arrives, as engine v1 does. A step-bound resume URL and an approval
|
||||
callback name the step. The waiting node builds them while its step runs, so they end that wait
|
||||
and no other.
|
||||
4. **The token states its kind.** A token is an execution-bound resume URL, a step-bound resume URL,
|
||||
or an approval callback, and the data plane applies the rule of that kind. A request resumes a
|
||||
step only if the wait of the step is bound the same way as its token. An execution-bound wait
|
||||
accepts an execution-bound resume URL. A step-bound wait accepts a step-bound resume URL and an
|
||||
approval callback.
|
||||
5. **The token binds what the node fixed.** A step-bound resume URL binds its path and its query, so
|
||||
a caller cannot change, add, or remove a parameter. An approval callback binds its decision. An
|
||||
execution-bound resume URL binds only the execution. Its caller chooses the query and the body,
|
||||
because a webhook caller sends its data that way.
|
||||
6. **The token does not expire.** The status of the step is the control. The token shows which
|
||||
caller can make the request. The compare-and-set that every other transition uses decides if the
|
||||
request still applies. `resumeStep` moves a step out of `waiting`, or it does nothing. A token
|
||||
for a wait that is already resolved, timed out, or cancelled has no effect.
|
||||
7. **The data plane owns the resume secret.** The resume secret is not the shared secret of the two
|
||||
planes. Only the runtime of the data plane reads it, also where the control plane hosts that
|
||||
runtime in its own process. The data plane mints every resume token and verifies every resume
|
||||
request. No node code runs for a resume request before the data plane accepts its token. The
|
||||
control plane passes the token to the data plane and does not read it.
|
||||
8. **The control plane sends each resume request to the engine that runs the execution.** It picks
|
||||
engine v1 or engine v2, and it does not read the token to do so.
|
||||
|
||||
This diagram shows how a resume request moves through the decisions above.
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant Caller
|
||||
participant CP as Control plane
|
||||
participant DP as Data plane
|
||||
|
||||
Note over DP: A node builds a resume URL,<br/>and the data plane mints its token.
|
||||
DP-->>Caller: Resume URL, sent by the node (for example in an email)
|
||||
Note over DP: The step waits.
|
||||
Caller->>CP: Resume request with the token
|
||||
Note over CP: An approval callback comes to a fixed URL.<br/>The control plane reads only its id.
|
||||
CP->>CP: Pick the engine by the shape of the id
|
||||
CP->>DP: Pass on the token, with the identity token of the control plane
|
||||
DP->>DP: Verify the token, its kind and binding.<br/>Check the tenant. Find the waiting step.
|
||||
alt The data plane rejects the request
|
||||
DP-->>CP: Rejected
|
||||
CP-->>Caller: Error response
|
||||
else The data plane accepts the request
|
||||
DP-->>CP: The waiting step
|
||||
CP->>CP: Run the webhook method of the node
|
||||
CP->>DP: Resume the step with the output of the node
|
||||
DP->>DP: Move the step out of waiting (compare-and-set)
|
||||
DP-->>CP: Resumed
|
||||
CP-->>Caller: Response
|
||||
end
|
||||
```
|
||||
|
||||
## Alternatives Considered
|
||||
|
||||
- **Add a scope to `ActionScope`.** We rejected it because almost none of the spec of the action
|
||||
token would remain. The audience of that token is the control plane, but the data plane verifies a
|
||||
resume request, and its 60-second lifetime would need an override. The shared enum would also tell
|
||||
a reader that each token is still valid at one endpoint only, which would be false. This option
|
||||
would reuse an existing token.
|
||||
- **Mint the token at suspension and store it on the step row.** We rejected it because it would
|
||||
need a column, and the shim would read the row again to build a message URL. No requirement asks
|
||||
for the revocation path that it would give. This option could revoke one wait without a change to
|
||||
the secret.
|
||||
- **Set the expiry to the deadline of the wait.** We rejected it because a wait that only a resume
|
||||
request ends has no deadline, so this option would need a second rule for that case. Two rules for
|
||||
one question increase the risk of a gap in the check. This option would give the shortest window
|
||||
for a wait that has a deadline.
|
||||
- **Use one long lifetime, for example ninety days.** We rejected it because it would set a maximum
|
||||
wait length that no other part of the engine sets. It would turn a secret-management problem into
|
||||
a product limit. This option would limit the damage from a URL that leaks, and it would not depend
|
||||
on the declaration.
|
||||
- **Copy engine v1: store a random token per wait and compare it.** We rejected it for two reasons.
|
||||
It would need the column that the derived token avoids. The optional check of v1 is also a failure
|
||||
mode to avoid: with no stored token, v1 makes no check. This option is known, and it could revoke
|
||||
one wait.
|
||||
- **Name the execution in every token.** We rejected it because an approval from an earlier
|
||||
iteration of a loop would end a later wait. This option would need one claim shape only.
|
||||
- **Let the URL decide the kind, as engine v1 does.** We rejected it because the rule would then
|
||||
depend on the path and on the configuration of the workflow, not on what the token proves. A
|
||||
change to either would change the check. This option would need no kind in the token.
|
||||
- **Bind the query of every resume URL.** We rejected it because a webhook caller sends its data in
|
||||
the query, and such a request would fail. This option would need one binding rule only.
|
||||
- **Derive the resume secret from the encryption key, as engine v1 does.** We rejected it for two
|
||||
reasons. A separate data-plane process does not hold the encryption key of the control plane. The
|
||||
control plane could also build a URL. This option would need no new configuration where both
|
||||
planes run in one process.
|
||||
- **Sign the token with the shared secret of the two planes.** We rejected it because the control
|
||||
plane holds that secret, so the control plane could build a URL. This option would need no new
|
||||
secret.
|
||||
|
||||
## Consequences
|
||||
|
||||
- A step-bound resume URL binds its query as the node built it, without the token parameter. The
|
||||
data plane compares the same string when the request arrives. It does not rebuild the query from
|
||||
parsed values, because two different queries can parse to the same values.
|
||||
- The Slack and Telegram approval callback reaches a fixed URL, with its signed reference in the
|
||||
request body. Engine v1 verifies that reference in the control plane. Here the data plane verifies
|
||||
it. The control plane reads only the id in the reference, to pick the engine, and forwards the
|
||||
body.
|
||||
- The approval callback must fit in 64 bytes, the limit of Telegram. Therefore it carries its claims
|
||||
in a compact signed form instead of the format of the resume token. Nothing is stored.
|
||||
- The control plane picks the engine by the shape of the id in the request, as its other execution
|
||||
routes do. A v1 id is numeric, and a v2 id is a UUID. Therefore an execution-bound resume URL
|
||||
carries the execution id in its path, and a step-bound resume URL and an approval callback carry
|
||||
the step id. An id of the wrong shape reaches an engine that rejects the token.
|
||||
- The control plane cannot tell an execution-bound resume URL from a step-bound one. Both carry a
|
||||
UUID in the same place, and only the token says which kind it is. Therefore the data plane offers
|
||||
a single endpoint for all kinds of resume request. The endpoint uses the id in the request, and
|
||||
the data plane reads the kind from the token.
|
||||
- Every resume request also carries the identity token of the control plane that forwards it, like
|
||||
every other call from the control plane to the data plane. The data plane resumes an execution
|
||||
only if the execution belongs to the tenant of that identity token. The trust layer between the
|
||||
planes specifies that check.
|
||||
- The engine cannot revoke one resume URL. A URL stops working when the step leaves the `waiting`
|
||||
status, or when the engine no longer accepts the secret that signed it.
|
||||
- The token does not expire. Therefore a secret that signed a token must stay accepted while its
|
||||
step waits, or that resume URL stops working.
|
||||
- The token authenticates the request. It does not authorize the workflow. Who can resume a given
|
||||
wait is a separate decision, if that rule becomes narrower than "the caller that holds the URL".
|
||||
- An execution-bound resume URL covers every wait of the execution, not one wait. A request resumes
|
||||
the wait that the execution is in when the request arrives. In a loop, the same URL resumes the
|
||||
wait of every iteration, also a URL that a node sent in an earlier iteration. Engine v1 does the
|
||||
same, so the bar is unchanged. An execution with two waits at the same time is ambiguous for an
|
||||
execution-bound resume URL, and the resolve path answers 409. Engine v1 has the same single-wait
|
||||
limit. A `webhookSuffix` is the natural way to tell two apart if that limit ever lifts.
|
||||
- The verification of the token shows which execution or step the caller means. It does not show
|
||||
that a step still waits. Therefore the resolve path reads the step row in all cases. The token
|
||||
does not remove a database read. It decides if the request can continue.
|
||||
- Two resume requests for the same step can both run the webhook of the node. The compare-and-set
|
||||
lets only the first one resume the step. The other one gets a conflict (409).
|
||||
- The token primitive must allow a token without an expiry. Therefore the lifetime is optional in
|
||||
the token spec.
|
||||
- A resume request can still arrive before the engine records the suspension
|
||||
(ADR-20260902-steps-declare-waits). At this endpoint, that window appears as a valid token for a
|
||||
step that does not yet hold the `waiting` status.
|
||||
|
||||
## Links
|
||||
|
||||
RFC: -
|
||||
|
||||
Documentation: Engine v2 — Detailed Design, §3.3 https://app.notion.com/p/n8n/34b5b6e0c94f81feba4bdb59a65d55dc; Engine v2: Trust Layer: CP ↔ DP — Design Overview https://app.notion.com/p/n8n/3725b6e0c94f8077a562cb2e2c821e9c
|
||||
|
||||
Related ADRs: ADR-20260902-steps-declare-waits
|
||||
Loading…
Reference in New Issue
Block a user