diff --git a/packages/@n8n/engine/docs/adr/ADR-20260904-a-derived-token-authorizes-a-resume-request.md b/packages/@n8n/engine/docs/adr/ADR-20260904-a-derived-token-authorizes-a-resume-request.md new file mode 100644 index 00000000000..fee8ef3225c --- /dev/null +++ b/packages/@n8n/engine/docs/adr/ADR-20260904-a-derived-token-authorizes-a-resume-request.md @@ -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,
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.
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.
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