ADR-029: Scoped technical-actor identity for headless use
Table of contents
Status
Accepted
Date
2026-07-17
Context
Headless consumers — Symfony Messenger workers, scheduler runs, CLI jobs —
need vault-gated secrets under a named technical backend user:
per-consumer audit attribution, group-scoped vault ACL, per-user budget
windows in downstream extensions.
The global CLI access configuration (allow +
cli) is an all-or-nothing trusted-operator switch; it
cannot express "this worker acts as technical user X".
Because Access read only $GLOBALS,
consumers worked around this by mutating the global themselves for the
duration of a call (nr_ai_search Backend; nr_llm
ADR-052 documents the same pattern as a workaround).
That mutation is a footgun:
- a temporarily privileged identity is visible to all code sharing the PHP process while the scope is open — from a shared frontend request that is exploitable;
- every consumer re-implements hydration via
@internalcore APIs (set, rawBe User By Uid () ->userreads); - restoration discipline (
finally) is copied per consumer instead of guaranteed centrally.
Decision
Vault owns the impersonation seam:
Netresearch\.
runloads theAs () be_record itself (Doctrine QueryBuilder), refusesusers uid <= 0and deleted, disabled, or start/endtime-restricted users with typedTechnicalcodes, resolves groups through core'sActor Exception Group(the same resolution a real login gets, including subgroup expansion), and snapshots the result into an immutableResolver Technicalvalue object.Actor - The actor lives on a per-service-instance stack; nested
runcalls stack cleanly with the innermost actor winning, and every scope is popped inAs () finally— the identity cannot leak past the scope, including on exceptions. Accessconsults the active technical actor before its BE_USER/CLI branches and evaluates it with the same user-based semantics an authenticated backend user gets: admin override — itself removable under the hardened profile, since it routes through the sameControl Service adminseam — owner check, ADR-005 group tiers with stale-group filtering.Bypass Active () $GLOBALSis never touched.['BE_ USER'] - Operation permissions resolve separately, because a technical actor has
no session whose
groupcould be consulted:Data secret.is granted implicitly, and every other permission only if one of the actor's subgroup-expandeduse be_rows carries the matchinggroups tx_custom option. Fail-closed on a missing group, a missingnrvault:<permission> Connectionor any database error.Pool - Without an active scope every check falls through unchanged — ambient web/CLI behaviour is bit-identical (guarded by characterization tests).
- The audit log records the technical actor as such:
actor_plus the actor's uid and username, sealed into the HMAC chain like every other attribution field (epoch 3, ADR-024).type = 'technical'
Consequences
- Consumers replace their
$GLOBALSmutation with a single['BE_ USER'] Technicalcall; theActor Context Interface:: run As () @internalcore API reads live in one reviewed place inside vault. runis not authentication: any PHP code with DI access can act as any enabled backend user — the same power global mutation already grants every extension. The API adds validation, scoping, and honest audit attribution, not a new privilege boundary.As () - The audit
actor_vocabulary grows bytype 'technical'; analytics count it as an automated actor. - The technical actor's group snapshot is taken at scope entry; group changes during a long-running scope are not observed (same as a real BE session).