ADR-063: Transport-Agnostic Authorization Expressions
- Status: Accepted
- Date: 2026-09-18
- Depends on: ADR-001 (Global Architecture), ADR-023 (Authentication Strategy)
- Amends: ADR-038 (Hide Forbidden As Not Found) — establishes that its masking is a property of the HTTP transport, not of the domain, and therefore does not hold on any other transport.
- Evidence: MCP feasibility spike, 17/09/2026
Context and Problem Statement
Most object-level authorization expressions in this codebase read the HTTP request:
security: "is_granted('TRIP_VIEW', request.attributes.get('id'))"
security: "is_granted('TRIP_EDIT', request.attributes.get('tripId'))"
Counted on api/src, there are 24 object-level expressions, of which 6 already use
the object form — all in Trip.php (lines 84, 100, 117, 133, 142, 161). 18 remain
coupled to the request, across 7 files: Trip.php, Stage.php, TripDetail.php,
TripRoute.php, MercureToken.php, AccommodationScan.php and Entity/TripShare.php.
This ADR therefore finishes a migration that is already a quarter done, rather than
starting one. That the object form is already in production here, working, is the best
evidence that it is the right target.
It is not harmless any more. The MCP spike proved that an API Platform operation can be invoked without an HTTP request. There are two failure modes, and the quieter one is the dangerous one.
Listing from the CLI — a loud failure.
ApiPlatform\Mcp\Security\ExpressionAccessChecker passes
['request' => $requestStack?->getCurrentRequest()], i.e. request is defined but
null, so the expression dies:
Unable to get property "attributes" of non-object "request".
Calling a tool over HTTP — a silent universal denial. ApiPlatform\Mcp\Server\Handler
passes that same getCurrentRequest(), but over the MCP transport there is a current
request: POST /mcp. It simply is not the trip route. So
request.attributes.get('tripId') does not throw — it returns null,
TripVoter::supports() rejects null, the voter abstains, and every caller is denied,
the owner included. ADR-038 then masks that as a perfectly plausible 404.
An exception is survivable; a rule that silently denies everyone and looks like a normal "not found" is not. This second mode is the real argument for the change.
The same coupling shows up outside authorization: TripCreateProcessor and
TripUpdateProcessor inject RequestStack solely to call
getPreferredLanguage(['en','fr']), so a trip created by a non-browser client silently
gets the en fallback.
The underlying defect is one of layering: authorization and locale are domain concerns that have been expressed in terms of a transport artefact.
Decision
Authorization expressions must not reference request. A single form replaces it:
name the URI variable.
- security: "is_granted('TRIP_EDIT', request.attributes.get('tripId'))"
+ security: "is_granted('TRIP_EDIT', tripId)"
ApiPlatform\Symfony\Security\State\AccessCheckerProvider already binds every URI
variable under its own name in the expression context:
// URI variables are exposed to the expression (e.g. is_granted('VIEW', user_id));
// reserved keys below must win on collision.
$resourceAccessCheckerContext = [
'object' => $body,
'previous_object' => ...,
'request' => $request,
] + $uriVariables;
So all 18 expressions are a one-token rewrite, with no change to any uriVariables,
Link or provider. The six Trip.php operations already using bare object stay as
they are: their provider returns a TripRequest, which TripVoter::supports() accepts.
Why not object.id, and why not Link(security:)
Both were considered and both are worse.
object.id is unnecessary: every one of these operations is keyed on {id} or
{tripId}, so the URI variable is available — and available earlier, see below.
Link(security:) is actively wrong for the goal. It is enforced by
SecurityParameterProvider, whose provide() calls $this->decorated->provide(...) on
its first line and only then checks. The provider has already run. For
MercureTokenProvider that would mean minting a Mercure JWT before refusing the
caller — precisely what the URI-variable form was supposed to avoid.
The check does not move: it stays before the provider
AccessCheckerProvider is wired four times into the chain, one of them at the
pre_read stage (decoration priority 10, the outermost). There it sets $body = null
and, when the expression references neither object nor previous_object
(ResourceAccessChecker::usesObjectVariable(), an AST walk), it evaluates before
calling the decorated provider.
is_granted('TRIP_EDIT', tripId) does not mention object, so the check happens exactly
where request.attributes.get('tripId') happened. The HTTP behaviour is unchanged,
MercureTokenProvider still mints nothing for a caller who will be refused, and its
docblock ("Ownership is enforced by the operation's security expression before this
runs") stays true.
ReadListener resolves $uriVariables before entering the chain, so they are in scope
from the outermost decorator. read: false does not change this: the listener always
calls the chain; it is ReadProvider, at the bottom, that consults canRead().
Both transports, one declaration
config/state/security.php (HTTP) and config/mcp/security.php (MCP) declare the same
four decorators, at the same priorities, over the same
api_platform.security.resource_access_checker.
On the MCP side, Mcp\Server\Handler fills $uriVariables from the JSON-RPC arguments
using the operation's declared URI variables, then calls that same chain. At tools/list
the variable is not yet bound, which raises a SyntaxError that ExpressionAccessChecker
catches on purpose — its comment names "uri variables" among the deferred cases, so
the element stays visible and the rule is enforced on the call. This form is the one the
framework anticipates.
⚠ Limit —
McpResourcehas no URI variables. ThatHandlerloop is guarded byif (!$isResource): anMcpResourcereceives an empty$uriVariables, so this form does not apply to it. An MCP resource needing object-level authorization must be modelled as a tool, or express its rule another way. ADR-064's excluded-surface list proposesexport_tripas anMcpResource; that choice has to be revisited when the tools are built.
Locale comes from the user, not from the request
TripCreateProcessor, TripUpdateProcessor and GpxUploadController read
$user->getLocale(). This removes RequestStack from both processors, and is more
correct regardless of transport: a stored preference beats a browser header the server
may never see.
That preference had to become writable first. User::$locale existed and defaulted to
fr, but only CreateUserCommand --locale ever wrote it — no route could change it.
Switching the trip locale onto it without opening a write would have made every trip
French for any account created with the default. So this ADR also introduces
PATCH /users/me (AccountUpdate input, AccountMe output), alongside the existing
GET/DELETE, with User::SUPPORTED_LOCALES replacing the four hard-coded ['fr','en']
lists.
/users/me, not /users/{id}: the whole Account resource resolves the current user from
the security token and never from a URL identifier, which is what gives it no IDOR
surface. It is also the more transport-agnostic shape — an agent holding a token does not
know its user's UUID.
The clients keep both directions in sync, and both are required. Making the account the source of truth for rendered content means the interface and the content can now disagree, in either direction:
| Missing direction | What the user sees |
|---|---|
| The switcher does not push | Interface switches to English, alerts stay French, and only the CLI can fix it |
| Login does not read | Account is en, a fresh browser opens the interface in French next to English alerts |
So the language switchers PATCH /users/me (fire-and-forget: switching language is local
and immediate, and must not be held hostage to the network), and the session adopts the
account's locale when it starts — the web BFF writes the locale cookie from
GET /users/me while it still holds the fresh JWT, and the mobile session effect, which
already fetched that endpoint for the email, applies it to i18next.
Both clients were previously wired to carry the interface language to the server —
the PWA through Accept-Language, mobile through an explicit header its middleware sets
because React Native's fetch does not (#1169). This replaces that link rather than
removing it. On mobile it also supplies the language persistence the app never had: i18next
starts from the device locale, and the account's choice takes over on a restored session.
RequestStack stays only where the transport genuinely is the subject
/auth/* and the early-access flow (AuthSessionProvider, AuthRequestLinkProcessor,
AccessRequestCreateProcessor, RequestEmailChangeProcessor) may keep it: they are
browser-bound by design and are excluded from any non-HTTP transport.
Consequences
The masking of ADR-038 does not survive a change of transport
ADR-038 hides object-level denials as 404 so that trip UUIDs cannot be enumerated. It is
implemented by HideForbiddenAsNotFoundListener, an HTTP-kernel exception listener.
The spike measured what a non-HTTP transport returns:
someone else's trip : Access Denied.
unknown trip : Trip "01936f6e-…-09ff" not found.
Two distinguishable answers — the enumeration ADR-038 closes on HTTP is open elsewhere. This ADR records the cause rather than the symptom: the masking is a rendering rule that lives in the transport layer, so it evaporates the moment the domain is reached by another road.
Any transport added to this application must therefore either reproduce the masking or explicitly accept that it does not apply. That decision belongs to the ADR introducing the transport, and is taken for MCP in ADR-064.
Never convert a URI variable into an object
The value reaches the voter as the raw string from the route, which
TripVoter::supports() accepts. UriVariablesConverter leaves it alone here because the
identifier properties are declared string (or absent from the DTO), and the only
transformers registered are integer, date_time and api_resource.
"Tidying" a Link into fromClass: TripRequest::class, identifiers: ['id'] would break
that: TripRequest::$id is a Uuid, supports() would return false, the voter would
abstain and access would be denied silently — and ADR-038 would dress the failure up as
a credible 404. A denial that comes from a misconfiguration is indistinguishable from a
denial that comes from the rule.
That is the general shape of the risk here, and it is why every migrated expression
needs a functional test proving denial, not a careful reading. The same reasoning
applies to SecurityParameterProvider, which does continue when it cannot resolve a
target resource (getFromClass() ?? getToClass()): a Link lacking a class carries no
authorization at all, without any error. That trap does not affect the form chosen here —
we do not use Link(security:) — but it is the reason not to reach for it later either.
Positive
- Authorization becomes a property of the domain, expressible once for every transport.
- Two processors lose their
RequestStackdependency. - The HTTP behaviour is unchanged: the same expressions work, only their inputs differ.
Alternatives considered
Keep request. and give each transport its own expressions. Rejected: it doubles the
authorization surface — the part of the codebase where duplication is least acceptable —
and guarantees the two copies drift.
Extend TripVoter to accept the response DTOs, and use bare object. Rejected: it
widens the voter's contract to solve a problem that object.id already solves, does
nothing for the 15 expressions that need the URI-variable form, and would not help
MercureToken at all — its DTO carries no trip identity for the voter to read.
Move all checks to pre_read. Rejected: AccessCheckerProvider sets object to null
at that stage by construction, so it cannot express object-level rules at all.