Auth & RBAC
What this covers / who it's for. The end-to-end story of who can act, as what, and how that is decided: the two-guard model, MFA and step-up for both populations, and the per-module permission registry with roles as pure UX. For wiring recipes (adding a guarded endpoint, a permission, a role), defer to the skill reference and the
permission-managementskill.
Two applications, two guards
Stables is two applications over one codebase, kept apart by the auth guard. Get the guard right and everything downstream — RLS posture, user model, RBAC storage, permission enum — follows:
| Central operator | Tenant client | |
|---|---|---|
| Guard | web | client (config/auth.php) |
| User model | Modules\Identity\Models\User | Modules\Identity\Models\ClientUser (tenant-scoped, RLS) |
| Auth backend | Fortify (sessions, passkeys) for the Livewire shell + a Sanctum-stateful JSON surface at /api/v1/backoffice/* | Sanctum stateful SPA cookies (config/sanctum.php → 'guard' => ['client']) at /api/v1/* |
| Tenant selection | none — tenancy never initialized | X-Tenant header (tenant uuid) + tenant.member membership guard |
| RLS posture | BYPASSRLS (central) | NOBYPASSRLS + stables.current_tenant |
| RBAC storage | central spatie roles/permissions (HasRoles, never RLS-policied) | RLS-scoped tenant_roles / tenant_role_has_permissions / client_user_has_roles (HasTenantRoles) |
| Self-service signup | disabled — operators are provisioned | workspace registration exists: POST /api/v1/register creates the tenant + owner (Turnstile + throttled) |
The guards never blur: Fortify is pinned to web, Sanctum's stateful guard to ['client'], and
spatie teams mode was explicitly rejected for tenant RBAC because its tables would sit outside
RLS (D24). The auth HTTP surface for both populations lives in the
authentication module
(app-modules/authentication/routes/authentication-routes.php); the models and Fortify actions
live in identity; RBAC storage and the registry live in
authorization.
The client login journey (email-first)
The SPA does not know its tenant up front, so login is email-first (D16) — and it doubles as the clearest tour of how identification, authentication, and membership compose:
Discovery is the one sanctioned cross-tenant read (it runs tenancy()->central(...)); every
authenticated tenant route then stacks auth:sanctum → tenant.member → account-active →
account-not-frozen. The operator variant is the same Sanctum-stateful pattern at
/api/v1/backoffice/* with auth:web, no tenancy, and no membership guard — see
architecture-flow.md §2.
MFA and step-up — both guards, one mechanism
Both populations share the same second-factor machinery:
- Credentials live in the
mfa_credentialstable (polymorphic owner, nullabletenant_idFK): a client's credential is tenant-scoped under RLS; an operator's carriesNULLand is invisible in any tenant context. TOTP and WebAuthn enrolment/challenge endpoints exist symmetrically on both surfaces (/api/v1/mfa/*and/api/v1/backoffice/mfa/*). - Login-time MFA: a password login with an enrolled factor parks in a pending-challenge
session state;
POST /mfa/challengecompletes it (self-limits to 5 attempts, then forces re-auth). - Step-up ("sudo mode"): privileged commands marked
RequiresFreshMfaare refused by the bus unless the server-side step-up window is fresh —Modules\Foundation\Support\StepUpMfa, TTL fromconfig('auth.step_up.ttl')(default 300s), state held in the session and never exposed to the client. The client re-confirms atPOST /mfa/step-upand retries onSTEP_UP_REQUIRED(403). Because operator and client hold distinct session cookies, each surface has at most one window.
Operators additionally get Fortify-native passkeys (passwordless login + passkey sudo) — see the
laravel-passkeys-development skill for the ceremony internals.
Authorization: one seam, one registry, roles as UX
There is exactly one authorization seam: the message's authorize() on the CQRS bus (see
cqrs-bus.md). Everything else is how the decision is declared and stored:
- Permissions are declared per module (D36): each module ships a
src/Authorization/PermissionProvider.phpregisteringPermissionDefinitions into the centralPermissionRegistry(both guards), synced to the DB withphp artisan permissions:sync. The legacy central enums (BackofficePermission/TenantPermission) are being migrated out module by module — never add new cases to them. - Authorization is always a single permission check; roles are a UX grouping. A guarded
message declares its key via
ChecksPermission+AuthorizesViaDeclaredPermission; thePermissionGateroutes the check by guard —web→ spatiecan(),client→hasTenantPermission()(which fails closed for any non-clientkey). Handlers never check roles. - Dependencies exist only at role-build time (D37):
RoleComposerexpands a permission's declared dependencies (depth ≤ 2) when composing a role, so a role that grants "approve payouts" also carries "view payouts" — but the runtime gate still checks exactly one key. - Default roles are contributed per module via the
DefaultRoleRegistry(D71); tenant defaults (owner/admin/member) are seeded automatically onTenantCreatedinsidetenancy()->run(), and backoffice roles seed from the registry. - Super-admin is a
Gate::beforebypass inAuthorizationServiceProviderkeyed on the literal protected role name — it short-circuits everycan()check on thewebguard (tenant-side checks don't rideGate, so there is no tenant super-user). - Routes declare, too: guarded API routes carry the
->requires(<PermissionKey>)macro (Foundation), which both enforces at the edge and exports the endpoint↔permission map (php artisan abilities:export, D51).
The abilities feed — how SPAs know what to render
Both SPAs read their effective permission set from GET /me/abilities (client) /
GET /api/v1/backoffice/me/abilities (operator) — GetClientAbilitiesQuery /
GetOperatorAbilitiesQuery through the bus, served with ETag/304 so polling is cheap. The feed
is UI-shaping only: hiding a button is a courtesy; the bus gate and the route ->requires()
remain the enforcement. Any authenticated user may read their own abilities, so the route
deliberately declares no specific permission.
Going deeper
- Wiring recipes + the full guard/config walkthrough: the auth-and-rbac skill reference.
- Adding/changing permissions, roles, dependencies: the
permission-managementskill; storage internals: thelaravel-permission-developmentskill (skills package). - Module specifics:
app-modules/authentication/README.md,app-modules/authorization/README.md,app-modules/identity/README.md. - Decision rationale: D16, D24, D36, D37, D51, D71 in the decision log.