Skip to content

External interaction internals

This page is the deep dive behind External interaction: how a public, token-bearing URL lets an external party (typically not logged in) drive a parked workflow step, and exactly why that is safe. Read the overview first for the concepts; this page traces the request, dissects the capability token, and walks the security model threat by threat.

The whole mechanism rests on one idea: the dispatcher routes are public, so the capability that authorizes acting on a run is a signed token in the URL, not the guessable instance id. Everything below follows from that.

The request lifecycle

An external party opens a link like /interaction/{instance}?token=.... The dispatcher validates the token, finds the step the instance is parked on, and hands the visitor to the interaction plugin that step declares. The plugin renders (or redirects); completing it resumes the workflow engine and bounces the visitor back to the dispatcher, so the flow chains to the next step with no landing screen.

sequenceDiagram
  actor V as Visitor (anonymous)
  participant D as InteractionController
  participant T as InteractionToken
  participant R as InteractionResolver
  participant P as Interaction plugin
  participant G as CapabilityGateway
  participant E as Workflow engine

  V->>D: GET /interaction/{id}?token=...
  D->>T: open(token)
  T-->>D: InteractionGrant{instanceId, tokenId?} or null
  Note over D: null or instance mismatch -> 403 AccessDenied
  D->>R: parkedToken(instance) / parkedTokenById(tokenId)
  R-->>D: parked token + declared interaction
  D->>P: respond(InteractionContext)
  P-->>V: form / redirect / message
  V->>P: submit (or click a signal link)
  P->>G: resume(token, result)
  G->>E: resumeWithResult(token, result)
  E-->>G: advanced
  P-->>V: redirect back to dispatcher (chain)

The controller entry points are InteractionController::step() (render the parked step) and InteractionController::signal() (resume with a visitor-initiated outcome such as cancel or back). Both begin with assertGrant(), which is where the token is checked.

The lifecycle above starts with the party already holding a link. Minting one is a single generic step, and delivering it is entirely open: Orchestra does not dictate how a party is sent into a run.

InteractionUrlsInterface (service orchestra_interaction.urls) is the entry point for any surface that needs to send a party into a parked instance's interaction flow. Hand it an instance id (and optionally a parked token id) and it returns a ready, token-bearing dispatcher URL, without you minting the token or knowing the route names:

// Inject Drupal\orchestra_interaction\InteractionUrlsInterface.
$url = $this->interactionUrls->stepUrl($instance_id);               // whatever step is parked
$url = $this->interactionUrls->stepUrlForToken($instance_id, $tid); // one specific branch

Under the hood that is just InteractionToken::issue() plus Url::fromRoute('orchestra_interaction.step', ...). Because the result is only a signed URL, delivery is open: email it, text it, show it on a page, return it from an API, or redirect the browser straight to it. The three shipped paths below are nothing more than built-in callers of this one service.

Custom start: the "Book" button

A booking-style front end is exactly this. Custom code behind a Book button starts the instance and sends the visitor straight to its first interaction step; there is no email and no inbox, and the visitor never sees a raw link:

// Inject Drupal\orchestra_api\OrchestraClientInterface and
// Drupal\orchestra_interaction\InteractionUrlsInterface.
$instance_id = $this->orchestra->startProcess($definition_id, $variables);
return new RedirectResponse($this->interactionUrls->stepUrl((int) $instance_id));

The visitor clicks Book, the instance starts and parks on its first interactive node, and the redirect lands them on that step (a form, a payment, a confirmation) with the capability token already in the URL. From there the flow chains on its own. This is the generic pattern a bespoke booking screen uses; it needs no notification module at all.

flowchart LR
  B["Book button<br/>(custom controller/form)"] --> ST["startProcess(definition, vars)"]
  ST --> ID[instance id]
  ID --> U["InteractionUrls::stepUrl(instance)"]
  U --> RD[redirect the browser]
  RD --> D[interaction dispatcher:<br/>first parked step]

Shipped conveniences

