authz
The permission catalog, the seeded roles, org-scoped custom roles, role bindings, and the evaluator that middleware calls on every protected request. Implements iface.AuthzProvider — the HasPermission primitive every other module's RBAC gate leans on.
Permissions are never in the JWT
This is the single most important thing about the module. A token carries the user's global system role and their list of org memberships — never their permissions. Those are resolved per request, cached in Redis for 60 seconds, and retired on every binding or role change.
The consequence is that revocation is effectively instant. Unbinding a role or disabling an account changes what the caller can do on their next request, no matter how much life the bearer token has left. That property is what makes service accounts safe to hand out, and it is why a token's remaining TTL is not a security parameter here.
How the cache is retired
The cache key folds in two generation counters — one global, one per user — read together in a single MGET on every lookup. Retiring a set of verdicts is therefore one INCR: entries written under an older generation simply become unreachable and expire on their own TTL. Nothing has to find a key to invalidate it, which is what the earlier scan-and-delete had to do on the hot path — a scan that could partially fail, race a concurrent read repopulating the entry, and built its pattern out of a request body.
The two directions of a permission change get opposite treatment, because a stale verdict does not mean the same thing in both:
- After a grant, a stale verdict is a late deny — harmless. So a grant retires the cache before it writes, and if that cannot be done the change is refused entirely with
503 authz.cache_unavailableand nothing is written. - After a revocation, a stale verdict is a live allow — harmful. So a revocation writes first and retires afterwards, best-effort. Refusing it would leave the privilege granted indefinitely, where writing leaves it for at most the 60-second TTL. A failed retirement is logged and counted, never returned.
Which side a role edit falls on is decided by what the edit can do, not by which field it touches. Adding a permission grants; dropping one revokes; and disabling a role revokes too — the evaluator ignores an inactive role entirely, so switching one off takes its permissions away from every holder at once. Re-enabling it is a grant again.
A third case retires nothing at all, because it cannot move a verdict: creating a brand-new role, which nothing is bound to yet, and renaming or re-describing an existing one, which changes no permission. Role creation earns that guarantee rather than assuming it — a role name is unique within its tenant, so a create naming a role that already exists is answered with 409 Conflict instead of quietly rewriting it.
A deployment with no Redis at all is not the failure case: there is no cached verdict to retire, so the retirement is a truthful no-op and mutations proceed. A deployment whose Redis evicts keys is a different matter — the generation counters carry no expiry, and an evicted counter would bring retired verdicts back — so the instance backing authz must run without an allkeys-* eviction policy. The bundled stack already does.
The catalog builds itself
No one writes to authz_permissions by hand. Every module declares its keys in Permissions(); after all modules have run Init, the registry hands authz the union of those declarations, and the seeded roles' permission sets are computed from the now-complete catalog.
So adding a permission means editing the owning module, never this one — and a role rename must be a single-file change in the seeder.
Lazy heal
ListRoles and ListPermissions both check whether the system-role count is zero and, if so, rebuild the catalog and roles from an in-memory copy of the specs. That is what makes the admin roles page recover by itself after a DB drop in development, with no backend restart. If you touch permission registration, keep that spec cache populated.
Roles
Eleven roles are seeded, all stored as global rows. The platform-vs-tenant distinction comes from how each is granted, not how it is stored.
Platform-level — granted through global bindings (empty tenant):
| Role | Permission set |
|---|---|
super_admin | * — a wildcard that overrides every check; the only holder by design |
administrator | Every registered permission |
developer | Everything in dev and staging; read and self scopes in production |
manager | Everything except delete and admin suffixes |
operator | Read plus self-service |
guest | Read-only |
Tenant-level — granted through tenant-scoped bindings:
| Role | Permission set |
|---|---|
org_owner | Every non-system permission — full tenant control, but no module, cross-tenant, or platform-user management |
org_admin | The owner set minus delete suffixes |
org_member | Non-system, filtered to read/view/self/own |
org_billing | Non-system, filtered to the finance surfaces |
org_viewer | Non-system, read-only |
Two rules on every binding
CreateBinding rejects before it inserts:
- Separation — a platform system role requires a global binding; an
org_*or custom role requires a tenant-scoped one. Never mixed. - Cascade — the caller's effective permissions in the binding's scope must be a superset of the role's. You cannot grant what you do not hold. The wildcard covers everything; a role asking for the wildcard requires the caller to hold it too.
A missing granter returns an error rather than silently waiving the cascade. The platform's own sentinel granter (used when creating a tenant auto-binds its owner) bypasses the cascade but still respects separation.
CreateBinding also starts the target's MFA enrollment grace clock when the granted role is a privileged one — idempotently, so repeated grants never reset a running clock.
Four rules on every custom-role permission list
Creating or editing a custom role runs the same class of checks, for the same reason: without them the role editor is a way around the binding cascade — write any permission into a role you can edit, then bind yourself to it.
- The list must be non-empty after trimming and de-duplication.
- Every key must exist in the registered catalog —
422 authz.permission_unknown, naming the key. A key no module declares can never be checked by anything. - No key may be
*or a platform-reserved (System: true) permission —422 authz.system_permission_forbidden, naming the key. This binds everyone,super_adminincluded: it is about what a tenant role may carry, not about what the actor may grant. - The actor must already hold every key in the role's scope —
403. Same rule, same helper, as the binding cascade.
One consequence to expect in practice: a role holding a permission from a module that no longer exists can still be renamed or disabled, but its permission list cannot be saved until the stale key is dropped. Because the admin UI round-trips the whole list, adding a single permission to such a role returns a 422 naming a key the operator never typed.
How a decision is made
super_adminshort-circuits with the wildcard.administratoranddeveloperinherit every permission flaggedSystem: trueby any module.- Otherwise: the union of every active binding for this user in this org, plus their global bindings.
- System permissions — and the wildcard — require a global grant. A tenant-scoped binding never contributes either: the evaluator skips them on that branch. Keys already stored that way are ignored, not deleted — they stay on the role and role reads still show them. The global branch is deliberately unfiltered, because that is where a platform role legitimately conveys a platform key.
Routes
| Method | Path | Gate |
|---|---|---|
| GET | /v1/authz/permissions | authenticated — the catalog is system-generated |
| GET | /v1/tenants/{tenantId}/authz/roles | authz.role.read |
| GET | /v1/tenants/{tenantId}/authz/bindings | authz.role.read |
| GET | /v1/tenants/{tenantId}/authz/me | authz.role.read — the caller's effective permissions here |
| POST | /v1/tenants/{tenantId}/authz/roles | + MFA step-up |
| PATCH | /v1/tenants/{tenantId}/authz/roles/{roleId} | + MFA step-up |
| DELETE | /v1/tenants/{tenantId}/authz/roles/{roleId} | + MFA step-up |
| POST | /v1/tenants/{tenantId}/authz/bindings | + MFA step-up |
| DELETE | /v1/tenants/{tenantId}/authz/bindings/{bindingId} | + MFA step-up |
Every mutation is behind a step-up because each one grants or revokes effective permissions. Each also carries its own permission key (authz.role.create, authz.role.update, authz.role.delete, authz.binding.create, authz.binding.delete) rather than the read key.
PATCH .../roles/{roleId} can also answer 403 authz.platform_admin_required — see the system-role invariant below.
Two of them can answer 503 authz.cache_unavailable: editing a role in a way that takes no permission away, and creating a binding. Those are the mutations that grant access and can change a verdict somebody already holds. The ones that take access away never answer it, and creating a role never does either — a role with no bindings changes nobody's permissions. Read that by direction rather than by HTTP verb.
Permissions it contributes
authz.role.read · authz.role.create · authz.role.update · authz.role.delete · authz.binding.read · authz.binding.create · authz.binding.delete, plus two system keys — system.modules.admin and system.users.admin. Those last two gate other modules but are declared here, because authz owns the concept of a system permission and the seeding of the roles that inherit them.
Invariants worth knowing
- System roles are immutable on name, description, and permissions. Only the active flag can be toggled — and only by a platform administrator (
super_adminoradministrator), because every system role is a global catalog row: disabling one empties it for every holder on the platform, not just inside the tenant named in the path. A caller whose own system role says otherwise gets403 authz.platform_admin_required. The rule is about who the caller is, not what they hold: anorg_ownerholds exactly the permissions theorg_ownerrow carries, so a "you must hold everything the role carries" rule would have let them disable it platform-wide. The service is the authoritative gate, not the UI. - Seeding preserves UUIDs — the seeder copies an existing role's UUID into the rewritten row, so bindings survive both reboots and lazy-heal runs.
- Deleting a role cascades its bindings, so nothing is left pointing at a role that no longer exists.
- Deleting a tenant cascades too — a post-delete hook removes that tenant's bindings and then retires the permission cache, best-effort and only when rows were actually removed. A failed retirement never fails the delete: the rows are gone, and reporting a failure would be a false signal for a cascade that succeeded. Without the hook, a re-used tenant UUID would inherit dangling grants. Global bindings are untouched: they carry platform roles that outlive any tenant.
- A tenant-role permit can never authorize a platform action. A Cedar forbid (
system_actions.require_platform_role) refuses everysystem.*action for a principal without asuper_admin/administrator/developersystem role — a single rule rather than a fix repeated per permit, so a tenant-role permit added later is covered without an edit. It is keyed on the permission's module, so it reaches the tensystem.-prefixed keys; the other five platform-reserved keys are covered instead by the rule that a global check never stamps the caller's tenant roles onto the Cedar request. Both land in shadow mode —CEDAR_ENFORCE_ACTIONSis a separate operational step. - Binding expiration is advisory, not a TTL. Expired bindings are filtered out of reads but stay in the collection; the index is a plain one. A reaper is future work.
:::note Not here
User identity and the system-role field live in user. Org ownership and membership live in tenant. Enforcement is middleware, which consumes HasPermission. There is no audit trail of role changes in this module — auditing goes through the compliance sink.
:::