---
title: "ADR-037: Backend AJAX admin guard"
manual: "TYPO3 LLM Extension"
version: "0.35"
permalink: "https://docs.typo3.org/permalink/netresearch/nr-llm:adr-037@0.35"
source: "Adr/Adr037BackendAjaxAdminGuard.rst"
modified: "2026-09-16T22:09:16+00:00"
---

# 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 `SkillSourceController` enforced an admin check, via a private
`denyNonAdmin()` method duplicated nowhere else. Every other backend AJAX
controller was unguarded.

## Decision

1.  **One shared guard trait.** `RequiresBackendAdminTrait`
    (`Classes/Controller/Backend/`) exposes a single private
    `denyNonAdmin(): ?ResponseInterface` that returns `null` for an admin
    and a `403` `{"success": false, "error": "<message>"}` JSON response
    otherwise, where `<message>` is the localised
    `error.adminRequired` label. `SkillSourceController` now uses the
    trait; its identical private copy was deleted.
1.  **Guard every AJAX-routed action, at the very top.** Each action listed in
    `AjaxRoutes.php` begins with
    `if (($deny = $this->denyNonAdmin()) !== null) { return $deny; }` before
    any body parse, repository read, or side effect. All AJAX actions already
    return `ResponseInterface`, so the `JsonResponse` is
    type-compatible. The guard covers
    `LlmModuleController`, `ProviderController`, `ModelController`,
    `ConfigurationController`, `TaskRecordsController`,
    `TaskExecutionController`, `SetupWizardController`,
    `ToolPlaygroundController`, `ToolController` (the tool-management
    module split out later — [ADR-039](https://docs.typo3.org/permalink/netresearch/nr-llm:adr-039@0.35)) and the already-guarded
    `SkillSourceController` — every AJAX-routed action, matching the route
    table exactly.
1.  **Non-AJAX module actions are left untouched.** Extbase module actions
    (`listAction`, `indexAction`, `executeFormAction`,
    `wizardFormAction`, …) are reached through the `access => admin` module
    route and are already protected; adding the guard there would be redundant.
1.  **The standard accessor is \`\`$GLOBALS\['BE_USER'\]\`\`.** The guard reads the
    current backend user from `$GLOBALS['BE_USER']` and checks
    `instanceof BackendUserAuthentication` plus `isAdmin()`. 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.

## Consequences

-   ●● Every backend AJAX endpoint now requires a backend admin; a
    non-admin receives a uniform `403` and 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 admin
    `BackendUserAuthentication` stub). 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](https://docs.typo3.org/permalink/netresearch/nr-llm:adr-023@0.35) for backend capability permissions and
[ADR-012](https://docs.typo3.org/permalink/netresearch/nr-llm:adr-012@0.35) for API-key encryption (the keys these endpoints
would otherwise expose).
