Skip to main content

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, and app-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: ValidateSignatureEnsureSignedTenantMatchesRequestaccount-not-frozenPinClientGuard. 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::Download is checked exactly once, by IssueDocumentDownloadUrlQuery's ChecksPermission gate, when the BFF (holding the real session) asks for a download URL. StreamDocumentQuery::authorize() — dispatched by the stream controller — is an honest return true: re-checking a permission against $actor, which is now always null on this route, would only ever deny the request, not add a real check. It is allow-listed in tests/Architecture/AuthorizationCoverageTest.php's PUBLIC_MESSAGES register (the same honest register VerifyMfaChallengeCommand — a different token-gated entry point — already sits in).
  • The signature IS the credential. A valid ValidateSignature pass is proof that an actor who held DocumentPermission::Download asked for this exact document within the last documents.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). ValidateSignature runs 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-Tenant pointing at a victim tenant would reach EnsureAccountNotFrozen — which needs only tenant('id') — and receive a distinguishable 403 ACCOUNT_FROZEN/ACCOUNT_CLOSED: an anonymous cross-tenant compliance-status oracle. With ValidateSignature first, 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 outer InitializeTenancyByRequestData with header > cookie > query-parameter precedence, and the X-Tenant header / tenant cookie 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 unsigned X-Tenant: B header, re-opening the same freeze/closure oracle and a cross-tenant document-resolution vector. A stream-scoped middleware (running after ValidateSignature, so the signed tenant param it trusts is tamper-proof, and before account-not-frozen) asserts the RESOLVED tenant('uuid') equals the signed tenant param (precedence-agnostic), refusing any mismatch with a generic 403 before the freeze gate evaluates anyone. This is why IssueDocumentDownloadUrlHandler always binds the tenant param 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-owned AccountFreezeGuard) 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's stream request still 403s ACCOUNT_FROZEN; a closed tenant's still 403s ACCOUNT_CLOSED — proven with no session in SignatureOnlyStreamTest (for the coded envelope, i.e. an Accept: application/json fetch — see "Error-envelope honesty" below for the bare-navigation shape).
  • The ambient guard is pinned to client (PinClientGuard). Dropping auth:sanctum also dropped the Authenticate middleware's implicit shouldUse('sanctum' → client) pin, so the ambient default guard falls back to web (config/auth.php). A co-resident backoffice operator session in the same browser would then resolve as the bus actor (via AuditsBusMessages::currentActor(), which calls Auth::user() on the default guard), and LogDocumentAccessCommand::authorize() (accepts only null | ClientUser) would DENY the access-log command — throwing on the controller's uncaught success-path logAccess() and failing an otherwise-valid signed download. A stream-scoped middleware pins the default guard to client (mirroring the explicit-client-guard precedent in ListNotificationsHandler), so Auth::user() resolves against client only: a BFF browser → null, a co-resident client SPA → its ClientUser, both accepted.
  • LogDocumentAccessCommand is null-actor-tolerant, and the accessor is captured from a signed issuer param (audit-who — CLOSED, not a gap). Its authorize() reads $actor === null || $actor instanceof ClientUser (the old $actor instanceof ClientUser would have silently denied every session-less serve, breaking the D6 every-serve-is-logged guarantee). But the written accessor is no longer left NULL: the serve has no session, so instead of reading an ambient guard, IssueDocumentDownloadUrlHandler binds the issuing client user's PUBLIC uuid into the signature as an additional signed issuer param, the controller threads it into LogDocumentAccessCommand::$issuerUuid, and LogDocumentAccessHandler resolves the accessor deterministically from it (a ClientUser looked up by uuid under the current tenant's RLS scope) and stamps accessor_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 no issuer, so the accessor is stamped NULL and the serve still lands. The DLP capture paths (ReadsClassifiedData/EgressesData on StreamDocumentQuery) 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 navigationwindow.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 tenant param 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's GET /api/v1/payments/exports/download route sits in the same authenticated + signed-signature stack that documents stream just left (auth:sanctum + AuthenticateSession + ValidateSignature), and therefore has the identical BFF/window.open session-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 in docs/tracking/conduit-payments-deferrals.md.
  • tests/Architecture/AuthorizationCoverageTest.php: StreamDocumentQuery moved into PUBLIC_MESSAGES; tenant.api.v1.documents.stream dropped out of ROUTES_WITHOUT_REQUIRES (it is no longer a "guarded" route by that test's own auth:web/auth:sanctum definition, 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) and PinClientGuard (ambient-guard pin). Both are documents-module-private, use only framework facades + the tenant() helper (no Eloquent/ Models — CqrsBoundaryTest clean, no cross-module useModuleBoundaryTest clean).
  • The OpenAPI exports regenerate mechanically (make openapi-export): the stream operation's summary is refreshed (signature-only wording) and it gains a documented issuer query parameter (from the controller's $request->query('issuer')); the earlier D109 security: [] / dropped-401 shape 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 issuer param 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.

← Decision log index