Skip to main content

D36 — Per-module permission registry, both guards, registry seeded FROM the enums (extends D24)

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.

ID note: the work item asked for IDs "D27, D28", but those are taken by the money-safety pillars (D27–D32), and D33–D35 are taken by the Features/Entitlements engine (merged from the sibling branch). To keep the log append-only and collision-free (the actual intent), these land as D36/D37.

The permission catalog is no longer a single central list — each module declares the permissions it owns. A module exposes src/Authorization/PermissionProvider.php (implements Modules\Authorization\Permissions\Contracts\ProvidesPermissions, returns PermissionDefinition[]) and registers them into a central, app-wide Modules\Authorization\Permissions\PermissionRegistry (a singleton, owned by the Authorization module) from its ServiceProvider boot(). This mirrors how each module surfaces its domain events from its own src/Events. The registry covers both guards: operator (web, spatie global roles) and tenant (client, the tenant_roles tables). A RegistryValidator fails the build (run by permissions:sync + the registry-integrity test) on a duplicate key, an unknown / cross-guard dependency, a cycle, or a dependency chain deeper than depth 2 (A→B→C).

Single source of truth (enum-delegation choice). We chose (b): the registry is seeded FROM the enums, not the enums delegating to the registry. Enums\BackofficePermission + Enums\TenantPermission stay the authored source for the keys they own; the Authorization module's own PermissionProvider lifts each case into a PermissionDefinition. Runtime checks remain the plain, direct enum-based calls ($actor->can(BackofficePermission::X->value) for operators, $actor->hasTenantPermission(TenantPermission::X) for clients), so the registry is never on the authorization hot path. Each key is declared by exactly one module — there are never two divergent sources for the same key.

RoleComposer is the single role-build chokepoint. Both role-CRUD paths (backoffice spatie roles and tenant tenant_roles) compose through Modules\Authorization\Permissions\RoleComposer, which expands a desired set into its dependency closure and validates completeness. permissions:sync creates/forgets the spatie web permissions from the registry and validates the registry + DefaultRoles; it is idempotent. An out-of-bus façade (Modules\Authorization\Permissions\Facades\Permissions over PermissionGate) routes a single permission to the right guard mechanism for UI/middleware checks outside the bus.

Worked example + reconciliation. Only the Team module is migrated as the worked example: the legacy tenant.team.* permissions moved out of TenantPermission into the Team module's own Modules\Team\Enums\TeamPermission (renamed to the module.entity.action shape team.members.*) and are declared in app-modules/team/src/Authorization/PermissionProvider.php. hasTenantPermission() and TenantRole::grantPermissions() were widened from the concrete TenantPermission enum to the PermissionKey contract so any guard's permission identifier works. Because the Authorization module must not depend on Team (module-graph direction), the default owner/admin tenant roles now receive the full client-guard registry catalog at seed time (SeedDefaultTenantRoles via the registry + RoleComposer) rather than a static enum list — so they pick up per-module permissions automatically. Migrating the rest of the catalog into per-module providers, and the role-management CRUD/UI, remain tracked follow-ups (docs/tracking/permission-catalog-migration.md, permission-role-management.md).


← Decision log index