ADR-130: Capability grants for backend users
- Status
-
Accepted
- Date
-
2026-08-06
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 doubly unreachable for non-admins today — both
human approval surfaces sit behind admin gates (the AgentRun module's
access => admin, the Playground'sdenyNonAdmin()). ThemayActOnRun()branch is real, tested code, but only becomes exercisable for non-admins with the editing module. Stated here so nobody reads it as an already-active control. - ``tasks_manage`` does not exist yet. The list/wizard actions have no per-action gate to migrate (they are module-gated only), and the trait's JSON 403 body is the wrong shape for HTML module actions. The grant arrives together with its consumer in the editing-module milestone.
- 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.