D109 — Signature-only documents stream route (BFF sealed session)
Architecture decision record. Status, thematic clusters, and how to record a new ADR: the decision log index. The mechanics this ADR describes live in
app-modules/documents/routes/documents-routes.php,app-modules/documents/src/Queries/StreamDocumentQuery.php, andapp-modules/documents/src/Commands/LogDocumentAccessCommand.php.
Context
The documents vault (D43) serves a downloaded file through a two-hop, signed-URL flow: a client
first calls GET /api/v1/documents/{document}/download (authenticated, auth:sanctum +
tenant.member, gated on DocumentPermission::Download) to mint a ~90s URL::temporarySignedRoute
to GET /api/v1/documents/{document}/stream; the client then follows that URL to get the bytes. Until
this decision, stream sat inside the SAME authenticated route group as every other documents
route — auth:sanctum + AuthenticateSession + tenant.member + account-active +
account-not-frozen — plus ValidateSignature on top.
The product moved to a Backend-for-Frontend (BFF) architecture: the browser talks only to the
BFF, which holds the real session; the browser itself never receives or presents a Laravel session
cookie or a Sanctum token to this API. That breaks the stream hop specifically: a browser action
like window.open(signedUrl) — the natural way to trigger a file download/viewer — is a plain
top-level navigation carrying no BFF-injected credentials. It could never satisfy auth:sanctum. The
download hop is unaffected (the BFF calls it server-to-server, with its own session), but the
signed URL it returns is handed to the browser, and the browser's own subsequent request to stream
is the one that would 401.
Decision
stream becomes signature-only. auth:sanctum, AuthenticateSession, tenant.member, and
account-active are dropped from this one route — every other documents route keeps its full
authenticated stack unchanged. Its route group carries four middleware in a security-critical order:
ValidateSignature → EnsureSignedTenantMatchesRequest → account-not-frozen → PinClientGuard. The
order and the two stream-scoped middleware are load-bearing hardening (see the four credential bullets
below); an earlier revision that mounted only [account-not-frozen, ValidateSignature] had a
pre-signature cross-tenant compliance oracle and a co-resident-guard bug, both closed here.
The credential model is now entirely front-loaded onto the signature:
- Permission moves to issue-time-only.
DocumentPermission::Downloadis checked exactly once, byIssueDocumentDownloadUrlQuery'sChecksPermissiongate, when the BFF (holding the real session) asks for a download URL.StreamDocumentQuery::authorize()— dispatched by thestreamcontroller — is an honestreturn true: re-checking a permission against$actor, which is now alwaysnullon this route, would only ever deny the request, not add a real check. It is allow-listed intests/Architecture/AuthorizationCoverageTest.php's PUBLIC_MESSAGES register (the same honest registerVerifyMfaChallengeCommand— a different token-gated entry point — already sits in). - The signature IS the credential. A valid
ValidateSignaturepass is proof that an actor who heldDocumentPermission::Downloadasked for this exact document within the lastdocuments.download_url_ttl(~90s). There is no weaker check standing in its place — there is no check standing in its place at all; the signature is the only gate. - Signature is proven BEFORE any tenant-state check (middleware order).
ValidateSignatureruns FIRST in the group. If a tenant-state check (account-not-frozen) ran before signature proof, an anonymous caller presenting no signature but a?tenant=/X-Tenantpointing at a victim tenant would reachEnsureAccountNotFrozen— which needs onlytenant('id')— and receive a distinguishable 403ACCOUNT_FROZEN/ACCOUNT_CLOSED: an anonymous cross-tenant compliance-status oracle. WithValidateSignaturefirst, a no/forged-signature request is refused identically for a frozen and an active tenant, before any tenant state is evaluated. (An earlier revision mounted[account-not-frozen, ValidateSignature]— this order was the defect.) - The evaluated tenant is the SIGNED tenant, never an unsigned header (
EnsureSignedTenantMatchesRequest). Tenancy is resolved by the outerInitializeTenancyByRequestDatawith header > cookie > query-parameter precedence, and theX-Tenantheader /tenantcookie are NOT part of the signed URL. So a signature minted for tenant A could otherwise be re-pointed at tenant B — steering both RLS and the freeze gate to B — via an unsignedX-Tenant: Bheader, re-opening the same freeze/closure oracle and a cross-tenant document-resolution vector. A stream-scoped middleware (running afterValidateSignature, so the signedtenantparam it trusts is tamper-proof, and beforeaccount-not-frozen) asserts the RESOLVEDtenant('uuid')equals the signedtenantparam (precedence-agnostic), refusing any mismatch with a generic 403 before the freeze gate evaluates anyone. This is whyIssueDocumentDownloadUrlHandleralways binds thetenantparam into the signature (it was already minted for the self-contained-navigation reason; here it becomes the anchor the serve is pinned to). - The freeze/closure gate is RETAINED and now guaranteed to evaluate ONLY the signed tenant.
account-not-frozen(EnsureAccountNotFrozen→ the compliance-ownedAccountFreezeGuard) only requires that tenancy be initialized (tenant('id') !== null); it never touches$request->user(). Placed AFTER the two guards above, it evaluates the freeze/closure state of the signed tenant and no other: a frozen tenant'sstreamrequest still 403sACCOUNT_FROZEN; a closed tenant's still 403sACCOUNT_CLOSED— proven with no session inSignatureOnlyStreamTest(for the coded envelope, i.e. anAccept: application/jsonfetch — see "Error-envelope honesty" below for the bare-navigation shape). - The ambient guard is pinned to
client(PinClientGuard). Droppingauth:sanctumalso dropped theAuthenticatemiddleware's implicitshouldUse('sanctum' → client)pin, so the ambient default guard falls back toweb(config/auth.php). A co-resident backoffice operator session in the same browser would then resolve as the bus actor (viaAuditsBusMessages::currentActor(), which callsAuth::user()on the default guard), andLogDocumentAccessCommand::authorize()(accepts onlynull | ClientUser) would DENY the access-log command — throwing on the controller's uncaught success-pathlogAccess()and failing an otherwise-valid signed download. A stream-scoped middleware pins the default guard toclient(mirroring the explicit-client-guard precedent inListNotificationsHandler), soAuth::user()resolves againstclientonly: a BFF browser →null, a co-resident client SPA → itsClientUser, both accepted. LogDocumentAccessCommandis null-actor-tolerant, and the accessor is captured from a signedissuerparam (audit-who — CLOSED, not a gap). Itsauthorize()reads$actor === null || $actor instanceof ClientUser(the old$actor instanceof ClientUserwould have silently denied every session-less serve, breaking the D6 every-serve-is-logged guarantee). But the written accessor is no longer leftNULL: the serve has no session, so instead of reading an ambient guard,IssueDocumentDownloadUrlHandlerbinds the issuing client user's PUBLIC uuid into the signature as an additional signedissuerparam, the controller threads it intoLogDocumentAccessCommand::$issuerUuid, andLogDocumentAccessHandlerresolves the accessor deterministically from it (aClientUserlooked up by uuid under the current tenant's RLS scope) and stampsaccessor_type/accessor_id. Semantics: this records who requested the download URL — the initiator within the ~90s bearer window — NOT a re-authenticated byte-fetcher (which the session-less route cannot identify). It is a logging field only, never an authz input (tamper-proof because signed). Backward-compatible: an in-flight URL minted before this rollout carries noissuer, so the accessor is stampedNULLand the serve still lands. The DLP capture paths (ReadsClassifiedData/EgressesDataonStreamDocumentQuery) were already null-actor-tolerant and needed no change.
Error-envelope honesty (what a bare navigation actually observes)
The stream refusals above (freeze/closure 403, forged/expired 403, shredded/quarantined/integrity 409)
only render the machine-readable JSON:API envelope — { errors: [{ code, status, … }] } with a
stable errors[0].code clients branch on — when the request sends Accept: application/json. That
is the condition each JsonApiExceptionHandler renderable checks ($request->expectsJson()); the
handler is deliberately NOT modified by this decision.
A bare browser navigation — window.open(signedUrl), the natural download trigger and the whole
reason stream is signature-only — sends the browser's default Accept: text/html…, so
expectsJson() is false and those renderables fall through. The bootstrap
shouldRenderJsonWhen(fn ($r) => $r->is('api/*')) still keeps the body JSON on this route, but as the
framework-generic { "message": … } shape carrying the status only — no errors[0].code. So a
bare window.open gets status-level distinguishability (403 vs 409 vs 200) and a generic body; it
does NOT get the coded envelope.
Consequently: an FE/BFF that needs to branch on the machine-readable refusal code MUST fetch the signed
URL with Accept: application/json (an XHR/fetch/blob download), not navigate to it. This is pinned
by SignatureOnlyStreamTest on both paths — the existing getJson() cases assert the coded envelope
(the Accept: application/json contract), and added bare-get() cases assert the actual observable for
a plain navigation (status + generic body, no errors[0].code) so the contract is honest in both
directions.
What is accepted as a residual gap
account-active and tenant.member are user-level checks — "is this specific ClientUser still
active" and "does this specific ClientUser still belong to this tenant." Both read
$request->user(), which stream no longer has. They move to issue-time-only: they still run on
the download hop (inside the unchanged authenticated group), gating who may mint a signed URL in the
first place. Once a URL is minted, a user who is deactivated or removed from the tenant in the
following ≤90s can still have their in-flight URL redeemed for a stream. This is a deliberate,
bounded, accepted trade-off — not an oversight:
- the window is capped by
documents.download_url_ttl(~90s), the same bound that already applied to every other risk this signature carries (a leaked URL, a replay); - the account-freeze/closure gate — the control this app treats as the serious one (D48, "a frozen
tenant cannot even READ") — is NOT weakened by this decision; it rides the signed
tenantparam and stays enforced with no residual window; - a user who is merely deactivated/removed mid-session already has other live artifacts (e.g. other already-issued tokens) with comparable exposure windows in a system that does not do real-time session revocation on every authorization change; this residual is not qualitatively new.
Alternatives rejected
- BFF byte-proxy — have the BFF itself fetch the bytes from
stream(server-to-server, its own session intact) and stream them back to the browser. Rejected: it makes the BFF hold and re-emit every document byte for every download, doubling bandwidth and latency on the hot path, and turns a short-lived signed redirect into a long-lived proxied connection the BFF must keep open — a much larger blast-radius change for a problem the signature already solves cleanly. - BFF 302-following — have the BFF issue the signed URL, then 302 the BROWSER to it with some
BFF-minted short-lived cookie/header riding along. Rejected: this reintroduces a session-shaped
credential on the browser side exactly where the whole point of the BFF is to avoid that; it also
couples
stream's auth model to BFF-specific plumbing instead of a plain, portable, signed URL any client (including a future non-BFF one) can follow.
Both alternatives were rejected in favor of leaning further into the signature — which the module
already treated as "the credential" (see the module README's pre-existing framing of the download/
stream pair) — rather than inventing a new credential shape to bridge the gap.
Consequences
- Un-migrated twin: payments
exports.download. The payments module'sGET /api/v1/payments/exports/downloadroute sits in the same authenticated + signed-signature stack that documentsstreamjust left (auth:sanctum+AuthenticateSession+ValidateSignature), and therefore has the identical BFF/window.opensession-auth contradiction. It was deliberately left un-migrated by this documents-scoped decision, pending its own separate BFF-download architecture decision (a payments-scope equivalent of the alternatives rejected here). Tracked indocs/tracking/conduit-payments-deferrals.md. tests/Architecture/AuthorizationCoverageTest.php:StreamDocumentQuerymoved into PUBLIC_MESSAGES;tenant.api.v1.documents.streamdropped out ofROUTES_WITHOUT_REQUIRES(it is no longer a "guarded" route by that test's ownauth:web/auth:sanctumdefinition, so it is no longer a candidate for that list at all).- Two stream-scoped middleware are added under
app-modules/documents/src/Http/Middleware/:EnsureSignedTenantMatchesRequest(signed-tenant pinning) andPinClientGuard(ambient-guard pin). Both are documents-module-private, use only framework facades + thetenant()helper (no Eloquent/ Models —CqrsBoundaryTestclean, no cross-moduleuse—ModuleBoundaryTestclean). - The OpenAPI exports regenerate mechanically (
make openapi-export): thestreamoperation's summary is refreshed (signature-only wording) and it gains a documentedissuerquery parameter (from the controller's$request->query('issuer')); the earlier D109security: []/ dropped-401shape is unchanged — no other operation changes. - In-flight signed URLs minted before this change remain valid and servable after it (the route name
and URI are unchanged; the signature format is unchanged — a pre-rollout URL simply omits the
issuerparam and logs a null accessor). This is a middleware + logging-field change to how the signature is presented and recorded, not a change to how it is minted or verified.