One permission model
Status: accepted (2026-11-12), being implemented. Scope: @frigear-nu/auth (the engine), @frigear-nu/authorization
(roles), every module that checks access (users, events, forms, payments, subscriptions, mcp,
oauth-server, oauth-resource, admin, menu, widget, account), apps/website and the docs. The pure core
lives in packages/auth/src/runtime/utils/permissions.ts.
Context
Authorization grew three systems that disagree with each other:
- Abilities:
defineAbilityfunctions that only see the plain user. - Role-name checks: route rules,
admin.settings.roles,meta.rolesand account documents compareuser.rolewith a fixed name, with no hierarchy. - RBAC behind
rbac: true: permissions registered withaddAuthScope, so "scope" and "permission" are one string under two names.
On top of that, each module adds its own <module>:authorize hook and its own defineStaffGuard / definePolicyGate
/ defineStaffScopes. In practice that gives three different defaults without RBAC (one of them "allow everything"),
a client can() that disagrees with the server, allowlisted staff who cannot mint tokens, OAuth scopes that are
dropped without a word, and no way for a mobile app or a resource server to see what a token may do.
Decision
A Laravel-style model (gates, policies, Gate::before) keyed on resource:action strings, with one meaning per word.
Vocabulary
| Term | Meaning |
|---|---|
| Permission | resource:action, registered by a module or the host (addPermission). The only thing code checks. |
| Role | A named bundle in authorization.roles: grant, inherit, requirements, auto. A user holds several. |
| Scope | What a delegated credential (API token, OAuth token) may do: a permission, a wildcard, or a named scope that expands to permissions (tickets) or to none (openid). |
| Policy | Code that refines one permission against a record (definePolicy, defineSharedPolicy). It can deny, never grant. |
| Before hook | authorization:before, the single host override. Listeners call allow() or deny(). |
* and resource:* never cover a sensitive permission (:pii by default): those are granted by name. An alias
(an old name a permission was split from) expands only on the held side, so a token that holds events:manage keeps
working while code that checks events:manage fails. Ability, staff guard, policy gate, ceiling, aggregateTo and
rbac leave the public API.
Evaluation
authorize(event, permission, record?) resolves the inputs and hands them to the pure decide:
- Unknown permission: 403
forbidden(and an error in development). - No principal: 401
unauthorized. - A
sessionOnlypermission on a bearer credential: 403session_required. - A bearer whose permissions, frozen when it was issued, don't cover the permission: 403
insufficient_scope. authorization:before: anydeny()or a listener that throws denies; otherwise anyallow()allows and skips 6-7.- The user's roles: assigned ∪
defaultRoles∪autoroles ∪authorization:roles, minus roles whose requirements fail, closed overinherit, plusimplicitpermissions. A token from a foreign issuer uses its ownpermissionsclaim instead. - Policies, only for a check with a record unless
withoutRecord:falseor a throw denies,trueandundefinedkeep the result. Module policies run before host ones. - The result.
Steps 3 and 4 always win, so neither the before hook nor a policy can widen a credential. Policies refine and never flip
a denial into a grant: an owner rule is record.ownerId === user.id || undefined on top of a role grant.
Fail closed
| Situation | Result |
|---|---|
| Permission not registered | 403 forbidden, also for admins and when before allows |
| Wildcard and a sensitive permission | Not covered; sensitive permissions are granted by name |
Bearer without a frozen permissions set | Holds nothing (403 insufficient_scope) |
A before listener throws | Denies |
| A role requirement throws | The role does not count (nor does it through inherit) |
| A policy throws, or a shared policy returns a promise | Denies |
| Role in the data but not in config | Ignored at runtime, listed in a warning in the server log |
@frigear-nu/authorization not installed | Only implicit permissions and before; route rules are still enforced by auth |
Unknown OAuth scope at /authorize | invalid_scope (re-filtered silently on refresh) |
A grant, wildcard, requirement or inherit that names nothing, an inherit cycle, an auto default role | Build error |
Roles and scopes
Roles come from config only (authorization.roles plus module defaults with addRole; the host wins per key) and are
stored per user in authorization_user_roles with a source (manual, sso, migrated), so single sign-on can
replace its own rows without touching manually assigned ones. Auth reaches roles only through a role provider
(getAssignedRoles, setRoles), which authorization supplies and which assigns nothing without it.
OAuth scopes stay scopes. A scope is grantable when every permission it expands to passes steps 5-6, when it lies within
the union of the user's oauthServer.settings.roleScopes (unset: no cap), and, for wildcards, only for clients with
wildcardScopes: true. Its expansion is frozen into the token at issue, intersected with the user's permissions, so
step 4 never queries the scope tables.
Consequences
- Breaking, no shims: the per-module
*:authorizehooks,auth:user-permissions,auth:scope-available,addAuthScope,addAuthRole,requireAuth'sscopesoption and therbacflag are removed in one coordinated release (users3.0, a breaking minor elsewhere). A migration guide maps each to its replacement, and a development-only plugin warns about listeners on removed hook names. - Data:
users.roleis copied once intoauthorization_user_roles(source = 'migrated') by an idempotent task that runs once on the first request after the upgrade (a marker inauthorization_meta), never read again, and dropped one release later. - Clients:
GET /api/auth/me/accessreturns{ kind, scopes, permissions, roles? }, so the client'scan(), a mobile app and a resource server see the same answer as the server; shared policies run on both sides. - Cost: roles, permission sets and decisions without a record are cached per request; policy results never are.
Alternatives considered
- One role per user. Simple, but it forces combinatorial roles (
eventmanager-and-member) and cannot express a derived role such as "has a subscription". Rejected for several roles per user plusautoroles. - Roles editable in the database. Flexible for admins, but a typo becomes a privilege escalation that no build catches, and roles would differ between environments. Rejected: roles come from config and are compiled at build time; only the assignment of roles to users lives in the database.
- Merging auth and authorization into one package. Fewer moving parts, but every app that only needs sign-in would
carry role tables and admin pages. Rejected: the engine (
decide,authorize, the hook) is in auth and works withimplicitpermissions alone; roles are a provider that authorization plugs in. - Keeping the
rbacflag. A switch between two models doubles every code path and test, and one side of it was "allow everything". Rejected: there is one model and it fails closed.
Tests
- The matcher: wildcards, sensitive permissions never matched by a wildcard, aliases on the held side only, unknown permissions.
- The
decidetable: session, user token and OAuth token ×sessionOnly× severalbeforelisteners (allow, deny, throw) × roles, requirements andauto× policies (true, false, undefined, throw, without a record). - Role compile errors and the inheritance closure; scope grantability with the
roleScopesunion and wildcards. - End to end, on existing fixtures:
me/access, role assignment and escalation, the role migration, frozen token permissions,invalid_scope, introspection and menu filtering after sign-in and sign-out.