Skip to main content

D24 — RBAC in a dedicated Authorization module (design + foundation)

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.

RBAC lives in a new Modules\Authorization module. Permission catalogs are the single source of truth (string-backed enums, never raw strings): Enums\BackofficePermission (global, web guard) and Enums\TenantPermission (client guard). Backoffice has GLOBAL operator roles; each tenant manages its OWN roles; both seed a default set then CRUD. Design vetted by a multi-agent design workflow (4 lenses incl. an adversarial alternative + an RLS-correctness lens) against the actual codebase.

Decision: split RBAC storage by guard. Do NOT use spatie's teams feature. This reverses the naive "teams, team_foreign_key = tenant_id" idea floated earlier — the workflow confirmed in vendor code that with teams=true the team key is emitted as a bare unsignedBigInteger with no FK to tenants, so TableRLSManager (scopeByDefault) finds no FK path and generates no policy — tenant roles would sit entirely outside RLS, invisible to tenants:verify-isolation, violating D1 ("even a missing WHERE cannot leak rows"). Two further disqualifiers: spatie's permission cache is a single global key with no tenant filter (PermissionRegistrar loads it at boot, before tenancy initializes → tenant A's roles served to tenant B), and the shipped morph model_id is bigint and cannot hold ClientUser's UUID PK.

(A) Backoffice (web) → stock spatie tables, unchanged, central. roles/permissions have no tenant_id and no FK path to tenants, so TableRLSManager never policies them — they are central reference data read by operators on the BYPASSRLS connection (like users). Keep teams=false and model_morph_key='model_id' (bigint) — fits User's bigint PK. Add spatie HasRoles to Modules\Identity\Models\User; messages check $actor->can(BackofficePermission::X->value).

(B) Tenant (client) → dedicated module tables, each with a real tenant_id FK → auto-RLS. Three tables owned by the Authorization module (app-modules/authorization/database/migrations):

Schema superseded by D25/D26 — follow the implemented schema, not the type words first sketched here. The original D24 sketch used uuid PKs, foreignUuid('tenant_id') and uuid FKs. The IMPLEMENTED, canonical schema uses the $table->identifiers() macro (internal bigint id + public uuid route key) and a bigint foreignId('tenant_id'), plus $table->blamable() and softDeletes(). The list below has been corrected to match the real migration.

  • tenant_rolesidentifiers(), foreignId('tenant_id')->constrained('tenants')->cascadeOnDelete(), name, guard_name default client, partial unique(['tenant_id','name']) WHERE deleted_at IS NULL.
  • tenant_role_has_permissionstenant_role_id (bigint FK), permission string (the TenantPermission value — no tenant permissions table), denormalized tenant_id FK, composite PK.
  • client_user_has_rolesclient_user_id (bigint FK), tenant_role_id (bigint FK), denormalized tenant_id FK, composite PK. Each carries tenant_idtenants:rls auto-generates a FORCED USING (tenant_id::text = current_setting('stables.current_tenant')) policy (same pattern as the rls_widgets canary), so a tenant cannot read or write another tenant's roles at the DB layer. No spatie, no teams, no PermissionRegistrar team-id plumbing (the RLS session var, already managed by PostgresRLSBootstrapper, is the only context primitive needed — a key simplification). ClientUser gets a module trait Concerns\HasTenantRoles (tenantRoles(), hasTenantPermission(TenantPermission), assignTenantRole()), reading the RLS-scoped tables inside tenant context. TenantRole model uses HasUuids + FillsCurrentTenant.

Bus integration. The bus authorize(?Authenticatable $actor) is the gate; $actor is the active guard's user, so the guard split already routes operator vs client. Post-auth messages call $actor->can(BackofficePermission::X->value) (web) / $actor->hasTenantPermission(TenantPermission::X) (client, evaluated in tenant context → RLS-backstopped). Login/discovery/logout stay in an explicit public allow-list (pre-auth / self-service). A ChecksPermission seam on the abstract messages + an arch test will forbid bare return true outside the allow-list.

Default roles. Backoffice: a central seeder (run once) creates the web permission rows + default global roles (e.g. SuperOperator/Compliance/Support/ReadOnly — names pending product sign-off). Tenant: a TenantCreated listener seeds that tenant's default TenantRole set inside tenancy()->run() (so FillsCurrentTenant stamps tenant_id and the RLS WITH-CHECK admits it) — e.g. Owner/Admin/Member/Viewer.

Guardrails (from the RLS-correctness lens). A coverage test asserts the three tenant tables ARE in TableRLSManager::generateQueries() (catches a forgotten tenant_id → the inverse leak) and the backoffice spatie tables are NOT (a backoffice table gaining a tenant FK would be force-policied and silently break central admin). Arch-test the pivot FK column types (a future "simplification" back to a shared polymorphic pivot reintroduces the bigint/uuid morph break).

Build order (foundation). 1) HasRoles on User + test. 2) tenant-tables migration → tenants:rls. 3) RLS coverage + cross-tenant isolation tests. 4) TenantRole model (+ arch list additions). 5) HasTenantRoles on ClientUser. 6) provider wiring. 7) seeders + TenantCreated listener. 8) bus ChecksPermission seam. 9) arch test enforcing permission checks. 10) Pint + full suite. CRUD management surface (backoffice Livewire screen + per-tenant REST via the bus) is a follow-up.

Open questions (carried for the build): pivot tenant_id auto-stamp via a FillsCurrentTenant pivot model vs explicit; operator impersonation into tenant context (currently User stays strictly central); confirm modular auto-loads the module's migrations vs explicit loadMigrationsFrom; default-role names + which (if any) are undeletable.


← Decision log index