Skip to main content

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-management skill.

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 operatorTenant client
Guardwebclient (config/auth.php)
User modelModules\Identity\Models\UserModules\Identity\Models\ClientUser (tenant-scoped, RLS)
Auth backendFortify (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 selectionnone — tenancy never initializedX-Tenant header (tenant uuid) + tenant.member membership guard
RLS postureBYPASSRLS (central)NOBYPASSRLS + stables.current_tenant
RBAC storagecentral spatie roles/permissions (HasRoles, never RLS-policied)RLS-scoped tenant_roles / tenant_role_has_permissions / client_user_has_roles (HasTenantRoles)
Self-service signupdisabled — operators are provisionedworkspace 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:sanctumtenant.memberaccount-activeaccount-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_credentials table (polymorphic owner, nullable tenant_id FK): a client's credential is tenant-scoped under RLS; an operator's carries NULL and 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/challenge completes it (self-limits to 5 attempts, then forces re-auth).
  • Step-up ("sudo mode"): privileged commands marked RequiresFreshMfa are refused by the bus unless the server-side step-up window is fresh — Modules\Foundation\Support\StepUpMfa, TTL from config('auth.step_up.ttl') (default 300s), state held in the session and never exposed to the client. The client re-confirms at POST /mfa/step-up and retries on STEP_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.php registering PermissionDefinitions into the central PermissionRegistry (both guards), synced to the DB with php 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; the PermissionGate routes the check by guard — web → spatie can(), clienthasTenantPermission() (which fails closed for any non-client key). Handlers never check roles.
  • Dependencies exist only at role-build time (D37): RoleComposer expands 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 on TenantCreated inside tenancy()->run(), and backoffice roles seed from the registry.
  • Super-admin is a Gate::before bypass in AuthorizationServiceProvider keyed on the literal protected role name — it short-circuits every can() check on the web guard (tenant-side checks don't ride Gate, 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


← Engineering wiki index