You are not restricted to a fixed set of delivery mechanisms. The paths below are simply the defaults the modules provide out of the box: each is an ordinary consumer of the orchestra_interaction.urls primitive, and you are free to mint a link yourself (as in the custom start above) and deliver it any way you like. The three built-in paths, for the common cases where you would rather not write code, are:

  1. Notify on arrival (the cold start) for an off-site party, in orchestra_interaction_notification. A wait node carries both External interaction and the Notify on arrival (park_notify) feature (whose audience is assignment plugins, so Users by variable resolves the original poster). When the engine parks a token there it fires TokenParkedEvent; ParkNotificationDispatcher resolves the audience, mints a branch-scoped link, and dispatches a channel-neutral OrchestraNotificationEvent of type interaction_link that a channel (orchestra_mail, or a custom SMS / chat / ECA handler) actually sends.

    flowchart LR
      E[Engine parks a token] -->|TokenParkedEvent| S[ParkNotificationDispatcher]
      S --> A[resolve audience<br/>assignment plugins]
      S --> L[mint branch-scoped<br/>capability link]
      A --> N[OrchestraNotificationEvent<br/>type: interaction_link]
      L --> N
      N --> C[channel: mail / SMS / ECA]
      C --> R[recipient gets the link]

    An interaction_task (the identity doorway) notifies through the inbox instead, so it is handled by orchestra_inbox_notification, not this dispatcher.

  2. Start-form redirect. The Webform Orchestra: start workflow on submit handler with return-to-workflow enabled is the custom-start pattern above, packaged: a public form starts the run and redirects the submitter into the dispatcher, with no code.

  3. Chaining. Once a party is in the dispatcher, each step re-issues the next link itself through InteractionContext::returnUrl() (and signalUrl()), minting a fresh, freshly-expiring token for the step the run advances to, so a live party moves from one step to the next with no new delivery and never meets an expired link.

The choice of stepUrl (instance-scoped) versus stepUrlForToken (branch-scoped) is the same link-scope decision described below; enumerate the branches awaiting a party with InteractionResolver::parkedInteractiveTokens($instance).

Anatomy of the capability token

The token is a single opaque, self-encoding string. It always names its own instance and carries its own expiry, both bound into an HMAC signature, and it optionally names one specific parked token (a single branch).

42 . 7 . 1961884800 . Ab3f...9kQ instance id token id expiry (unix) HMAC signature signed payload: orchestra_interaction:{instance}:{token}:{expiry} key = site private key + hash salt (Crypt::hmacBase64, base64) instance-scoped variant drops the token id: 42.1961884800.Ab3f...9kQ

So there are two shapes, produced by InteractionToken::issue($instance, $token):

Shape String Authorizes
Instance-scoped instance.expiry.signature whatever step the instance is parked on now
Branch-scoped instance.token.expiry.signature one specific parked token (one branch)

Minting

InteractionToken::sign() builds the signature over the payload, keyed by the site secret:

$key = $this->privateKey->get() . Settings::getHashSalt();
$payload = $token_id === NULL
  ? 'orchestra_interaction:' . $instance_id . ':' . $exp
  : 'orchestra_interaction:' . $instance_id . ':' . $token_id . ':' . $exp;
return Crypt::hmacBase64($payload, $key);

Binding instance, optional branch token, and expiry into the HMAC is what makes the token tamper-evident: change any field and the signature no longer matches. The key never leaves the site, so the token cannot be forged elsewhere.

Validating

InteractionToken::open() splits the string, range-checks the numeric fields, optionally rejects an expired token, then recomputes the signature and compares it with hash_equals() (a constant-time compare, so there is no timing oracle on the signature):

return hash_equals($this->sign($instance_id, $token_id, $exp), $signature)
  ? new InteractionGrant($instance_id, $token_id)
  : NULL;

A valid token opens to an InteractionGrant (the instance id, and an optional token id); anything malformed, expired, or forged opens to NULL. open() takes a $check_expiry flag: it is TRUE on the public path (the token is the authorization), and only passed FALSE when a separate durable binding already authorized the action and the code just needs to recover the branch the token names (a webform reopening an already-bound submission). Even then the signature is still verified; only the expiry check is skipped.

The dispatcher: routing to the parked step

Once the grant is validated, step() decides what the visitor sees. It never hardcodes a node id: the InteractionResolver reads which token is parked and which interaction that node declares.

