Migrate to the permission model
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.
<module>:authorize
hook. The model itself is in Access control; the reasons are in the
decision record.What changed and why
- Before.
defineAbilityfunctions saw only the user. Route rules,admin.settings.rolesand documents compareduser.rolewith a fixed name. Behindrbac: true, "scopes" and "permissions" were the same string. Every module added its own<module>:authorizehook, 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 fromauthorization.rolesonly. 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
| Removed | Replacement |
|---|---|
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, bouncer | A permission plus a role grant, and a policy (definePolicy) for record rules. |
defineStaffGuard({ hook, scope, message }), guard.require, guard.can | authorize(event, '<module>:manage'), and allows(event, ...). |
definePolicyGate, defineStaffScopes | A 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 }). |
addOAuthScope | addScope, 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:authorize | authorization:before, or a policy on the module's permissions. |
users:admin:authorize | authorization:before, or a policy on users:*. |
users:admin:roles | Roles in authorization.roles; the roles tab of the user admin assigns them. |
authorization:role | authorization:roles: push to or splice from ctx.roles. |
auth:user-permissions | Roles, or authorization:before. |
auth:scope-available | canGrantScope decides what a token or client may be given; refine it with authorization:before. |
oauth:scope:staff | Roles that grant the permission, or authorization:before. |
oauth:user:roles | authorization:roles. allowedRoles resolves through the role provider. |
The old me/permissions route of authorization and the users admin roles route | GET /api/auth/me/access returns { kind, scopes, permissions, roles? }. |
Configuration
| Removed | Replacement |
|---|---|
authorization.rbac | Nothing: there is one model. Delete the flag. |
authorization.abilities, resolvers | Permissions, roles and policies. |
authorization.roles.hierarchy | inherit on each role. |
authorization.roles.inherits | inherit on each role. |
authorization.roles.grant (per-role map) | grant inside each role definition. |
authorization.roles.revoke | Nothing. Roles only add. Remove the permission from the role's grant, or do not inherit a role that has it. |
authorization.roles.labels, descriptions | label, labelKey, description, descriptionKey on each role. |
authorization.roles.definitions | The role entries themselves: authorization.roles.<name>. |
authorization.roles.defaultRole | authorization.defaultRoles: ['user']. |
Route rule keys roles, ability, scopes | permission. 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.roles | admin.settings.permission (default admin:access, granted to admin). Using the old key throws at build time. |
account.settings.documents[].roles | permission. 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 applications | Roles and roleScopes. |
oauthServer.clients[].allowedRoles | Still there, now resolved through the role provider (effective roles, inheritance included). |
The users.role field | Roles 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
| Removed | Replacement |
|---|---|
useAuthorization().can(ability, ...) against abilities | can(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 roles | permission: 'resource:action'. |
Behaviour changes that bite
These follow from "fail closed". Check each against your app before you deploy.
- No authorization installed. Only
implicitpermissions (self-service such astickets:read,profile:write) andauthorization:beforeapply. Route rules are still enforced. A staff route with no role provider and nobeforelistener answers403, where it may have answered200before. - An unregistered permission is a
403. A route that checks a name nobody registered fails, also for admins and also whenbeforeallows. 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.*andresource:*skip:piipermissions (events:pii,forms:pii). Grant them by name.grantToon the permission puts them intoadminfor 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:listis now needed to list users.users:readopens one user; the directory and the sign-up numbers needusers:list. The oldusers:readroles need both.events:refundis new andsessionOnly. Refunds are money. Roles that nameevents:manageneedevents:refundtoo;events:*covers it. An API token or OAuth token never refunds.forms:credentialsis new andsessionOnly. Creating or removing form access tokens, passwords and secrets. Roles that nameforms:manageneed it too.subscriptions:manageissessionOnly. 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
sessionOnlypermissions. 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/authorizeisinvalid_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.rolesor remove it.
Move the roles in your data
Existing users kept one role each in users.role. A one-time, automatic step copies it:
- On the first request after the upgrade,
@frigear-nu/authorizationcopies everyusers.rolethat is notuser(and is not a default orautorole) intoauthorization_user_roleswithsource = 'migrated'. - It then writes a marker (
users-role-migrated) intoauthorization_meta, so it never runs again. Removing a role later cannot bring it back, because nothing readsusers.roleas a fallback. - Single sign-on writes its own rows (
source = 'sso') and never touches manual ones. - The
users.rolecolumn 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;
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;
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.
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.
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
- Access control – the vocabulary, the decision order and the fail-closed table.
- Protect a route – the two-step route in practice.
- Register permissions in a module – for module authors.