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
uuidPKs,foreignUuid('tenant_id')anduuidFKs. The IMPLEMENTED, canonical schema uses the$table->identifiers()macro (internal bigintid+ publicuuidroute key) and a bigintforeignId('tenant_id'), plus$table->blamable()andsoftDeletes(). The list below has been corrected to match the real migration.
tenant_roles—identifiers(),foreignId('tenant_id')->constrained('tenants')->cascadeOnDelete(),name,guard_namedefaultclient, partialunique(['tenant_id','name']) WHERE deleted_at IS NULL.tenant_role_has_permissions—tenant_role_id(bigint FK),permissionstring (theTenantPermissionvalue — no tenantpermissionstable), denormalizedtenant_idFK, composite PK.client_user_has_roles—client_user_id(bigint FK),tenant_role_id(bigint FK), denormalizedtenant_idFK, composite PK. Each carriestenant_id→tenants:rlsauto-generates a FORCEDUSING (tenant_id::text = current_setting('stables.current_tenant'))policy (same pattern as therls_widgetscanary), so a tenant cannot read or write another tenant's roles at the DB layer. No spatie, no teams, noPermissionRegistrarteam-id plumbing (the RLS session var, already managed byPostgresRLSBootstrapper, is the only context primitive needed — a key simplification).ClientUsergets a module traitConcerns\HasTenantRoles(tenantRoles(),hasTenantPermission(TenantPermission),assignTenantRole()), reading the RLS-scoped tables inside tenant context.TenantRolemodel usesHasUuids+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.