flowchart TD
  A[step: assertGrant] --> B{instance running?}
  B -- no --> F[finished response:<br/>terminal message / thank-you]
  B -- yes --> C{parked step<br/>declares an interaction?}
  C -- no --> D{branch-scoped link<br/>and catch-up on?}
  D -- yes --> E[fast-forward to the<br/>branch's current chain]
  D -- no --> N["neutral: nothing to do right now"]
  C -- yes --> G{render-only<br/>message step?}
  G -- yes --> M[show the chain's messages]
  G -- no --> P[plugin.respond: form / redirect]

The resolver deliberately picks the lowest-id parked non-timer token for an instance-scoped link, so "the" parked step is deterministic when several are parked. A branch-scoped grant instead resolves its own named token through parkedTokenById().

Both public routes declare _access: 'TRUE' in orchestra_interaction.routing.yml. That is not "no access control": it moves the check off Drupal permissions (there is no logged-in user to check) and into the controller, where the capability token is the gate.

The token records its scope, and scope is the single biggest lever on how much a leaked link can do.

Instance-scoped 42.exp.sig step 1 step 2 step 3 drives whatever step is parked, chaining for the whole TTL: a run-level resume link Branch-scoped 42.7.exp.sig branch 7 branch 9 drives one branch only; siblings untouched; goes inert once that branch is consumed
  • Instance-scoped (stepUrl): the right link for a sequential self-service journey, a resume-by-email link, or a single wait offered to several people where the first to act wins. It keeps acting on each next step for the whole TTL, so it is the broader capability.
  • Branch-scoped (stepUrlForToken): when an instance parks several visitor-interactive branches in parallel, mint one per branch and send a distinct link to each recipient. The shipped webform interaction issues branch-scoped links, so a form always resumes the exact branch it was opened for. Once acted on, the link is inert (a loop that re-parks mints a new token id the old link does not name).

Use InteractionResolver::parkedInteractiveTokens($instance) to enumerate the branches awaiting a party, then build a link per recipient.

Security model, threat by threat

Threat Mitigation
Guessing an instance id to act on a run The id is not the capability; a valid signed token is required. Public route, private gate.
Forging or tampering with a token HMAC keyed by the site private key + hash salt; instance, branch, and expiry are all signed. Verified with hash_equals() (constant time).
Re-pointing a token at another instance or branch Instance and branch are inside the signed payload; changing either breaks the signature.
Extending a token's life Expiry is inside the signed payload; it cannot be changed, and open() rejects an expired token.
A leaked link (web-server log, Referer, external redirect) Time-limited (30-day TTL); links are re-issued fresh as the flow chains, so a live party never meets an expired one. Branch-scoped links also go inert once consumed.
Replaying a link (prefetch, double click, scanner) resumeWithResult() only advances a still-parked token, so a repeat is an idempotent no-op, not a second advance.
Driving the run down an arbitrary branch via the signal route signal() accepts only an outcome in the parked interaction's signalOutcomes() allow-list; anything else is denied.
An uninstalled interaction provider turning a public URL into an error A node whose declared plugin is missing resolves to "nothing to do", not a 500.
A cross-tenant reach Instances are tenant-scoped by the engine; the token authorizes one instance, which belongs to one tenant.

Two design points are worth calling out:

Why signal() resumes on a GET. The visitor usually has no session, so there is no CSRF token to require; the time-limited capability token is the authorization, and the idempotent resume makes a prefetch harmless. For heavier or destructive confirmations, an interaction renders a small confirmation form shown on GET and resumed only on its POST, so a link preview cannot trigger it.

The stateless trade-off. The token carries no server-side state, so it needs no per-token storage, but it also cannot be revoked before its expiry. In practice the consuming action is close to single-use (idempotent resume, branch-scoped inertness), and the blast radius is always bounded by the TTL plus the step's short outcome list. A future hardening pass wanting revocable or strictly single-use semantics would add per-token server-side state.

One plugin, two doorways

The same interaction plugin is reachable two ways, and the plugin does not know which. It is handed an InteractionContext carrying a continuation handle (an opaque "how to resume this step"); resolving that handle is the doorway's job.

flowchart TD
  H[Continuation handle<br/>from InteractionContext] --> CR[ContinuationResolver<br/>tries each scheme in priority]
  CR --> C1[CapabilityContinuationResolver]
  CR --> C2[TaskContinuationResolver]
  C1 -- opens as a signed token --> RC1["Capability resumer:<br/>whoever holds it may resume"]
  C2 -- matches a task reference --> RC2["Assignment resumer:<br/>re-checks the authenticated,<br/>assigned actor"]
  RC1 --> E[Workflow engine resume]
  RC2 --> E
  • The bearer doorway (CapabilityGateway) is the public URL path described here. The handle is the signed capability token: holding a valid one authorizes the resume.
  • The identity doorway (AssignmentGateway, from orchestra_task_interaction) is an inbox task for a logged-in assignee. The handle is a plain task reference that resolves only after re-checking the authenticated, assigned actor, so a leaked task handle is inert.

Because the plugin only passes the handle through, the same webform can be a public form an anonymous party fills or an in-site review an operator does, with no change to the form or the plugin, only to which node type carries it. Adding a third doorway is just tagging another ContinuationResolverInterface scheme.

The Webform integration

orchestra_interaction_webform makes any Webform a form step. A single hidden Orchestra interaction element is prepopulated with the step's continuation handle, so that one element is the whole binding. The Orchestra: resume on submit handler does the rest.

sequenceDiagram
  actor V as Visitor
  participant W as Webform (+ Orchestra element)
  participant H as OrchestraResumeHandler
  participant CR as ContinuationResolver
  participant E as Workflow engine

  Note over W: hidden element prepopulated with the continuation handle
  V->>W: open form (handle carried in the element)
  W->>H: first save
  H->>CR: resolve(handle) -> instance + branch + resumer
  H->>W: bind submission to instance (source entity)
  V->>W: complete submission
  W->>H: confirmation
  H->>E: resumer.resume(token, result)  // default "submitted"
  E-->>H: advanced
  H-->>V: return to the dispatcher (if chaining on)

Key properties:

  • On first save the handle is resolved and the submission is bound to the instance as its Webform source entity, so a later step can reopen it (the earlier answers are kept) and Views relates the two natively.
  • On a later re-edit the persisted binding is the authorization, so the run resumes even if the original bearer token has since expired (open() is called with $check_expiry = FALSE). A submission that never bound resumes nothing.
  • The doorway's own resumer still enforces its gate, so a leaked task handle resumes nothing under the identity doorway.
  • With Return to the workflow after submit enabled, the handler resolves the branch's current chain and the token now parked on it (read from the workflow, not from the request), and sends the visitor to the dispatcher for the next step. Parallel branches each land on their own next step rather than drifting onto a sibling.

The mirror Orchestra: start workflow on submit handler starts a workflow from a fresh submission, binds it, and seeds variables (submission id, submitter uid) so later steps can reopen the form and assign work back to the poster.

Reference

Routes (orchestra_interaction)

Path Controller Purpose
/interaction/{orchestra_instance} InteractionController::step Render the parked step (or a message).
/interaction/{orchestra_instance}/signal/{outcome} InteractionController::signal Resume with a visitor-initiated outcome.

Both are public (_access: 'TRUE') and gated by the capability token.

Key classes

Class Role
InteractionToken Mints and validates the capability token (issue, open, sign).
InteractionGrant What a valid token authorizes: an instance, and an optional branch token.
InteractionUrls Builds the token-bearing dispatcher URLs.
InteractionController The public dispatcher (step, signal).
InteractionResolver Finds the parked token and the interaction its node declares.
InteractionContext What a plugin acts in: instance, token, gateway, continuation handle.
InteractionInterface The @api contract for interaction plugins (respond, signalOutcomes, title).
CapabilityGateway The bearer doorway: token-bearing URLs, resume straight to the engine.
ContinuationResolver Tries each doorway scheme in turn to resolve a handle.
ParkNotificationDispatcher Mints and dispatches the first link on TokenParkedEvent (in orchestra_interaction_notification).

See External interaction for the concepts and setup, and Interaction chains for how chained messages and milestones render across a run.