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_idFK → auto RLS), separate from the central operatorApp\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.clientsession guard (config/auth.php) +sanctum.guard = ['client']. The tenant API is protected withauth:sanctum+ thetenant.memberguard.- Middleware order (
routes/tenant.php):EnsureFrontendRequestsAreStateful→ throttle → bindings →InitializeTenancyByRequestData→ (auth:sanctum→tenant.member). tenancy.cache.scope_sessions = false— one central SPA session per browser; isolation via RLS + the membership guard.scope_sessions = falsealone is not sufficient to keep the default central/sanctum/csrf-cookieroute 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_tokensstays central (SPA uses sessions, not tokens). If client API tokens are introduced later, addtenant_idto that table for RLS.- SPA flow:
GET /sanctum/csrf-cookie→POST /api/login(+X-Tenant) → cookie-authed/api/*. CORS (config/cors.php,supports_credentials) +SANCTUM_STATEFUL_DOMAINS+SESSION_DOMAINare 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 (
webguard) 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
passwordcolumn, so the generic re-hash signal never fires for them. - The surviving session's ID is not regenerated on password change (only
remember_tokencycles) — 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.