Skip to main content

D15 — Client (tenant-user) auth: Sanctum SPA + tenant-scoped ClientUser (RESOLVED)

Architecture decision record, relocated verbatim from the retired single-file docs/tenancy/ decision log. Status, thematic clusters, and how to record a new ADR: the decision log index.

The TanStack client app authenticates via Sanctum SPA (cookie/session):

  • App\Models\ClientUser — a tenant-scoped table (client_users, tenant_id FK → auto RLS), separate from the central operator App\Models\User. Login and session→user resolution run under the tenant's RLS scope, so a user can only authenticate against, and act within, their own tenant. Email is unique per tenant.
  • client session guard (config/auth.php) + sanctum.guard = ['client']. The tenant API is protected with auth:sanctum + the tenant.member guard.
  • Middleware order (routes/tenant.php): EnsureFrontendRequestsAreStateful → throttle → bindings → InitializeTenancyByRequestData → (auth:sanctumtenant.member).
  • tenancy.cache.scope_sessions = false — one central SPA session per browser; isolation via RLS + the membership guard. scope_sessions = false alone is not sufficient to keep the default central /sanctum/csrf-cookie route working unmodified — see the amendment below, D15.1, for the second half of the contract and why the original ordering-trap claim here was wrong.
  • personal_access_tokens stays central (SPA uses sessions, not tokens). If client API tokens are introduced later, add tenant_id to that table for RLS.
  • SPA flow: GET /sanctum/csrf-cookiePOST /api/login (+X-Tenant) → cookie-authed /api/*. CORS (config/cors.php, supports_credentials) + SANCTUM_STATEFUL_DOMAINS + SESSION_DOMAIN are env-driven (set to the SPA origin per environment).

D15.1 — Central-session contract needs a dedicated unscoped session cache store, not just scope_sessions = false

Follow-on to D15 (branch fix/central-tenant-context-mismatches). Frontend e2e testing (2026-08-01) found a centrally-minted CSRF/session cookie rejected (419) on tenant routes — a live break of the "one central SPA session" contract this ADR claimed was settled by scope_sessions = false alone, including the specific claim that this setting "avoids the scoped-session vs Sanctum stateful/CSRF middleware-ordering trap." That claim was wrong; this amendment corrects it without rewriting the original decision — the text above is only annotated to point here.

Mechanism. The session handler is a clone of its cache store, taken the first time it's instantiated (SessionManager::createCacheHandler); Illuminate\Cache\Repository::__clone freezes that store's prefix at the moment of cloning. stancl prepends tenancy identification to the middleware priority list, so on tenant routes tenancy bootstraps (and tenant-prefixes any store listed in tenancy.cache.stores) before Sanctum's stateful pipe starts the session. A session riding a store that appears in tenancy.cache.stores therefore clones an already tenant-prefixed store and silently loses every centrally-minted session — regardless of scope_sessions, which only controls whether CacheTenancyBootstrapper treats the configured session store as one more store to prefix, not which store sessions actually ride.

Resolution. Sessions now ride a dedicated, unscoped session cache store (config/cache.php $stores['session']), which config/session.php store defaults to and which is deliberately absent from tenancy.cache.stores (config/tenancy.php). scope_sessions stays false — unchanged from the original decision. The contract is exactly what D15 always intended; it just wasn't actually enforced until both halves were in place. See the authoritative comment at config/tenancy.php (tenancy.cache.scope_sessions) and config/cache.php (session store block) for the full mechanism.

Pinning test. app-modules/authentication/tests/Feature/CentralCsrfTenantLoginTest.php — exercises both seeding flows through the real Redis session driver and real middleware/CSRF boundary: central GET /sanctum/csrf-cookie then a tenant login, and tenant GET /api/v1/whoami then a tenant login (the deployed-frontend path, which must keep working across the fix).

Rollout. On the deploy that carries this fix, client SPA sessions minted on tenant routes before the deploy live under tenant-prefixed keys and are invalidated once — affected users simply re-login (self-healing, no operator action needed); central/operator sessions are unaffected, since the dedicated session store inherits the same cache.prefix it always did.

D15.2 — Password change revokes other client sessions via Sanctum AuthenticateSession

Follow-on to D15/D15.1 (branch fix/password-change-session-revocation). Frontend security review (2026-08-01) found that changing the client password left every other active session working — a stolen session cookie survived the victim "securing" their account. There is no per-user index of session IDs on the redis-backed session store (D15.1), so a direct purge of the other sessions is impossible there.

Resolution. Every tenant-client authenticated route group (authentication and every other tenant module — payments, treasury, custody-controls, accounts, funding, team, documents, notification, async-requests, onboarding) carries Sanctum's AuthenticateSession middleware immediately after auth:sanctum (placement is load-bearing — before auth it resolves no user and no-ops; Sanctum's variant keys the session stamp password_hash_client on config('sanctum.guard'), not the default guard). Coverage matters: the middleware only challenges requests that traverse it, so a group without it would keep accepting a revoked session — a route-coverage pinning test enumerates all auth:sanctum + tenant.member routes and fails the suite if any lacks the middleware. Each authenticated response stamps the user's current password hash into the session; each request compares it. The password-change handler's existing re-hash + save is therefore the revocation signal: every other session 401s (and is flushed) on its next request, while the changing session is re-stamped and survives. The code-based password reset (ResetClientPasswordHandler) performs the identical re-hash, so a reset revokes other sessions the same way. No handler change; Auth::logoutOtherDevices() is deliberately not called (it would redundantly re-hash). This is pull-based revocation — driver-agnostic across the local database driver and the prod redis session store, and unaffected by D15.1's central/unscoped store placement.

Pinning test. app-modules/authentication/tests/Feature/PasswordChangeSessionRevocationTest.php — two independent real login flows over the real Redis session driver; asserts the other session 401s after the change while the changing session stays live, and that the response leaks neither the new password nor session identifiers.

Rollout + structural caveat. A session is only challenged once it carries a stamped hash, and it is stamped on its first authenticated request through a covered route — this is a permanent property of the mechanism, not just a deploy-day artifact: any session whose first-ever authenticated request happens after a later password change stamps the new hash and is never challenged for that change. In practice the window is negligible (the SPA calls /me immediately after login), and sessions alive at deploy time self-heal the same way — no operator action needed.

Named follow-ups (out of scope for this amendment).

  • Operator (web guard) parity — the backoffice surface has no equivalent revocation yet.
  • MFA-management actions (enrol/confirm/remove credential, regenerate recovery codes) do not trigger other-session revocation — they never touch the password column, so the generic re-hash signal never fires for them.
  • The surviving session's ID is not regenerated on password change (only remember_token cycles) — a pre-existing session-fixation-adjacent gap.
  • No audit/notification listener exists for ClientPasswordChanged — support must infer revocation from the 401 pattern; a durable log/comms hook is future work.

← Decision log index