Going further

Migrate to the permission model

Move a host app or module from abilities, role-name checks, RBAC and per-module authorize hooks to permissions, roles, scopes and policies.

This release replaces three overlapping access systems with one: permissions (resource:action), roles that bundle them, scopes for delegated credentials, and policies that refine a check against a record. There are no compatibility shims. A removed option or hook either fails the build or, for a hook listener, logs a warning in development and never runs.

For: anyone who upgrades a host app or a module that checked roles, abilities, scopes or a <module>:authorize hook. The model itself is in Access control; the reasons are in the decision record.

What changed and why

  • Before. defineAbility functions saw only the user. Route rules, admin.settings.roles and documents compared user.role with a fixed name. Behind rbac: true, "scopes" and "permissions" were the same string. Every module added its own <module>:authorize hook, with three different defaults when RBAC was off (one was "allow everything").
  • Now. Code checks one thing: a registered permission, with authorize(event, permission, record?). A user holds several roles, which come from authorization.roles only. A token holds the permissions frozen into it when it was issued. One hook, authorization:before, is the host override. Everything fails closed.

Old to new

Server API and hooks

RemovedReplacement
requireAuth(event, { scopes }), requireUser(event, { scopes })await authorize(event, 'resource:action'). requireAuth / requireUser now only authenticate, and take sessionOnly.
principalHasScopes(principal, scopes)await allows(event, permission), or inspect for the reason.
defineAbility, abilities, bouncerA permission plus a role grant, and a policy (definePolicy) for record rules.
defineStaffGuard({ hook, scope, message }), guard.require, guard.canauthorize(event, '<module>:manage'), and allows(event, ...).
definePolicyGate, defineStaffScopesA registered permission with grantTo, and authorization:before or a policy for host rules.
addAuthScope(nuxt, { name, roles })addPermission(nuxt, { name, label, grantTo }) for what code checks, addScope(nuxt, { name, permissions }) for a named group of permissions.
addAuthRole(nuxt, ...)addRole(nuxt, { name, grant, inherit }).
addOAuthScopeaddScope, from @frigear-nu/auth/utils. It serves API tokens and OAuth alike.
ctx.hasScope(scope) (MCP)await ctx.can(permission).
events:authorize, forms:authorize, payments:authorize, oauth:authorize, authorization:authorizeauthorization:before, or a policy on the module's permissions.
users:admin:authorizeauthorization:before, or a policy on users:*.
users:admin:rolesRoles in authorization.roles; the roles tab of the user admin assigns them.
authorization:roleauthorization:roles: push to or splice from ctx.roles.
auth:user-permissionsRoles, or authorization:before.
auth:scope-availablecanGrantScope decides what a token or client may be given; refine it with authorization:before.
oauth:scope:staffRoles that grant the permission, or authorization:before.
oauth:user:rolesauthorization:roles. allowedRoles resolves through the role provider.
The old me/permissions route of authorization and the users admin roles routeGET /api/auth/me/access returns { kind, scopes, permissions, roles? }.

Configuration

RemovedReplacement
authorization.rbacNothing: there is one model. Delete the flag.
authorization.abilities, resolversPermissions, roles and policies.
authorization.roles.hierarchyinherit on each role.
authorization.roles.inheritsinherit on each role.
authorization.roles.grant (per-role map)grant inside each role definition.
authorization.roles.revokeNothing. Roles only add. Remove the permission from the role's grant, or do not inherit a role that has it.
authorization.roles.labels, descriptionslabel, labelKey, description, descriptionKey on each role.
authorization.roles.definitionsThe role entries themselves: authorization.roles.<name>.
authorization.roles.defaultRoleauthorization.defaultRoles: ['user'].
Route rule keys roles, ability, scopespermission. These keys fail the build: a rule that still used one would leave its routes open.
meta.roles, meta.ability in definePageMeta({ authorize })definePageMeta({ authorize: 'resource:action' }).
admin.settings.rolesadmin.settings.permission (default admin:access, granted to admin). Using the old key throws at build time.
account.settings.documents[].rolespermission. Using the old key throws at build time.
mcp definitions with scopes, gate, sessionOnly{ permission: 'resource:action' }. A definition without one is never exposed. Use a sessionOnly permission for money and credentials.
staffOnly on OAuth scopes and applicationsRoles and roleScopes.
oauthServer.clients[].allowedRolesStill there, now resolved through the role provider (effective roles, inheritance included).
The users.role fieldRoles live in authorization_user_roles. The column stays one release for the copy, is not read by access control, and is dropped next release. The user.role type is deprecated.

Client

RemovedReplacement
useAuthorization().can(ability, ...) against abilitiescan(permission, record?): synchronous, from GET /api/auth/me/access, then shared policies.
<Can ability="...">, <Can role="..."><Can permission="events:update" :record="event">, and <Cannot>.
menu, widget and account items with rolespermission: 'resource:action'.

Behaviour changes that bite

