Someone says the roles should inherit. Everyone nods, because it sounds obviously right: an administrator can do everything a member can, so why restate it. The nodding conceals the fact that at least four different mechanisms travel under that word, and they have different failure modes.
Naming which one you mean before the schema exists is cheap. Discovering that half the team implemented one and half the team assumed another is a rewrite, because permission bugs are the kind you cannot ship a quick fix for.
The four models
The first is a role hierarchy: roles form a chain or a graph, and a higher role holds everything a lower one holds. It reads beautifully and it fails the first time a customer needs a billing administrator who cannot read documents, because that role is not above or below anything.
The second is composition: a role is a named set of permissions, and sets can include other sets. No ordering is implied, so the awkward billing role is just a different bundle. The third is resource-tree inheritance, where a grant made at an organisation flows down to projects and to individual resources unless overridden. The fourth is derivation from the directory, where a group membership upstream maps to a role and nothing local is authoritative at all.
- Hierarchy — roles ordered by strength; simple to explain, poor at exceptions
- Composition — roles as sets of permissions that include other sets; flexible, needs discipline
- Resource tree — grants flow down the container structure; matches how customers think
- Directory-derived — group membership decides the role; the local model becomes a cache
Resolve at read, or flatten at write
Whichever model you pick, a decision has to be made about when the resolution happens. Resolving at read means the check walks the structure on every request: always current, more expensive, and it makes an access review expensive too because nothing is materialised anywhere. Flattening at write means each grant is expanded into concrete permissions when it is made: fast to check, straightforward to list, and stale the moment a role definition changes unless you re-expand every affected grant.
For most products, resolve at read is the right default until it measurably hurts, and the answer when it does hurt is a cache with an explicit staleness bound rather than a switch to flattening. A stale cache with a short lifetime is a known, bounded wrongness. A flattened table that nobody re-expanded after an edit is unbounded wrongness that looks correct.
A cache with a stated staleness bound is a known wrongness. A flattened table nobody re-expanded is an unknown one.
Deny rules are where explainability dies
Sooner or later a customer asks for an exception: this person is an administrator but must not see salary fields. The direct implementation is a deny that overrides an inherited allow, and the moment both directions exist, the answer to may this person do this depends on evaluation order rather than on any statement a human made.
Resist it for as long as you can, and prefer carving the sensitive capability into its own permission that is simply not granted. Where a deny is genuinely unavoidable, make it narrow, make it apply at exactly one level, and make the precedence a documented rule rather than an emergent property of your resolver's traversal order. The test is whether an engineer can predict the outcome without running the code.
The endpoint that saves your support team
Every inheritance model eventually produces the same question from a customer administrator: why can this person do this. If the only way to answer is for an engineer to trace the code by hand, that question will consume a real amount of your team's week, and the answers will occasionally be wrong.
Build the explanation as a first-class output of the resolver. A check should be able to return not only the decision but the path that produced it — this permission came from this role, granted on this resource, through this group membership, at this time. It is a small amount of extra work while the resolver is being written, it makes the tests far more precise, and it turns access reviews from an archaeology exercise into a report.
- Return the reason alongside the decision, not just an allow or a deny
- Show inherited grants distinctly from direct ones in the administration interface
- Assert on the reason in tests so a refactor that changes the path fails loudly
- Keep the number of ways a permission can arrive small enough to hold in your head
Everything here, already built
Sign-in, enterprise SSO, directory provisioning, roles and an audit trail behind one API. Start with the quickstart and have a working sign-in this afternoon.