Decisions

One permission model

Why abilities, role-name checks, RBAC scopes and per-module authorize hooks become one model of permissions, roles, scopes and policies, how a check is evaluated, and what fails closed.

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: defineAbility functions that only see the plain user.
  • Role-name checks: route rules, admin.settings.roles, meta.roles and account documents compare user.role with a fixed name, with no hierarchy.
  • RBAC behind rbac: true: permissions registered with addAuthScope, 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

TermMeaning
Permissionresource:action, registered by a module or the host (addPermission). The only thing code checks.
RoleA named bundle in authorization.roles: grant, inherit, requirements, auto. A user holds several.
ScopeWhat 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).
PolicyCode that refines one permission against a record (definePolicy, defineSharedPolicy). It can deny, never grant.
Before hookauthorization: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:

  1. Unknown permission: 403 forbidden (and an error in development).
  2. No principal: 401 unauthorized.
  3. A sessionOnly permission on a bearer credential: 403 session_required.
  4. A bearer whose permissions, frozen when it was issued, don't cover the permission: 403 insufficient_scope.
  5. authorization:before: any deny() or a listener that throws denies; otherwise any allow() allows and skips 6-7.
  6. The user's roles: assigned ∪ defaultRoles ∪ auto roles ∪ authorization:roles, minus roles whose requirements fail, closed over inherit, plus implicit permissions. A token from a foreign issuer uses its own permissions claim instead.
  7. Policies, only for a check with a record unless withoutRecord: false or a throw denies, true and undefined keep the result. Module policies run before host ones.
  8. 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

SituationResult
Permission not registered403 forbidden, also for admins and when before allows
Wildcard and a sensitive permissionNot covered; sensitive permissions are granted by name
Bearer without a frozen permissions setHolds nothing (403 insufficient_scope)
A before listener throwsDenies
A role requirement throwsThe role does not count (nor does it through inherit)
A policy throws, or a shared policy returns a promiseDenies
Role in the data but not in configIgnored at runtime, listed in a warning in the server log
@frigear-nu/authorization not installedOnly implicit permissions and before; route rules are still enforced by auth
Unknown OAuth scope at /authorizeinvalid_scope (re-filtered silently on refresh)
A grant, wildcard, requirement or inherit that names nothing, an inherit cycle, an auto default roleBuild 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 *:authorize hooks, auth:user-permissions, auth:scope-available, addAuthScope, addAuthRole, requireAuth's scopes option and the rbac flag are removed in one coordinated release (users 3.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.role is copied once into authorization_user_roles (source = 'migrated') by an idempotent task that runs once on the first request after the upgrade (a marker in authorization_meta), never read again, and dropped one release later.
  • Clients: GET /api/auth/me/access returns { kind, scopes, permissions, roles? }, so the client's can(), 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 plus auto roles.
  • 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 with implicit permissions alone; roles are a provider that authorization plugs in.
  • Keeping the rbac flag. 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 decide table: session, user token and OAuth token × sessionOnly × several before listeners (allow, deny, throw) × roles, requirements and auto × policies (true, false, undefined, throw, without a record).
  • Role compile errors and the inheritance closure; scope grantability with the roleScopes union 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.
Copyright © 2026