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.
| Endpoint | Dispatches on | Notes |
|---|---|---|
POST /events | event_type in the body | One Target serves every event trigger |
POST /functions/preuserinfo | the URL path | One Target per function |
POST /functions/preaccesstoken | the URL path | |
POST /functions/presamlresponse | the URL path | |
POST /requests | fullMethod in the body | e.g. /zitadel.user.v2.UserService/CreateUser |
POST /responses | fullMethod 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.
| Condition | Response |
|---|---|
| No signing keys configured at all | 503, "runtime not configured" |
| No configured key matches the signature | 401 |
| Body is not valid JSON | 400 |
| No script bound to this trigger | 404, 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.
| Route | Purpose |
|---|---|
POST /internal/scripts | Install or replace a script in the registry |
DELETE /internal/scripts/:name | Drop a script from the registry |
GET /internal/scripts | List registered scripts. Source, secrets, host bindings and config are stripped |
POST /internal/scripts/:name/test-run | Execute a script against a synthetic payload. No HMAC needed; nothing persists |
GET /internal/recent-payloads | Recorded trigger summary, or the recent payloads for one TYPE:NAME |
POST /internal/connectors/execute | Run 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 scripts | Brokered connectors | |
|---|---|---|
| Authored by | Trusted atrium:script_author admins | Imported from a repo, untrusted |
| Reaches the vendor via | ctx.fetch(url, init) | ctx.request(method, path, body, contentType?) / ctx.execute(name, params) |
| Holds a credential | yes, ctx.secrets | no — ctx.secrets does not exist |
| Bounded by | Deployment-wide ATRIUM_ACTIONS_FETCH_ALLOWLIST | the manifest's declared requests (or statements) |
| Entry point | The script body | one 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
| Variable | Purpose |
|---|---|
ZITADEL_WEBHOOK_SIGNING_KEYS | Comma-separated per-Target signing keys |
ZITADEL_WEBHOOK_SIGNING_KEY | Single-key fallback, backward compatibility |
ATRIUM_INTERNAL_TOKEN | Bearer token gating /internal/* |
ATRIUM_ACTIONS_FETCH_ALLOWLIST | Deployment-wide egress allowlist for action scripts |
PORT | Listen port, default 3000, bound to 0.0.0.0 |
LOG_LEVEL | Fastify 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.