ADR-037: Backend AJAX admin guard
- Status
-
Accepted
- Date
-
2026-06-28
- Authors
-
Netresearch DTT GmbH
Context
The nrllm backend module is registered with access => admin, so TYPO3's
module dispatcher only renders its controllers for backend administrators.
The module's interactive features, however, are driven by standalone AJAX
routes declared in Configuration/Backend/AjaxRoutes.php (ajax_nrllm_*).
These routes are dispatched by the generic backend AJAX route handler, not
through the module route — so the module's access => admin check never runs
for them.
The practical effect: any authenticated backend user (including a low-privilege editor) could call these endpoints directly. The exposed surface is broad and sensitive — provider/model/configuration state mutations (toggle-active, set-default), provider and model test calls that decrypt vault-stored API keys and reach out to upstream LLMs, task execution (which spends budget and runs the configured prompt), reading of arbitrary TYPO3 records via the task record picker, the tool playground's run (which executes the agent loop, spending budget and invoking registered tools) and tool toggle, and the setup wizard's save which creates providers and stores new API keys in the vault.
Only Skill enforced an admin check, via a private
denyNonAdmin() method duplicated nowhere else. Every other backend AJAX
controller was unguarded.
Decision
- One shared guard trait.
Requires(Backend Admin Trait Classes/Controller/Backend/) exposes a single privatedenyNonAdmin(): ?ResponseInterfacethat returnsnullfor an admin and a403{"success": false, "error": "<message>"}JSON response otherwise, where<message>is the localisederror.adminRequiredlabel.Skillnow uses the trait; its identical private copy was deleted.Source Controller - Guard every AJAX-routed action, at the very top. Each action listed in
AjaxRoutes.phpbegins withif (($deny = $this->denyNonAdmin()) !== null) { return $deny; }before any body parse, repository read, or side effect. All AJAX actions already returnResponse, so theInterface Jsonis type-compatible. The guard coversResponse Llm,Module Controller Provider,Controller Model,Controller Configuration,Controller Task,Records Controller Task,Execution Controller Setup,Wizard Controller Tool,Playground Controller Tool(the tool-management module split out later — ADR-039) and the already-guardedController Skill— every AJAX-routed action, matching the route table exactly.Source Controller - Non-AJAX module actions are left untouched. Extbase module actions
(
listAction,indexAction,executeFormAction,wizardFormAction, …) are reached through theaccess => adminmodule route and are already protected; adding the guard there would be redundant. - The standard accessor is ``$GLOBALS['BE_USER']``. The guard reads the
current backend user from
$GLOBALS['BE_USER']and checksinstanceof BackendplusUser Authentication is. This is the conventional accessor for the authenticated backend user in this context — the AJAX route handler has already established the backend user session by the time the controller action runs, and using the global keeps the guard a zero-dependency trait that any controller can adopt without constructor changes.Admin ()
Consequences
- ●● Every backend AJAX endpoint now requires a backend admin; a
non-admin receives a uniform
403and no state is mutated, no vault key is decrypted, no upstream LLM is called, and no arbitrary record is read. - ● A single shared trait removes the duplicated guard and makes "add the guard" the obvious, one-line step for any future backend AJAX action.
- ● The guard short-circuits before request-body parsing, so it is cheap and cannot be bypassed by malformed input.
- ◐ Tests that exercise these actions must now set up an admin
$GLOBALS['BE_USER'](functional:setUpBackendUser(1); unit: an adminBackendUserAuthenticationstub). This is a one-time, mechanical update to the existing controller test suites. - ◐
$GLOBALS['BE_USER']is a global accessor rather than an injected dependency. It matches existing project usage and keeps the trait dependency-free, but it is global state and is set/reset explicitly in tests. - ✕ This is an authorization (admin-only) control, not per-record or per-table access control: an admin retains full access to every endpoint, including reading arbitrary records through the task picker. Finer-grained authorization is out of scope.
See ADR-023 for backend capability permissions and ADR-012 for API-key encryption (the keys these endpoints would otherwise expose).