These follow from "fail closed". Check each against your app before you deploy.

  • No authorization installed. Only implicit permissions (self-service such as tickets:read, profile:write) and authorization:before apply. Route rules are still enforced. A staff route with no role provider and no before listener answers 403, where it may have answered 200 before.
  • An unregistered permission is a 403. A route that checks a name nobody registered fails, also for admins and also when before allows. In development it logs an error too. A route rule or page meta naming an unknown permission fails the build.
  • * never covers a sensitive permission. * and resource:* skip :pii permissions (events:pii, forms:pii). Grant them by name. grantTo on the permission puts them into admin for you.
  • Plain users cannot mint * tokens. A user holds only what their roles grant, and a token cannot hold more than its user. A wildcard token needs a user whose roles cover it.
  • users:list is now needed to list users. users:read opens one user; the directory and the sign-up numbers need users:list. The old users:read roles need both.
  • events:refund is new and sessionOnly. Refunds are money. Roles that name events:manage need events:refund too; events:* covers it. An API token or OAuth token never refunds.
  • forms:credentials is new and sessionOnly. Creating or removing form access tokens, passwords and secrets. Roles that name forms:manage need it too.
  • subscriptions:manage is sessionOnly. Granting free memberships and sending payment links is cookie sessions only.
  • A user's payment history in the admin needs payments:manage, not just a staff role.
  • Token permissions are frozen. A token or OAuth access token carries the permissions it had when it was issued, intersected with its user's. Demoting a user does not shrink an old token's claim, but every check still goes through the user's current permissions, so a demoted user's token loses access at once. A token minted before the upgrade has no frozen set and holds nothing: mint it again.
  • DB scopes cannot hold wildcards, sensitive or sessionOnly permissions. The admin refuses them. Config scopes are the same: a named scope that expands to a sensitive or session-only permission fails the build.
  • An unknown scope at /oauth/authorize is invalid_scope. It used to be dropped. On refresh, scopes are still filtered silently.
  • A role that is in the data but not in config is ignored and listed in a startup warning. Add it to authorization.roles or remove it.

Move the roles in your data

Existing users kept one role each in users.role. A one-time, automatic step copies it:

  1. On the first request after the upgrade, @frigear-nu/authorization copies every users.role that is not user (and is not a default or auto role) into authorization_user_roles with source = 'migrated'.
  2. It then writes a marker (users-role-migrated) into authorization_meta, so it never runs again. Removing a role later cannot bring it back, because nothing reads users.role as a fallback.
  3. Single sign-on writes its own rows (source = 'sso') and never touches manual ones.
  4. The users.role column is dropped in the next release.

Verify it. Both queries list users whose old role has no row, so they should return nothing:

SELECT u.id, u.email, u.role
FROM users u
LEFT JOIN authorization_user_roles r ON r.user_id = u.id AND r.role = u.role
WHERE u.role IS NOT NULL AND u.role <> 'user' AND r.user_id IS NULL;

A role that is a default or auto role in your config is legitimately missing from the result of the copy: list it by name in the query, or ignore it. To see what was copied and which names are unknown to your config:

SELECT role, source, COUNT(*) AS users
FROM authorization_user_roles
GROUP BY role, source
ORDER BY role;
Roles that match nothing in authorization.roles are kept and ignored. The startup log names them: stored roles that are not configured are kept and ignored.

Move an allowlist to authorization:roles

A host that made email allowlists administrators (NUXT_ACL_ADMINS, a *-policy.ts plugin, effectiveRole) now adds the role in the authorization:roles hook. The role is not stored, so removing an address removes the access at once.

server/plugins/authorization-roles.ts
export default defineNitroPlugin((nitro) => {
  const admins = new Set((process.env.NUXT_ACL_ADMINS ?? '').split(',').map(email => email.trim().toLowerCase()).filter(Boolean))
  nitro.hooks.hook('authorization:roles', (ctx) => {
    if (admins.has(ctx.user.email.toLowerCase()) && !ctx.roles.includes('admin')) {
      ctx.roles.push('admin') // identify the user from ctx.user, never from the session on ctx.event
    }
  })
})

Rules that deny (a coordinator may not edit an admin, an email change needs a staff check) become a policy, or an authorization:before listener that calls deny(). See Policies.

Checklist

Update the packages

Upgrade every @frigear-nu/* package together: users goes to 3.0, the others take a breaking minor.

Define your roles

Replace the rbac, roles.hierarchy, inherits, grant, labels block with authorization.roles, each role with grant, inherit, requirements and auto. Set defaultRoles.

nuxt.config.ts
authorization: {
  defaultRoles: ['user'],
  roles: {
    admin: { label: 'Admin', grant: ['*'] },
    manager: { label: 'Coordinator', grant: ['forms:*', 'users:read', 'users:list'], inherit: ['user'] },
    user: { label: 'User', grant: ['events:read'] },
  },
},

Fix the build errors

Route rules with roles, ability or scopes, admin.settings.roles, document roles, and a grant that names an unregistered permission all fail the build and say where.

Replace the checks in your server code

Swap requireAuth({ scopes }), guards and ability calls for authorize, in the order authorize, load, authorize with the record. See HTTP routes.

Move the hooks

Replace each removed *:authorize listener with a role grant, a policy or authorization:before, and the role allowlist with authorization:roles. Run pnpm dev and read the console: a listener on a removed hook name logs a warning.

Update the client

<Can ability> becomes <Can permission>. Menu, widget and account items take permission.

Regenerate migrations

Authorization adds authorization_user_roles and authorization_meta, auth adds auth_user_tokens.permissions, and OAuth Server adds oauth_scopes. Generate and commit migrations for your app, and for every fixture that includes these modules (from the repository root):

pnpm --filter @frigear-nu/<pkg> exec nuxt db generate --cwd "$PWD/packages/<pkg>/test/fixtures/<fixture>"

Run it again after: it must print No schema changes, nothing to migrate.

Verify the data and the access

Run the SQL above, sign in as each kind of user, and call GET /api/auth/me/access: permissions must be what you expect. Try one token with only tickets:read against a staff route: it answers 403 insufficient_scope.

Next steps

Copyright © 2026