ADR-110: Service account scopes
- Status
-
Accepted
- Date
-
2026-07-23
- Authors
-
Netresearch DTT GmbH
Context
Every stateful entry point now carries an explicit
Ai (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
(Configuration) 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
(Netresearch). 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 has 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: `service. There is no wildcard scope, so a new or
mis-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:approve—Agent/Runtime:: approve () submitInput()agent:cancel—AgentRuntime:: cancel () agent:read—Agent/Runtime:: status () events()conversation:access—ConversationService:: 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 may takes the
required Service 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:cancelCLI command) declaresagent:canceland 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 dedicatedagent:runscope is deferred until a service-account caller actually reaches those methods, so no unenforced scope is shipped.conversation:accessis 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.