Roles and requirements
A role is a named bundle of permissions. A user holds several roles, and what they may do is the union of what the roles grant. This page explains how a role is defined, how modules and your app merge their definitions, where a user's roles come from, and how roles are assigned. Set up the module first: Overview.
Define a role
import { defineRoles } from '@frigear-nu/auth/access'
export default defineNuxtConfig({
authorization: {
defaultRoles: ['user'],
roles: defineRoles({
coordinator: {
label: 'Coordinator',
grant: ['forms:*', 'users:read', 'users:list'],
inherit: ['eventmanager'],
},
member: {
label: 'Member',
grant: ['member-area:view'],
requirements: { subscription: '*' },
auto: true,
inherit: ['user'],
},
}),
},
})
interface RoleDefinition {
label?: string
labelKey?: string // an i18n key for the label
description?: string
descriptionKey?: string
grant?: PermissionGrant[] // permission names or wildcards (`string` before the app's types are generated)
inherit?: RoleName[] // roles whose permissions this role also holds
requirements?: Record<string, unknown>
auto?: boolean
assignable?: boolean
replace?: boolean // host only: see below
}
| Key | Meaning |
|---|---|
grant | Permissions the role holds. A name (events:update), a resource wildcard (events:*) or *. A wildcard never covers a sensitive permission (:pii by default): grant those by name. The built-in ones (events:pii, forms:pii) gate MCP tool output only; the admin HTTP API is gated by events:manage / forms:manage. Every grant has to match a registered permission. |
inherit | Roles whose permissions this role also holds, transitively. There are no ranks: manager inheriting crew simply holds everything crew grants. A cycle fails the build. |
requirements | Conditions that must all hold for the role to count. Each key is a registered requirement. The role is dropped, and so are roles that only reach it through inherit, while one fails. |
auto | Held by everyone who meets the requirements. Not assignable, never in defaultRoles, and it needs at least one requirement. |
assignable | Whether admins may assign it. Defaults to true; always false for auto and default roles. |
replace | Host only: discard the module's definition of this role instead of merging into it. |
Type your roles
Wrap the roles in defineRoles from @frigear-nu/auth/access, in nuxt.config.ts or in a roles.ts it imports:
import { defineRoles } from '@frigear-nu/auth/access'
export const roles = defineRoles({
coordinator: { label: 'Coordinator', grant: ['forms:*', 'users:read', 'users:list'], inherit: ['eventmanager'] },
user: { label: 'User' },
})
import { roles } from './roles'
export default defineNuxtConfig({
authorization: { defaultRoles: ['user'], roles },
})
It returns the roles unchanged. Its value is in the types:
granttakes aPermission,*orresource:*(PermissionGrant), so'users:lst'fails typecheck with a "did you meanusers:list" hint.inherittakes the roles of the same object, and once the types are generated every compiled role (RoleName), modules' roles included (eventmanager).defaultRolestakes aRoleName.
The types come from .nuxt/types/auth-access.d.ts, which auth writes at nuxi prepare (and every build) from the
registered permissions and roles, and which nuxt.config.ts sees too. Before the first prepare, and in a package
without a generated app, Permission and RoleName are plain string: nothing fails, nothing is checked. The build
checks the same rules at runtime regardless (see Build errors).
defaultRoles and implicit
Two things give every signed-in user a permission, and they are not the same:
defaultRolesis your baseline bundle (['user']). It is host config, shows up on the roles pages and can hold anything.implicitis a flag on a permission that a module registers for self-service every user needs, such astickets:readorprofile:write. It applies even when Authorization is not installed.
How roles merge
A role can come from several places. The build merges them at modules:done:
- Modules ship roles with
addRole(Events offerseventmanager) and put permissions into roles by name withgrantToon the permission (Forms givesforms:piitoadmin). Roles that inherit that role get the permission too. When the role does not exist, the grant is skipped with an info log. - Two modules defining one role union their
grantandinherit. Differingrequirementsorautofail the build. - Your app's
authorization.roles[name]merges into a module's role of the same name by default: your label and description win,grantandinheritare unions, and yourrequirements,autoandassignablewin. replace: truediscards the module's definition, including thegrantTogrants into that role, so you own it completely.
authorization: {
roles: {
// Events ships `eventmanager` (grant: events:*). Add forms to it, keep the rest.
eventmanager: { grant: ['forms:read'] },
// Take over a module role entirely.
crew: { replace: true, label: 'Crew', grant: ['events:read'] },
},
}
Provenance
The compiled role remembers where each grant came from: host, module:<name>, grantTo:<module> or
inherit:<role>. The roles admin page shows it, so it is always clear why a
role holds a permission.
Build errors
The build stops when the role configuration would do nothing, or something surprising. Each message names the role or permission.
| Error | Fix |
|---|---|
| A grant that names no registered permission, or a wildcard that matches none | Register the permission with addPermission, or fix the name. |
inherit names a role that is not defined | Define the role or fix the name. |
| Roles inherit in a cycle | Remove the loop. |
| An unknown requirement | Install the module that registers it, or fix the name. |
An auto role without requirements, or in defaultRoles | Add a requirement, or make it a plain default role. |
| A default role that is not defined | Define it, or remove it from defaultRoles. |
Two modules disagree on requirements or auto for one role | Rename one role, or define it in your app with replace: true. |
A permission registered twice with a different sensitive, sessionOnly or implicit | Make the registrations agree. |
An unknown permission in a route rule or definePageMeta({ authorize }) | Register it, or fix the name. |
Requirements
A requirement ties a role to a condition. The role only counts while every requirement holds. The most common pairing is
an auto role, so everyone who meets the condition holds it:
authorization: {
roles: {
member: {
grant: ['member-area:view'],
requirements: { subscription: '*' }, // any plan; or 'gold'; or ['gold', 'silver']
auto: true,
},
},
}
A module registers a requirement with addRoleRequirement in setup(). The file's default export decides it per user:
addRoleRequirement(nuxt, { name: 'subscription', path: resolver.resolve('./runtime/server/requirements/subscription') })
export default async function ({ user, value }: AuthRoleRequirementContext): Promise<boolean> {
return hasActiveSubscription(Number(user.id), { plans: value === '*' ? [] : [].concat(value) })
}
- The context is
{ event, user, value }, wherevalueis what the role wrote after the name. - A requirement that throws counts as not met, and an unknown requirement name is not met either: it fails closed.
- Results are cached per request and user.
- Subscriptions registers
subscription. It is the only built-in requirement.
Where a user's roles come from
A user's effective roles are, in order:
- the roles assigned to them (stored, see Assignment),
- plus the
defaultRoles, - plus every
autorole, - then the
authorization:roleshook, which may add or remove roles, - then the roles whose requirements fail are dropped, and the rest is closed over
inherit.
Role permissions are grants of all of these, plus the implicit permissions. An assigned role that is not configured
any more is kept in the database, ignored at runtime and listed in a startup warning. A role provider or a hook that
throws leaves the user with no roles at all (only implicit permissions), never with extra ones.
The hook is how an environment allowlist becomes a role:
export default defineNitroPlugin((nitro) => {
nitro.hooks.hook('authorization:roles', (ctx) => {
if (ctx.user.emailVerifiedAt && admins.includes(ctx.user.email)) ctx.roles.push('admin')
})
})
Roles from the hook are not stored and cannot be changed in the admin. They still count for target protection.
Assignment and its rules
Roles are assigned in the users admin or through
PUT /api/admin/authorization/users/:id/roles with { roles: string[] }. The call replaces the roles staff assigned
(manual and migrated rows). It needs roles:assign, which is session only, and three hard-coded rules a host
cannot override:
- No escalation. To add or remove a role the actor must hold every permission that role expands to, sensitive
ones by name. An admin without
forms:piicannot hand out a role that holds it. Missing permissions are403 forbiddenwith the list indata.missing. - Strictly below. The role must also sit below the actor: the actor holds at least one permission the role does
not (the
implicitpermissions everyone holds do not count). So a manager can assign and remove lower roles, but notmanageritself, and peers cannot promote or demote each other; that answers403 role_not_belowwith the role indata.role. The one exception is an actor who holds every registered permission, sensitive ones included: such an admin may assign and remove their own role (adminwithgrant: ['*']alone does not qualify when sensitive permissions exist; grant those by name). - Not assignable.
autoroles, default roles and roles withassignable: falseanswer409 role_not_assignable. An unknown role is400 unknown_role.
The actor's own roles are resolved strictly for this check: when the role provider, an authorization:roles listener or
a requirement fails, nothing is assigned (403 forbidden) and the user tab offers no roles.
Each row in authorization_user_roles records its source:
| Source | Written by | Replaced by |
|---|---|---|
manual | An admin, through the API or the user tab | The next assignment |
sso | Single sign-on (frigear.nu sign-in), through the role provider's setRoles | The next sign-in. It replaces only sso rows, never manual ones. |
migrated | The one-off migration of users.role | The next assignment |
Target protection
Assigning roles is not the only way to edit another user. Users refuses any
write on a user whose permissions the actor does not fully hold, roles from authorization:roles included. So a
coordinator cannot edit an administrator.
The users.role migration
Earlier releases stored one role in users.role. This release keeps the column but never reads it as a role.
- On the first request, an idempotent task copies
users.role(where it is not'user', a default or anautorole) intoauthorization_user_roleswithsource = 'migrated'. - It records completion in
authorization_meta(users-role-migrated), so it never runs again. Removing a migrated role therefore cannot bring it back. - Single sign-on stops writing
users.role. A later release drops the column.
To check it, list the users of a role on /admin/roles/:name, or call GET /api/admin/authorization/roles/:name/users,
and compare with what you expect. Roles found in the data but absent from config are kept, ignored and listed in a
startup warning: add them to authorization.roles, or remove the rows. The mapping of every removed option is in
Migrate access control.
Next steps
- Policies – refine a permission for one record.
- Admin – the pages that show roles and provenance.
- Access control – the order in which a check is decided.