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 (allowCliAccess +
cliAccessGroups) is an all-or-nothing trusted-operator switch; it
cannot express "this worker acts as technical user X".
Because AccessControlService read only $GLOBALS['BE_USER'],
consumers worked around this by mutating the global themselves for the
duration of a call (nr_ai_search BackendUserContext::runAs(); 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 (setBeUserByUid(), raw->userreads); - restoration discipline (
finally) is copied per consumer instead of guaranteed centrally.
Decision
Vault owns the impersonation seam:
Netresearch\NrVault\Security\TechnicalActorContext::runAs(int $beUserUid, callable $fn): mixed.
runAs()loads thebe_usersrecord itself (Doctrine QueryBuilder), refusesuid <= 0and deleted, disabled, or start/endtime-restricted users with typedTechnicalActorExceptioncodes, resolves groups through core'sGroupResolver(the same resolution a real login gets, including subgroup expansion), and snapshots the result into an immutableTechnicalActorvalue object.- The actor lives on a per-service-instance stack; nested
runAs()calls stack cleanly with the innermost actor winning, and every scope is popped infinally— the identity cannot leak past the scope, including on exceptions. AccessControlServiceconsults 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, owner check, ADR-005 group tiers with stale-group filtering.$GLOBALS['BE_USER']is never touched.- 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_type = 'technical'plus the actor's uid and username, sealed into the HMAC chain like every other attribution field (epoch 3, ADR-024).
Consequences
- Consumers replace their
$GLOBALS['BE_USER']mutation with a singleTechnicalActorContextInterface::runAs()call; the@internalcore API reads live in one reviewed place inside vault. runAs()is 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.- The audit
actor_typevocabulary grows by'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).