Authorization

Roles and requirements

How roles are built from host config and module defaults, how users get them, and how to keep a role tied to a condition such as a subscription.

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

nuxt.config.ts
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
}
KeyMeaning
grantPermissions 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.
inheritRoles whose permissions this role also holds, transitively. There are no ranks: manager inheriting crew simply holds everything crew grants. A cycle fails the build.
requirementsConditions 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.
autoHeld by everyone who meets the requirements. Not assignable, never in defaultRoles, and it needs at least one requirement.
assignableWhether admins may assign it. Defaults to true; always false for auto and default roles.
replaceHost 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:

roles.ts
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' },
})
nuxt.config.ts
import { roles } from './roles'

export default defineNuxtConfig({
  authorization: { defaultRoles: ['user'], roles },
})

It returns the roles unchanged. Its value is in the types:

  • grant takes a Permission, * or resource:* (PermissionGrant), so 'users:lst' fails typecheck with a "did you mean users:list" hint.
  • inherit takes the roles of the same object, and once the types are generated every compiled role (RoleName), modules' roles included (eventmanager).
  • defaultRoles takes a RoleName.

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:

  • defaultRoles is your baseline bundle (['user']). It is host config, shows up on the roles pages and can hold anything.
  • implicit is a flag on a permission that a module registers for self-service every user needs, such as tickets:read or profile: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:

  1. Modules ship roles with addRole (Events offers eventmanager) and put permissions into roles by name with grantTo on the permission (Forms gives forms:pii to admin). Roles that inherit that role get the permission too. When the role does not exist, the grant is skipped with an info log.
  2. Two modules defining one role union their grant and inherit. Differing requirements or auto fail the build.
  3. Your app's authorization.roles[name] merges into a module's role of the same name by default: your label and description win, grant and inherit are unions, and your requirements, auto and assignable win.
  4. replace: true discards the module's definition, including the grantTo grants into that role, so you own it completely.
nuxt.config.ts
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.

ErrorFix
A grant that names no registered permission, or a wildcard that matches noneRegister the permission with addPermission, or fix the name.
inherit names a role that is not definedDefine the role or fix the name.
Roles inherit in a cycleRemove the loop.
An unknown requirementInstall the module that registers it, or fix the name.
An auto role without requirements, or in defaultRolesAdd a requirement, or make it a plain default role.
A default role that is not definedDefine it, or remove it from defaultRoles.
Two modules disagree on requirements or auto for one roleRename one role, or define it in your app with replace: true.
A permission registered twice with a different sensitive, sessionOnly or implicitMake 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:

nuxt.config.ts
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:

src/module.ts
addRoleRequirement(nuxt, { name: 'subscription', path: resolver.resolve('./runtime/server/requirements/subscription') })
src/runtime/server/requirements/subscription.ts
export default async function ({ user, value }: AuthRoleRequirementContext): Promise<boolean> {
  return hasActiveSubscription(Number(user.id), { plans: value === '*' ? [] : [].concat(value) })
}
  • The context is { event, user, value }, where value is 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:

  1. the roles assigned to them (stored, see Assignment),
  2. plus the defaultRoles,
  3. plus every auto role,
  4. then the authorization:roles hook, which may add or remove roles,
  5. 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:

server/plugins/authorization-roles.ts
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:pii cannot hand out a role that holds it. Missing permissions are 403 forbidden with the list in data.missing.
  • Strictly below. The role must also sit below the actor: the actor holds at least one permission the role does not (the implicit permissions everyone holds do not count). So a manager can assign and remove lower roles, but not manager itself, and peers cannot promote or demote each other; that answers 403 role_not_below with the role in data.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 (admin with grant: ['*'] alone does not qualify when sensitive permissions exist; grant those by name).
  • Not assignable. auto roles, default roles and roles with assignable: false answer 409 role_not_assignable. An unknown role is 400 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:

SourceWritten byReplaced by
manualAn admin, through the API or the user tabThe next assignment
ssoSingle sign-on (frigear.nu sign-in), through the role provider's setRolesThe next sign-in. It replaces only sso rows, never manual ones.
migratedThe one-off migration of users.roleThe 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.

  1. On the first request, an idempotent task copies users.role (where it is not 'user', a default or an auto role) into authorization_user_roles with source = 'migrated'.
  2. It records completion in authorization_meta (users-role-migrated), so it never runs again. Removing a migrated role therefore cannot bring it back.
  3. 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.
Copyright © 2026