---
title: "ADR-110: Service account scopes"
manual: "TYPO3 LLM Extension"
version: "0.35"
permalink: "https://docs.typo3.org/permalink/netresearch/nr-llm:adr-110@0.35"
source: "Adr/Adr110ServiceAccountScopes.rst"
modified: "2026-09-16T22:09:16+00:00"
---

# 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](https://docs.typo3.org/permalink/netresearch/nr-llm:adr-091@0.35)) 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:approve` — `AgentRuntime::approve()` / `submitInput()`
-   `agent:cancel` — `AgentRuntime::cancel()`
-   `agent:read` — `AgentRuntime::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 `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](https://docs.typo3.org/permalink/netresearch/nr-llm:adr-102@0.35))
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.
