Skip to main content

D37 — Permission dependencies are enforced at role-build time only, never on the runtime hot path

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.

A PermissionDefinition may declare dependsOn (other keys, same guard). Dependencies are resolved only when a role is composed/edited: selecting a dependent permission auto-adds and locks its dependencies (RoleComposer::compose), and a role holding a permission without its dependencies is rejected (RoleComposer::validate). The runtime check — $actor->can() / hasTenantPermission() and the bus authorize() gate — stays a single-permission lookup and trusts that role construction already closed the set. We deliberately do not expand dependencies on the hot path.

Rationale: authorization runs on every guarded request; dependency expansion is graph traversal that would add cost (and a stale-registry failure mode) to the most frequently-executed code in the app. Roles are built/edited rarely, so that is the correct place to pay for the closure once. Roles remain a UX-only grouping — authorization decisions always go through permission checks; a role name never appears in an authorize() gate. Depth is capped at 2 (A→B→C); the worked example is team.members.inviteteam.members.list (you cannot invite into a team you cannot see).


← Decision log index