Documentation menu

Actions runtime

Reference for the atrium-actions service: the six Zitadel webhook endpoints, the bearer-authed internal control plane, multi-key signature verification, and the two trust classes sharing one sandbox.

atrium-actions is a stateless Fastify service that executes sandboxed code on behalf of Atrium. It has two jobs: receive Zitadel webhooks and run the Action script bound to that trigger, and execute imported behavioural connectors on demand.

It holds no database. Its script registry is in-memory, and Atrium is the source of truth that populates it.

Webhook endpoints

Six endpoints, one per Zitadel Target type. All accept POSTs signed by Zitadel, and the runtime body-dispatches to the matching script.

EndpointDispatches onNotes
POST /eventsevent_type in the bodyOne Target serves every event trigger
POST /functions/preuserinfothe URL pathOne Target per function
POST /functions/preaccesstokenthe URL path
POST /functions/presamlresponsethe URL path
POST /requestsfullMethod in the bodye.g. /zitadel.user.v2.UserService/CreateUser
POST /responsesfullMethod in the body

/functions/:name accepts only those three names; anything else returns 404 with the allowed list.

Signature verification takes several keys

Each Zitadel Target has its own signing key, so the runtime accepts a comma-separated list in ZITADEL_WEBHOOK_SIGNING_KEYS and tries each until one matches. A single-key ZITADEL_WEBHOOK_SIGNING_KEY is still honoured for backward compatibility.

ConditionResponse
No signing keys configured at all503, "runtime not configured"
No configured key matches the signature401
Body is not valid JSON400
No script bound to this trigger404, with the registered trigger list in details

Webhook routes read the body as a raw string so the HMAC is computed over the exact bytes Zitadel sent. Internal routes get normal JSON parsing. That split is why the two families are handled by one content-type parser branching on the URL prefix.

A script error never blocks Zitadel

If a script throws, the runtime still answers 200 with { ok: false, error }. Zitadel is not held up by author error. Latency is reported back to Atrium fire-and-forget, classified SUCCESS, ERROR, or TIMEOUT, and a slow or failing Atrium never delays the response.

Every parsed payload is recorded even when no script is bound to that trigger, which is what lets the test-run UI show authors real production payloads for a trigger they are still wiring up.

Internal control plane

Atrium drives the runtime over these routes. They are gated by a bearer token, ATRIUM_INTERNAL_TOKEN, compared in constant time.

RoutePurpose
POST /internal/scriptsInstall or replace a script in the registry
DELETE /internal/scripts/:nameDrop a script from the registry
GET /internal/scriptsList registered scripts. Source, secrets, host bindings and config are stripped
POST /internal/scripts/:name/test-runExecute a script against a synthetic payload. No HMAC needed; nothing persists
GET /internal/recent-payloadsRecorded trigger summary, or the recent payloads for one TYPE:NAME
POST /internal/connectors/executeRun one behavioural-connector grant or revoke

GET /healthz is the liveness probe and reports the loaded script count, the number of configured signing keys, and build info. Note that it sits outside the /internal/ prefix, so unlike the routes above it is not bearer-authed.

Test-run has two modes. The preferred one is inline: the caller passes source, triggerType and triggerName, so it works for a draft script, an active script that has not been pushed yet, or a script whose registration was lost to a restart. The legacy mode looks the name up in the registry and 404s when nothing is installed under it.

The registry is in-memory, so restarts need a repush

The script registry does not survive a process restart. Between the moment the runtime restarts (deploy, OOM, host reboot) and the moment Atrium re-pushes, every webhook 404s and the provisioning it drives silently does not happen.

ATRIUM_ACTIONS_RUNTIME_RECONCILE (on by default) is the resync mechanism: it periodically re-pushes ACTIVE scripts and evicts stale registrations. The manual force-repush procedure, and the incident that motivated it, are in the runbook.

Two trust classes, one sandbox

Both kinds of code run in the same proven sandbox, isolated-vm inside a worker thread inside this container. What differs is where their network and credential boundaries come from.

Action scriptsBrokered connectors
Authored byTrusted atrium:script_author adminsImported from a repo, untrusted
Reaches the vendor viactx.fetch(url, init)ctx.request(method, path, body, contentType?) / ctx.execute(name, params)
Holds a credentialyes, ctx.secretsno — ctx.secrets does not exist
Bounded byDeployment-wide ATRIUM_ACTIONS_FETCH_ALLOWLISTthe manifest's declared requests (or statements)
Entry pointThe script bodyone fn(input, ctx) per declared operation

The connector entry points are grant, revoke, provision, deprovision and notify — three axes, one signature. The harness appends return await <operation>(ctx.payload, ctx) to the module rather than editing it, so the argument order is fixed at (input, ctx) and an author's stack-trace line numbers stay meaningful. input is the axis payload (member + accessLevel, resource, or message) with the connector's non-secret config spread alongside it. A module missing the requested operation is reported as that, not as undefined is not a function.

The difference that matters: a brokered connector never receives the credential. It names a request; the runtime resolves the base URL from Atrium's own config, refuses anything outside the declared surface, applies a per-run effect ceiling, injects the credential and sends it. Exfiltration is therefore not bounded — it is impossible, because the key is never inside the isolate.

declaredHosts still bounds egress (and, for the legacy direct class, binds the credential to those hosts). For the SQL flavor it must be empty: such a connector makes no HTTP calls at all, so ctx.fetch is blocked outright and the statement list is the whole surface.

Executing a connector

POST /internal/connectors/execute takes the connector id, its module source, the operation, the axis input it acts on, and a broker binding — either { baseUrl, auth, rules } for HTTP or { kind: 'postgres', dsn, statements } for SQL. The credential lives in that binding, which stays in the worker; the isolate only ever gets the ctx.request / ctx.execute bridge.

The caller resolves baseUrl before it builds that binding: the operator's configured field if one is set, otherwise the manifest's constant baseUrl. The module never sees either — it names a path, and the address is decided on the trusted side.

A brokered run gets no secrets in the isolate, enforced here rather than left to each caller: passing both a broker binding and secrets does not half-broker the connector, the secrets are dropped.

The request is validated before a sandbox is spun up. An HTTP binding with empty declaredHosts is refused; a SQL binding is required to carry a DSN and a non-empty statement list instead. A SQL run is wrapped in one transaction — committed on success, rolled back on failure — so a module that dies halfway leaves the vendor database as it found it.

Every call the module makes, including refusals, is returned as a brokerLog, which is what certification asserts against when it checks that a connector stayed inside its declared surface.

The harness appends a call to the module rather than modifying it, so line numbers in an author's stack trace stay meaningful. A module missing the requested operation reports that as a clear error instead of an undefined-is-not -a-function crash.

Admins reach this path through POST /api/admin/connectors/imported/<id>/test-run, described in Connectors.

Configuration

VariablePurpose
ZITADEL_WEBHOOK_SIGNING_KEYSComma-separated per-Target signing keys
ZITADEL_WEBHOOK_SIGNING_KEYSingle-key fallback, backward compatibility
ATRIUM_INTERNAL_TOKENBearer token gating /internal/*
ATRIUM_ACTIONS_FETCH_ALLOWLISTDeployment-wide egress allowlist for action scripts
PORTListen port, default 3000, bound to 0.0.0.0
LOG_LEVELFastify log level, default info

Host allowlisting for scripts is the operational trap worth knowing before you ship one: see the allowlist section of the ctx API.