---
title: "ADR-117: Withdraw the backend capability permissions"
manual: "TYPO3 LLM Extension"
version: "0.35"
permalink: "https://docs.typo3.org/permalink/netresearch/nr-llm:adr-117@0.35"
source: "Adr/Adr117WithdrawCapabilityPermissions.rst"
modified: "2026-09-16T22:09:16+00:00"
---

# ADR-117: Withdraw the backend capability permissions

-   *Status:* Accepted
-   *Date:* 2026-07-27
-   *Supersedes:* [ADR-023](https://docs.typo3.org/permalink/netresearch/nr-llm:adr-023@0.35)
-   *Authors:* Netresearch DTT GmbH

## Context

[ADR-023](https://docs.typo3.org/permalink/netresearch/nr-llm:adr-023@0.35) registered every `ModelCapability` case as a native
TYPO3 backend group permission and shipped `CapabilityPermissionService` to
resolve the check. It deliberately stopped there: the ADR states that it ships
"the registration + check primitive" and does **not** gate existing calls, and
lists injecting the check into the feature services as a rejected alternative
for that release.

The follow-up never came. Every backend group record therefore shows eleven
checkboxes — chat, completion, embeddings, vision, streaming, tools, JSON mode,
audio, image, text-to-speech, transcription — that look like an access control
and change nothing. An administrator who unticks "vision" for an editor group
gets no warning and no effect.

## Decision

**Remove the registration, the service and its interface rather than wiring the
check in.**

Three findings decided it, each verified against the code rather than inferred:

**There is no chokepoint to gate.** The middleware pipeline covers chat,
completion, embedding, vision, tools and every specialized call, but streaming
is routed to `StreamingDispatcher` instead and bypasses the pipeline
entirely. `ModelCapability::STREAMING` — the capability an operator is most
likely to withhold for cost reasons — would be structurally unenforceable, so
the control would be incomplete by construction.

**The check's polarity turns enforcement into a bypass.**
`CapabilityPermissionService::isAllowed()` returns `true` when no
backend user is present. The queue worker never populates
`$GLOBALS['BE_USER']` — `ActingBackendUserResolver` builds a detached
user object instead — so the same run would be denied synchronously and allowed
after `enqueue()`. Reversing the polarity instead denies every user-less
path: service-account runs, runs whose initiator was deleted or disabled, and
the evaluation commands. `ServiceAccountScope` has no capability-shaped
case, so a CLI job cannot be granted "may use embeddings" at all.

**It would be inert where the UI implies it applies.** All thirteen nr_llm
backend modules are `'access' => 'admin'` and administrators bypass the
check by definition, so unticking a capability would change nothing inside
nr_llm's own interface. Only third-party consumers would be affected — a
half-enforcement that is harder to reason about than no enforcement.

### Alternatives considered

**Wire the check in behind an opt-in switch, with an upgrade wizard that
pre-ticks all eleven boxes for existing groups.** The migration shape exists in
this codebase ([ADR-115](https://docs.typo3.org/permalink/netresearch/nr-llm:adr-115@0.35) and
`DataClassEnforcementDefaultUpdateWizard`) and would work. Rejected on
value, not on feasibility: it would buy an incomplete control over third-party
consumers only, at the cost of a new chokepoint, a wizard, and an answer for
every user-less execution path.

**Keep the checkboxes and relabel them as reserved.** Rejected: a control that
has to be labelled "has no effect" is worse than its absence.

## Consequences

-   **Breaking for consumers.** `CapabilityPermissionService` and
    `CapabilityPermissionServiceInterface` are removed, along with the DI
    alias and the `Documentation/Developer/CapabilityPermissions.rst` guide that
    described their use. A consumer injecting either must drop the dependency.
    Nothing inside nr_llm called them.
-   **The checkboxes disappear from the backend group form.** Values previously
    ticked remain in `be_groups.custom_options` as inert strings
    (`nrllm:capability_*`). They are never read again; no migration removes
    them, because an unknown entry in that field has no effect.
-   **Access control is unchanged**, because there was none to change. The gate
    that does work stays what it was: the per-configuration `allowed_groups`
    relation, enforced at runtime by `LlmConfigurationService::hasAccess()`
    and `ConfigurationResolver` ([ADR-070](https://docs.typo3.org/permalink/netresearch/nr-llm:adr-070@0.35)).
-   **The door stays open.** Should per-capability permissions become worth it,
    they should be designed onto `AiActorContext` — which already carries the
    acting identity across the synchronous, queued and service-account paths —
    rather than onto the ambient superglobal this ADR removes.
