---
title: "ADR-130: Capability grants for backend users"
manual: "TYPO3 LLM Extension"
version: "0.35"
permalink: "https://docs.typo3.org/permalink/netresearch/nr-llm:adr-130@0.35"
source: "Adr/Adr130BackendUserGrants.rst"
modified: "2026-09-16T22:09:16+00:00"
---

# ADR-130: Capability grants for backend users

-   *Status:*

    Accepted (constraint 3 enumerated the approval surfaces and the
    enumeration was not exhaustive — see [ADR-131](https://docs.typo3.org/permalink/netresearch/nr-llm:adr-131@0.35))

-   *Date:* 2026-08-06
-   *Amended:*

    2026-08-06 by [ADR-131](https://docs.typo3.org/permalink/netresearch/nr-llm:adr-131@0.35); 2026-08-18 by
    [ADR-169](https://docs.typo3.org/permalink/netresearch/nr-llm:adr-169@0.35) (named constraint 4's reserved `tasks_manage` is
    retired rather than fulfilled)

## Context

nr_llm is admin-only end to end: every backend module is \``access =>
admin``, every AJAX action carries ``denyNonAdmin()``, half the builtin
tools require an administrator. :ref:`ADR-117 \<adr-117>\` withdrew the first
attempt at finer permissions (capability checkboxes) on three verified
findings — no chokepoint for streaming, a polarity that turned enforcement
into a bypass on user-less paths, and inertness inside an admin-only UI —
but left one door open, verbatim: 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"*.

The product decision has now been made: a grant-set (not a role ladder),
nothing without an explicit grant, approval as its own grant outside any
preset, two coarse task grants, and a non-admin editing surface as a
separate follow-up milestone.

## Decision

**Grants, not roles.** `BackendUserGrant` is a string-backed enum
mirroring `ServiceAccountScope`: each case documents exactly one
enforcement point, there is no wildcard, and a case is only added together
with its consumer — a grant nothing reads is worse than none. Roles are
documentation-level presets (named grant bundles), not code.

**Assignment via TYPO3's own mechanism.** Grants are registered as
`customPermOptions` and assigned per backend **group** in the be_groups
access lists; enforcement reads `check('custom_options', …)` on the live
user. The core check short-circuits for administrators, so "admins hold
every grant implicitly" comes from the platform, not from our code. A
grant is revoked by unticking it — effective with the next request, since
every check reads the live group data.

**Frozen into the actor at the boundary.** `currentActor()` captures the
grant set next to the admin flag and group ids; downstream consumers read
the actor, never the ambient user. `AiActorContext::hasGrant()` is
fail-closed: false for service accounts (their mechanism stays scopes) and
for anonymous callers.

The two initial grants:

-   `tasks_use` — execute an existing task and refresh its input data
    (the two `TaskExecutionController` AJAX actions). The per-user budget
    pre-flight (audit REC 4) bounds what a grant holder can spend.
-   `agent_approve` — decide OTHER users' suspended runs, as a new branch
    in `AiActorContext::mayActOnRun()`; the human sibling of the
    `agent:approve` service-account scope. Deliberately the only scope
    with a grant equivalent — everything else stays owner-or-admin.

## Named constraints, each verified against the code

1.  **No colons in grant values.** TYPO3 strips `:|,` from custom
    permission item keys when rendering the be_groups select
    (`TcaItemsProcessorFunctions::populateCustomPermissionOptions()`); a
    colon-namespaced value would be stored mangled and every check would
    silently deny. The values are therefore underscore-separated
    (`tasks_use`), breaking the naming symmetry with
    `ServiceAccountScope` (`agent:approve`) on purpose.
1.  **The AJAX endpoints go live immediately.** AJAX routes bypass the
    module access check (that is why `denyNonAdmin()` exists, ADR-037).
    Swapping the two task-execution gates to `tasks_use` makes them
    reachable for grant holders **now**, without any UI — the intended end
    semantics, bounded by the per-user budget. This ADR must not be read as
    "inert until the editing module ships".
1.  **\`\`agent_approve\`\` is reachable for non-admins** — amended, see
    [ADR-131](https://docs.typo3.org/permalink/netresearch/nr-llm:adr-131@0.35). This record called the grant doubly
    unreachable because it named two human approval surfaces and both sat
    behind admin gates.

    Both gates still hold. `nrllm_runs` is still `'access' => 'admin'`
    in `Configuration/Backend/Modules.php`, and the Playground's
    `resumeAction()` and `submitInputAction()` still open with
    `denyNonAdmin()`. The editing module added a *third* surface that
    did not exist when this was written: `nrllm_aitasks` is
    `'access' => 'user'` and registers `AgentRunController::approve`
    and `submitInput`. A non-admin whose group has that module ticked
    **and** who holds the grant therefore decides other users' suspended
    runs today — which is what the grant is for. Both switches are
    required, and neither substitutes for the other
    ([ADR-131](https://docs.typo3.org/permalink/netresearch/nr-llm:adr-131@0.35) decision 2): the module tick alone grants no
    execution, and the grant alone reaches no surface.

    This record named its own expiry trigger. The sentence that stood here
    said the `mayActOnRun()` branch "only becomes exercisable for
    non-admins with the editing module" — and then the editing module
    shipped, with nothing carrying that prediction into the change that
    fired it. So the failure is not that the trigger went unforeseen; it
    is that a foreseen trigger left no mark anywhere a change had to pass.
    **A constraint that enumerates surfaces is only as durable as the
    enumeration.** "Both surfaces are gated" is a claim about a list, and
    a list stops being complete the moment someone registers the next
    entry, whether or not the record predicted it. Nothing in the codebase
    held the list.

    A constraint phrased against the *check* is falsifiable where one
    phrased against the *inventory* is not — but "no approval action sits
    outside an admin gate" is not the check to write, because this record
    deliberately made it false. What distinguishes an intended non-admin
    surface from an accidental one is the inventory written down and
    asserted, which
    `Tests/Unit/Configuration/ApprovalSurfaceInventoryTest.php` now
    does. It holds three lists, and each covers a
    bounded scope rather than the whole idea of an approval surface. It
    pins which modules register `approve`/`submitInput` and under
    which `access`. It pins which classes *under* `Classes/Controller`
    reach `AgentRuntime::approve()` or
    `AgentRuntime::submitInput()`. And it requires every AJAX route
    targeting one of three named actions — `resumeAction`,
    `submitInputAction`, `approveAction` — to open with
    `denyNonAdmin()`. It then requires this paragraph to name each
    module the scan finds, matched as a whole identifier rather than as a
    substring, so a name that merely extends one already written here
    does not satisfy it. No example of such a name is given, deliberately:
    writing one into this paragraph would be enough to satisfy the check
    for it.

    A fourth surface fails whichever of the three it falls inside.
    Registered as a module, it fails the module list and this paragraph's
    naming check — under any controller, not only `AgentRunController`.
    Written as a class under `Classes/Controller` that calls the
    runtime, it fails the controller list before it is registered
    anywhere. Exposed as an AJAX route on one of the three named actions,
    it fails unless that route guards itself. Whichever list it broke has
    to be edited; the module door additionally requires this text. Each
    of those failures was produced on purpose, by registering a fourth
    surface of that shape and watching the assertion fail, before this
    paragraph was allowed to claim it.

    What none of the three catches, stated so nobody reads more into them
    than they hold: an approval-calling class outside `Classes/Controller`,
    exposed under an action name that is not one of the three. That
    combination was tried and the suite stayed green. Widening the scan to
    every class in `Classes` would close it and was not done here — the
    check exists to make the enumeration in this paragraph falsifiable, not
    to become a second registry.

    What bounds the grant is therefore not module access alone. Three
    checks apply to a single approval, in the order
    `ResumeCoordinator::approve()` evaluates them. **This list
    describes the approve path only** — `submitInput` is the other
    action this constraint names, and its gates are set out after the
    list rather than merged into it, because the middle check does not
    exist there and a merged list of three would be false for it:

    -   `AiActorContext::mayActOnRun()` decides whether this actor may
        act on this run at all — owner, admin, or `agent_approve` holder.
        A refusal is `RunAccessDeniedException`.
    -   Only on a configuration with `require_second_approver` set, an
        *approval* (never a denial) from the run's own initiator is refused,
        admin and grant holder alike ([ADR-172](https://docs.typo3.org/permalink/netresearch/nr-llm:adr-172@0.35)). This is
        opt-in and narrows who may release one particular run
        (`ResumeCoordinatorFourEyesGateTest`).
    -   `approverRefusal()` resolves the approver's live backend user
        and asks `ToolCallPolicyInterface::decide()` about every
        pending call that declares a write, throwing
        `ApproverNotPermittedException` on a denial
        ([ADR-133](https://docs.typo3.org/permalink/netresearch/nr-llm:adr-133@0.35)). Unlike the previous check this one is
        not opt-in: `approve()` calls it for every approval decision, and
        only a *denial* skips it, because a denial executes nothing. Two
        things scope it — it looks at pending calls that declare a write, so
        a read-only turn passes it unexamined, and the `requiresAdmin()`
        axis it leans on is one the `tools.dataClassEnforcement: observe`
        switch does not relax (that switch governs the trust-zone axis
        only). This is what keeps the grant from becoming a write
        escalation: the grant admits the *decision*, ADR-133 withholds the
        *release* of a write the approver could not run themselves
        (`ResumeCoordinatorApproverGateTest`).

    None of the three consumes the turn. The first two throw above the
    `claimResume()` call, so the run is never claimed and simply stays
    `WAITING_FOR_APPROVAL`; the third claimed the state to read it and
    therefore calls `release()` before throwing. `AgentRunController`
    turns the latter two into a flash and a redirect, so a run one grant
    holder may not release stays decidable by someone who may — the two
    gate tests named above assert exactly that, each re-reading the run
    and finding it `WAITING_FOR_APPROVAL`.

    None of the three shrinks the set of runs the grant admits a decision
    on: that set is `mayActOnRun()`'s answer, and the other two act on
    the *outcome* of a decision already admitted. That is why they are
    named here and amend nothing about the grant itself.

    `submitInput()` is gated the same way minus the middle check. It
    opens with the same `AiActorContext::mayActOnRun()` call and the
    same `AGENT_APPROVE` scope, and it ends at
    `submitterRefusal()` ([ADR-150](https://docs.typo3.org/permalink/netresearch/nr-llm:adr-150@0.35)), the sibling of
    `approverRefusal()` against the submitter's live backend user.
    One rule differs: it asks the policy about **every** pending call
    rather than only those declaring a write, because an input-requiring
    tool declares no write — the input and approval markers are mutually
    exclusive at registration — so a write filter would select nothing
    and the gate would be decorative. There is no four-eyes check on that
    path: [ADR-172](https://docs.typo3.org/permalink/netresearch/nr-llm:adr-172@0.35) refuses a self-*approval*, and
    supplying input is not one.
1.  **\`\`tasks_manage\`\` is retired, not pending** — amended, see
    [ADR-169](https://docs.typo3.org/permalink/netresearch/nr-llm:adr-169@0.35). This constraint reserved the name and said the
    grant would arrive with the editing module.

    It will not, and the reason is this record's own rule rather than a change
    of mind: a case is added together with its enforcement point. ADR-169
    section 2 settles which records a non-admin may manage — `tx_nrllm_task`,
    `tx_nrllm_promptsnippet` and `tx_nrllm_configuration` — and section 4
    settles that they are managed through FormEngine with `exclude => true`
    on the governance and spend fields. Both halves are then authorised by
    `tables_modify` and `non_exclude_fields`, which are TYPO3's own
    permissions on the same fields. A `tasks_manage` case would sit in front
    of nothing.

    The original observation still holds and is why the name was reserved
    rather than built: the list and wizard actions carry no per-action gate to
    migrate, and the trait's JSON 403 body is the wrong shape for an HTML
    module action. What changed is that the gate they lack turns out not to be
    ours to add.
1.  **The record picker stays admin-only.** `TaskRecordsController`
    reads arbitrary table rows with only a housekeeping-prefix exclusion —
    no `tables_select` check, no denylist for `be_users`/`sys_log`/
    vault tables. Opening it to `tasks_use` without a read-boundary would
    be a data-exfiltration primitive; the denylist (modelled on the tool
    denylist) is a prerequisite the editing-module milestone owns.
1.  **Grants are group-scoped.** `custom_options` is a be_groups field;
    a user without groups cannot hold a grant.
1.  **The ADR-117 findings, answered:** the chokepoints here are concrete
    controller gates and `mayActOnRun()` (not a pipeline that streaming
    bypasses); the polarity is deny-on-absence everywhere, including
    user-less paths (`hasGrant()` is false for service accounts and
    anonymous callers, so the queue worker cannot flip a decision); and the
    inertness concern is exactly why the editing surface is a committed
    follow-up rather than an afterthought.

## Consequences

-   `AiActorContext::backendUser()` gains an optional `$grants`
    parameter and the context serialises/rehydrates the grant set with the
    same fail-closed `tryFrom` filtering as scopes (recorded in the API
    snapshot; CHANGELOG entry per the 0.x rules).
-   The resume path still reconstructs the run owner without grants
    (`ResumeCoordinator`) — harmless today because nothing on that path
    reads them; anything that ever does will see the fail-closed empty set.
-   Recommended presets (documentation, not code): *AI editor* =
    `tasks_use`; approval is granted separately and deliberately sits in
    no preset.
