Skip to content
0

ADR-0008: Access is three orthogonal axes, not one role ladder ​

Status: Accepted · Date: 2026-10-18

Context ​

AccessService shipped one thing: a role ladder with a fixed z.enum of six WordPress-shaped roles and a fixed vocabulary of content:* capabilities. It answered "may this subject do this kind of thing?" and nothing else.

Three deployments immediately need more than that, and each reaches for it in a different direction:

  1. A self-serve dashboard. Signup, email verification, an admin listing users and editing them. The built-in ladder is exactly right, and any abstraction added for the cases below is cost paid by everyone.
  2. A closed organisation — a municipal intranet, an enterprise panel — where nobody signs themselves up. The identifier is a national identity document captured at hire, the initial password is generated by IT and handed over on paper, it is rotated every three months, and the account lifecycle is owned by a different department from the one that manages permissions.
  3. A document-sharing product. Someone is given access to one file and to nothing else. No global role ladder expresses that: the role is real, but its blast radius is a single resource.

Cases 2 and 3 pull in opposite directions — one wants an identity layer that is the provider's job, the other wants a per-resource scope that is not a role at all. Folding either into the ladder corrupts the other.

Two failure modes showed up in the existing code before this decision:

  • ROLE_RANK was a Record<AccessRole, number> hardcoded next to the registry. A role registered through useRegisterRole resolved to undefined, and undefined >= rank is false — so a custom role silently inherited nothing while still appearing in useRoles(). Extending the registry was a lie.
  • useRegisterRole validated nothing, so a definition missing a field poisoned every subsequent check without a warning.

Decision ​

The three axes are separate, and the credential never reaches the role ​

Identity is who the person is in your system; credential is how they proved it; authorization is what they may do. Principal carries the credential result (subject, method, provider, claims) and holds no roles. Identity is yours and outlives the provider account.

The rule that falls out: an OAuth sign-in never grants elevation. Signing in with Google proves "I control this account" — a real, strong claim — and says nothing about employment. A deployment that lets anyone self-register therefore creates an identity with no scope and no elevated role; elevation requires someone with the authority to grant it. The same reasoning means a deployment whose users register with a personal email must keep its own role table: the provider has no concept of the employment relationship and cannot revoke on offboarding.

Anonymity is a value, not an error ​

useResolve() returns { ok: true, data: null } for a visitor and { ok: false, error } only when the provider itself failed. Collapsing them turns a broken identity provider into a public route, and the symptom — every guarded endpoint returning 401 — reads as "nobody is logged in" rather than "the backend is down". An unknown provider id is ok: false for the same reason: a typo must not quietly open a route.

Roles and capabilities are open strings; rank is data ​

AccessRoleSchema and AccessCapabilitySchema became format-validated strings. The built-ins live in BUILT_IN_ROLE_SLUGS / BUILT_IN_CAPABILITIES const tuples, so autocomplete survives while a typo fails loudly at the schema boundary instead of silently granting nothing.

rank moved from the hardcoded table into AccessRoleDefinition, and useRegisterRole validates before storing. This is what makes a custom role a first-class participant in the hierarchy rather than a special case.

The cost is accepted knowingly: a mistyped role slug no longer fails at compile time. It fails at runtime with the Unknown access role error, one call earlier.

A grant is a role scoped to a resource ​

useGrantAwareCan() checks global roles first, then Grant rows for that exact resource. A grant reuses the same registry — viewer, commenter and editor mean whatever the deployment registered them to mean — so there is one vocabulary, not two. Only the scope differs.

GrantResolver is injected, so the core never learns where rows live. Grant expiry is evaluated by the core, not the resolver: revoking a contractor is deleting one row, not auditing every query path that might have cached the role. An unreadable expiresAt is treated as expired, because a share whose deadline cannot be read is a share nobody meant to leave open. A missing resolver denies rather than skips.

The directory owns the lockout invariants ​

DirectoryService wraps a DirectoryRepository and enforces the rules that otherwise live in a code review nobody reads:

  • the last active holder of a role cannot lose it (a suspended holder does not rescue them — counting them would let the system drop to zero live administrators);
  • nobody may suspend, delete or demote themselves;
  • you cannot grant a role more privileged than the best one you hold, and owner is never granted through these paths at all;
  • useUpdate({ roles }) runs the same guards, because an update that accepts { roles: [] } is a way around useRemoveRole;
  • useSuspend is a soft offboarding — reversible, auditable, and distinct from useDelete.

Every refusal carries a stable code, so a UI can say "promote someone else first" instead of matching on message text.

Consequences ​

Better. One authorization vocabulary for global and per-resource access. A deployment can be a plain dashboard with zero configuration, or a closed organisation with identity capture and a rotation policy, without either forking the toolkit. The lockout guards are the default rather than a review comment. dist/ stays free of database concerns, so the core remains testable with a plain object.

Worse. The public AccessRoleDefinition gained a required rank and useRegisterRole now throws on a malformed definition — a source-level break for anyone who was registering a custom role, who was silently getting no inheritance anyway. Roles and capabilities are strings, so a typo is a runtime error rather than a compile error. DirectoryService is deliberately not a singleton: it is constructed with a repository, because a process-global directory is a process-global database handle.

Deliberately absent. Deleting a provider account, revoking a live session and forcing a password change are not methods here. A directory row disappearing must never be the only thing that happens on offboarding, and those operations belong to whichever provider the deployment actually uses.

Released under the MIT License.