Technical actor context
Headless consumers — Symfony Messenger workers, scheduler runs, CLI jobs — often need vault-gated secrets under a named technical backend user: per-consumer audit attribution and group-scoped vault ACL instead of the all-or-nothing CLI access switch.
\Netresearch\
provides that as a scoped API (see
ADR-029: Scoped technical-actor identity for headless use):
use Netresearch\NrVault\Security\TechnicalActorContextInterface;
use Netresearch\NrVault\Service\VaultServiceInterface;
final readonly class IngestHandler
{
public function __construct(
private TechnicalActorContextInterface $technicalActorContext,
private VaultServiceInterface $vaultService,
) {}
public function __invoke(IngestMessage $message): void
{
$apiKey = $this->technicalActorContext->runAs(
$this->technicalBeUserUid,
fn (): ?string => $this->vaultService->retrieve('my_ext/embeddings_api_key'),
);
// ...
}
}
Semantics
While the callable runs, vault access checks evaluate as the given backend user with the same user-based semantics a real authenticated BE user gets: admin override, owner check, and the ADR-005 group tiers (including stale-group filtering). Groups are resolved exactly like a real login, including subgroup expansion.
Validation is fail-closed and happens before the callable runs.
run throws a typed
\Netresearch\ for:
| Code | Refusal |
|---|---|
| 1784000001 | uid is not a positive integer |
| 1784000002 | no non-deleted be_users record with that uid |
| 1784000003 | the user record is disabled |
| 1784000004 | the user is outside its start/end time window |
| 1784000005 | the user record is not at root level (pid != 0) |
The identity is always restored on scope exit — including when the
callable throws.
Nested
run calls stack; the innermost actor wins and each
scope restores the previous one.
The audit log records the scope honestly: entries written inside
run carry the actor's uid and username with
actor_type = 'technical', sealed into the tamper-evident HMAC
chain like every other attribution field.
Note
run is not an authentication mechanism.
Any PHP code with DI access can act as any enabled backend user —
the same power that $GLOBALS['BE_USER'] mutation already grants
every extension.
The API adds validation, guaranteed restoration, and honest audit
attribution; it does not add a privilege boundary.
Migrating from $GLOBALS['BE_USER'] mutation
Before this API, consumers impersonated a technical user by hydrating a
Backend via the @internal
set and swapping it into $GLOBALS['BE_USER']
(restoring it in a finally) so that vault's access control — which
read only that global — would see the identity:
$backendUser = new BackendUserAuthentication();
$backendUser->setBeUserByUid($technicalBeUserUid); // @internal API
$previous = $GLOBALS['BE_USER'] ?? null;
$GLOBALS['BE_USER'] = $backendUser; // visible to ALL code in this process
try {
$result = $callback();
} finally {
$GLOBALS['BE_USER'] = $previous;
}
That pattern is unsafe in any PHP process shared with a live visitor
request: while the scope is open, all code in the process observes
a fully privileged backend-user identity.
It also spreads @internal core API usage across every consumer.
Migration is mechanical:
- Inject
Technicalinstead of constructingActor Context Interface Backendyourself.User Authentication - Replace the global swap with
$context->run.As ($technical Be User Uid, $callback) - Drop any
Contextaspect swap done solely for vault — vault never reads thebackend.useraspect. Keep it only if other collaborators in the callback need the aspect. - Remove the record-validation code —
runrefuses missing, deleted, disabled, and time-restricted users itself.As ()
$GLOBALS['BE_USER'] is never touched by
run; an ambient
backend user (or the CLI placeholder a messenger worker runs under)
stays untouched and regains effect the moment the scope ends.