ADR-130: Capability grants for backend users
- Status
-
Accepted (constraint 3 enumerated the approval surfaces and the enumeration was not exhaustive — see ADR-131)
- Date
-
2026-08-06
- Amended
-
2026-08-06 by ADR-131; 2026-08-18 by ADR-169 (named constraint 4's reserved
tasks_manageis retired rather than fulfilled)
Context
nr_llm is admin-only end to end: every backend module is `access =>
admin``, every AJAX action carries ``deny, half the builtin
tools require an administrator. :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.
Backend is a string-backed enum
mirroring
Service: 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.
Ai 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 twoTaskExecutionControllerAJAX 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 inAi; the human sibling of theActor Context:: may Act On Run () agent:approveservice-account scope. Deliberately the only scope with a grant equivalent — everything else stays owner-or-admin.
Named constraints, each verified against the code
- 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 withServiceAccountScope(agent:approve) on purpose. - 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 totasks_usemakes 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". -
``agent_approve`` is reachable for non-admins — amended, see ADR-131. 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_runsis still'access' => 'admin'inConfiguration/Backend/Modules.php, and the Playground'sresumeAction()andsubmitInputAction()still open withdenyNonAdmin(). The editing module added a third surface that did not exist when this was written:nrllm_aitasksis'access' => 'user'and registersAgentRunController::approveandsubmitInput. 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 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.phpnow does. It holds three lists, and each covers a bounded scope rather than the whole idea of an approval surface. It pins which modules registerapprove/submitInputand under whichaccess. It pins which classes underClasses/ControllerreachAgentorRuntime:: approve () Agent. And it requires every AJAX route targeting one of three named actions —Runtime:: submit Input () resumeAction,submitInputAction,approveAction— to open withdenyNonAdmin(). 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 underClasses/Controllerthat 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 inClasseswould 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
Resumeevaluates them. This list describes the approve path only —Coordinator:: approve () submitInputis 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:Aidecides whether this actor may act on this run at all — owner, admin, orActor Context:: may Act On Run () agent_approveholder. A refusal isRunAccessDeniedException.- Only on a configuration with
require_second_approverset, an approval (never a denial) from the run's own initiator is refused, admin and grant holder alike (ADR-172). This is opt-in and narrows who may release one particular run (ResumeCoordinatorFourEyesGateTest). approverresolves the approver's live backend user and asksRefusal () Toolabout every pending call that declares a write, throwingCall Policy Interface:: decide () Approveron a denial (ADR-133). Unlike the previous check this one is not opt-in:Not Permitted Exception 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 therequiresAdmin()axis it leans on is one thetools.dataClassEnforcement: observeswitch 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 staysWAITING_FOR_APPROVAL; the third claimed the state to read it and therefore callsrelease()before throwing.AgentRunControllerturns 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 itWAITING_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 sameAicall and the sameActor Context:: may Act On Run () AGENT_APPROVEscope, and it ends atsubmitter(ADR-150), the sibling ofRefusal () approveragainst 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 refuses a self-approval, and supplying input is not one.Refusal () -
``tasks_manage`` is retired, not pending — amended, see ADR-169. 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_promptsnippetandtx_nrllm_configuration— and section 4 settles that they are managed through FormEngine withexclude => trueon the governance and spend fields. Both halves are then authorised bytables_modifyandnon_exclude_fields, which are TYPO3's own permissions on the same fields. Atasks_managecase 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.
- The record picker stays admin-only.
TaskRecordsControllerreads arbitrary table rows with only a housekeeping-prefix exclusion — notables_selectcheck, no denylist forbe_users/sys_log/ vault tables. Opening it totasks_usewithout a read-boundary would be a data-exfiltration primitive; the denylist (modelled on the tool denylist) is a prerequisite the editing-module milestone owns. - Grants are group-scoped.
custom_optionsis a be_groups field; a user without groups cannot hold a grant. - 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$grantsparameter and the context serialises/rehydrates the grant set with the same fail-closedtryFromfiltering 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.