ADR-110: Service account scopes 

Status

Accepted

Date

2026-07-23

Authors

Netresearch DTT GmbH

Context 

Every stateful entry point now carries an explicit AiActorContext (ADR-091) instead of reading $GLOBALS['BE_USER']. An interactive caller is a backend user, authorised by ownership and admin rights. A non-interactive caller — a CLI command, a scheduler task, a queue worker — has no backend user, so it identifies itself as a named service account.

Until now a service account was trusted for everything: mayAccessSession(), mayActOnRun() and the restricted-configuration gate (ConfigurationResolver) all returned true for any service account. That is too coarse. A narrow automation — say a nightly job that only cancels stale runs — would, the moment it holds a service-account context, also be able to approve pending runs, read any conversation, and use configurations restricted to other groups. A single over-broad principal is exactly the escalation surface the actor context was introduced to close.

Decision 

A service account carries an explicit, minimal set of scopes (NetresearchNrLlmDomainEnumServiceAccountScope). Each entry point a service account can reach checks the one scope it requires; a service account that does not declare that scope is denied. Backend users are unaffected — scopes govern service accounts only, and hasScope() is always false for an interactive caller, so an entry point must still combine it with its own ownership/admin check.

Fail-closed 

serviceAccount($name) with no scopes may do nothing. A capability is granted only by naming it: `serviceAccount('cli:nrllm:agent:cancel', [ServiceAccountScope::AGENT_CANCEL]). There is no wildcard scope, so a new or mis-declared automation can never acquire a capability it did not ask for, and a truncated or tampered serialised row drops any value that is not a known scope (:php:AiActorContext::fromArray()`).

One scope per enforcement point 

The taxonomy is deliberately small — every case maps to exactly one existing gate, so there are no unenforced scopes:

  • agent:approveAgentRuntime::approve() / submitInput()
  • agent:cancelAgentRuntime::cancel()
  • agent:readAgentRuntime::status() / events()
  • conversation:accessConversationService::send()
  • configuration:use — restricted-configuration gate

Run operations do not share one scope: an account granted agent:cancel can cancel but neither read nor approve. This is why mayActOnRun() takes the required ServiceAccountScope rather than deciding one blanket verdict for all five run methods.

Scopes round-trip with the actor 

The queue persists the full actor with a queued run (ADR-102) and rehydrates it in the worker. Scopes are part of that serialisation, so a service account that enqueues work resumes with exactly the capabilities it started with — never more.

Consequences 

  • The single shipped service account (the nrllm:agent:cancel CLI command) declares agent:cancel and nothing else.
  • enqueue() / run() are not gated by a scope: who may start a run is decided by whoever builds the request (a controller behind backend auth, a CLI behind shell access), not by a runtime scope check. A dedicated agent:run scope is deferred until a service-account caller actually reaches those methods, so no unenforced scope is shipped.
  • conversation:access is a single read-and-continue capability; a finer read-only vs write split is deferred until a consumer needs it.
  • New service-account callers must declare their scopes explicitly — a scopeless account failing closed is the intended behaviour, not a regression.