---
title: "Technical actor context"
manual: "nr-vault"
version: "1.0"
permalink: "https://docs.typo3.org/permalink/netresearch/nr-vault:developer-technical-actor-context@1.0"
source: "Developer/TechnicalActorContext.rst"
rendered: "2026-09-18T07:37:50+00:00"
---

# Technical actor context {#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\NrVault\Security\TechnicalActorContextInterface`
provides that as a scoped API (see
[ADR-029: Scoped technical-actor identity for headless use](https://docs.typo3.org/permalink/netresearch/nr-vault:adr-029-technical-actor-context@1.0)):

**EXT:my_extension/Classes/MessageHandler/IngestHandler.php**

```php
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 {#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](https://docs.typo3.org/permalink/netresearch/nr-vault:adr-005-access-control@1.0) (including stale-group
filtering).
Groups are resolved exactly like a real login, including subgroup
expansion.

[Operation permissions](https://docs.typo3.org/permalink/netresearch/nr-vault:security-operation-permissions@1.0) resolve
differently, because a technical actor has no authenticated session whose
`groupData` could be consulted. A non-admin actor holds exactly what the
`tx_nrvault` custom permission options on its (subgroup-expanded)
`be_groups` rows grant, read directly from the database. This is
fail-closed: no groups means no grant.

The one implicit grant is `secret.use`. Headless consumption is the whole
purpose of a technical actor, and gating it on a group-level option would
break every existing `runAs()` caller while adding nothing — the
per-secret tier already decides which secrets the actor may read. Every other
operation, including `secret.create`, `secret.rotate`, `secret.delete`
and `secret.manage_policy`, must be granted explicitly.

Validation is fail-closed and happens **before** the callable runs.
`runAs()` throws a typed
`\Netresearch\NrVault\Exception\TechnicalActorException` 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 `runAs()` calls stack; the innermost actor wins and each
scope restores the previous one.

The audit log records the scope honestly: entries written inside
`runAs()` carry the actor's uid and username with
`actor_type = 'technical'`, sealed into the tamper-evident HMAC
chain like every other attribution field.

> [!NOTE]
> `runAs()` 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 {#migrating-from-globals-be-user-mutation}

Before this API, consumers impersonated a technical user by hydrating a
`BackendUserAuthentication` via the `@internal`
`setBeUserByUid()` 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:

**Legacy consumer workaround (do not copy)**

```php
$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:

1.  Inject `TechnicalActorContextInterface` instead of constructing
    `BackendUserAuthentication` yourself.
1.  Replace the global swap with
    `$context->runAs($technicalBeUserUid, $callback)`.
1.  Drop any `Context` aspect swap done *solely* for vault — vault
    never reads the `backend.user` aspect.
    Keep it only if other collaborators in the callback need the aspect.
1.  Remove the record-validation code — `runAs()` refuses missing,
    deleted, disabled, and time-restricted users itself.

`$GLOBALS['BE_USER']` is never touched by `runAs()`; an ambient
backend user (or the CLI placeholder a messenger worker runs under)
stays untouched and regains effect the moment the scope ends.
