Access control
This page is for module authors. A module says what can be done (permissions), who holds it by default (grantTo,
roles), what a token may be given (scopes), and how a record narrows it (policies). The host app decides the rest in
authorization.roles. The model is in Access control; the reasons are in the
decision record.
A module declares all of it in one typed file, src/runtime/access.ts, with defineAccess from
@frigear-nu/auth/access, and registers it in setup() with registerAccess from @frigear-nu/auth/utils. List
@frigear-nu/auth in moduleDependencies and in package.json. All registrations of all modules and the host merge
once every module has run, and any error fails the build.
Declare access
import { defineAccess } from '@frigear-nu/auth/access'
export const eventsAccess = defineAccess('events', {
permissions: {
// Quote every key once one has a colon (eslint's `quote-props`).
'manage': { label: 'Manage events', grantTo: ['admin', 'eventmanager'] }, // events:manage
'pii': { label: 'See attendee details', sensitive: true, grantTo: ['admin'] }, // events:pii
'refund': { label: 'Refund tickets', sessionOnly: true, grantTo: ['admin'] }, // events:refund
'tickets:read': { label: 'Read your tickets', implicit: true }, // a full name: another resource
'tickets:write': { label: 'Buy tickets', implicit: true },
},
scopes: {
tickets: { label: 'Your tickets', icon: 'i-lucide-ticket', permissions: ['tickets:read', 'tickets:write'] },
},
roles: {
eventmanager: { label: 'Event manager', grant: ['events:manage', 'events:refund', 'events:pii'] },
},
})
import { registerAccess } from '@frigear-nu/auth/utils'
import { eventsAccess } from './runtime/access'
// in setup(): the opt-in role only with `settings.roles`
registerAccess(nuxt, eventsAccess, { roles: !!options.settings?.roles, from: '@frigear-nu/events' })
- Names come from the keys.
managebecomesevents:manage. A key with a colon is a full name, for a resource the module owns besides its own (events ownstickets:*). - The names are typed.
eventsAccess.permissions.managehas the type'events:manage': use it in routes, MCP tools, menus and tests instead of a string, so a rename is one edit.eventsAccess.scopes.ticketsandeventsAccess.roles.eventmanagerwork the same way. - i18n keys are derived unless you pass
labelKey/descriptionKey:<resource>.permissions.<key>and<resource>.permissions.<key>Description,<resource>.scopes.<key>,<resource>.roles.<key>. A full-name key is camel-cased (tickets:read→events.permissions.ticketsRead). Passi18n: 'oauthServer'when the module's translations live under another namespace than the resource. A key that is missing from the locale files shows thelabel. - The file lives in
src/runtime/, so server routes, app code and tests can import it as well asmodule.ts.@frigear-nu/auth/accesshas no Nuxt, h3 or Vue imports, so it is safe everywhere.
registerAccess(nuxt, access, options?) registers everything unless narrowed:
| Option | Meaning |
|---|---|
permissions | Only these permission keys (['profile:read', 'profile:write'] when the admin API is off). |
scopes | false for none, or a list of scope keys. |
roles | false for none, or a list of role keys. A grantTo naming one of the access's own roles that is left out is dropped with it, so an opt-in role needs no second permission list. |
from | The roles' provenance on the roles admin pages. Defaults to the resource. |
Permission fields
| Field | Meaning |
|---|---|
label, labelKey, description, descriptionKey, group | What the roles admin and the token form show. group defaults to the resource of the name. |
sensitive | Never covered by * or resource:*: held only when granted by name. On by default for :pii. |
sessionOnly | Only a cookie session may use it. Never held by a token, never offered as a scope. |
implicit | Held by every signed-in user, also without @frigear-nu/authorization. |
aliases | Old names that, when held, also hold this permission. Expanded on the held side only: a token with events:manage keeps working after you split it, while code that checks events:manage fails. |
grantTo | Roles that get it by default, by name. Roles that inherit them get it too. A role that is not configured is skipped, with a build note. This replaces aggregateTo. |
The same name registered by two sources must agree on sensitive, sessionOnly and implicit, or the build fails.
Typed everywhere else
Once a module registered its access, auth generates .nuxt/types/auth-access.d.ts with every registered permission and
role. The Permission type (@frigear-nu/auth/access) becomes their union in the app, the server and
nuxt.config.ts, so a misspelled permission fails typecheck in authorize, allows, definePolicy,
defineSharedPolicy, useAuthorization().can, <Can permission>, route rules, definePageMeta({ authorize }), MCP
definitions and menu, widget and tab items. In a module package's own build nothing is generated, so Permission is
string there and the package still builds.
Naming rules
resource:action, resource first, one colon:events:update,member-area:view. Reuse the actionsread,list,create,update,delete,manage.- Sensitive data ends in
:pii. A permission that exposes other people's names, emails or answers (events:pii,forms:pii) is its own permission, never a side effect ofmanage. - Money and credentials are
sessionOnly, each with a permission of its own (events:refund,forms:credentials,users:manage). The permission carries the rule, so a route cannot forget it. When you split one out of an existing permission, roles that name the old one need the new one: say so in the changelog.resource:*covers it. - Self-service is
implicit. What every signed-in user does with their own data (tickets:read,profile:write,payments:read) needs no role. Check the record with a policy, so users reach only their own. - Staff permissions are not implicit. Put them in
grantTo: ['admin']. Add a role of your own only when staff of your module differ from the host's admins.
Scopes
A scope is what a token or an OAuth client may be given. A permission is a scope by itself, as are resource:* and
* (for clients allowed wildcards). A named scope (the scopes of defineAccess) groups permissions under one
consent line: tickets expands to tickets:read and tickets:write.
A named scope may expand only to permissions that a token can hold: no sensitive and no sessionOnly permission, and
its name cannot be a registered permission. Scopes work for API tokens and OAuth alike, so a module does not depend on
OAuth Server to offer one.
Roles
A module may ship a role (the roles of defineAccess) when its staff are not the host's admins. Make it opt-in when
most hosts will not want it, and leave it out with registerAccess(nuxt, access, { roles: false }).
- Two modules defining one role merge
grantandinheritas unions. Conflictingrequirementsfail the build. - The host's
authorization.roles.<name>merges into a module role: its label and description win,grantandinheritare unions, itsrequirementsandautowin. Withreplace: trueit drops the module's definition and everygrantTogrant into it. - Grant by name what a wildcard does not cover: a role with
grant: ['events:*']lacksevents:pii.
Register requirements and policies
A requirement lets a role count only while something holds, such as an active subscription. The file's default export decides, and a throw means not met:
import { addRoleRequirement } from '@frigear-nu/auth/utils'
addRoleRequirement(nuxt, { name: 'subscription', path: resolver.resolve('./runtime/server/requirements/subscription') })
export default async ({ user, value }: { user: { id: number | string }, value: unknown }) => {
return hasActiveSubscription(user.id, value) // value: what the role set, `'*'` or plan keys
}
A policy refines one permission against a record. It can deny, never grant. Register the file; module policies run before the host's:
import { addPolicies } from '@frigear-nu/auth/utils'
addPolicies(nuxt, { path: resolver.resolve('./runtime/server/policies') })
// definePolicy is auto-imported in server code. Return false to deny, true or undefined to keep the result.
export const ownTicket = definePolicy<{ userId: number }>('tickets:read', ({ user, record }) => record.userId === user.id)
A policy runs only for a check that passes a record, unless you set { withoutRecord: true }. A shared policy
(defineSharedPolicy from @frigear-nu/auth/policies, registered with shared: true) also runs in the browser's
can(): it must stay synchronous and import nothing from Nuxt, h3 or the database.
Check them in routes and tools
Authorize first, load second, authorize with the record third. Name the permission through the access declaration:
import { eventsAccess } from '../../../access'
export default defineEventHandler(async (event) => {
await authorize(event, eventsAccess.permissions.manage)
const { id } = await getValidatedRouterParams(event, idParams().parse)
const record = await loadEvent(id)
await authorize(event, eventsAccess.permissions.manage, record)
// ...
})
An unregistered name is 403 forbidden for everyone, so a typo is not a hole. MCP tools and resources pass
{ permission } and nothing else. Never check another user with allows: userCan is for assignment and target
checks only. HTTP routes has the rest of the handler order.
Menus, widgets and tabs take permission to hide what a visitor cannot use. That is display only, so the route
behind them checks too.
Under the hood
registerAccess calls the low-level build functions of @frigear-nu/auth/utils, which stay available for what
defineAccess does not cover, such as scopes read from module options (OAuth Server's oauthServer.scopes) or a test
fixture's one-off role:
| Function | Registers |
|---|---|
addPermission(nuxt, ...definitions) | Permissions, each with its full name. |
addScope(nuxt, ...scopes) | Named scopes. |
addRole(nuxt, ...roles) | Roles, with name and from (the provenance). |
addRoleRequirement(nuxt, { name, path }) | A role requirement. |
addPolicies(nuxt, { path, shared? }) | A file of policies. |
addPermission(nuxt, { name: 'events:manage', label: 'Manage events', labelKey: 'events.permissions.manage', grantTo: ['admin'] })
addRole(nuxt, { name: 'eventmanager', label: 'Event manager', grant: ['events:manage'], from: '@frigear-nu/events' })
Test it
A fixture does not need @frigear-nu/authorization to have roles. Register the roles your tests use with addRole,
and give the fixture a role provider that reads a field of the test user. The events fixture decides by email domain,
so a cookie session and an API token get the same answer:
import { fileURLToPath } from 'node:url'
import { addRole, setRoleProvider } from '@frigear-nu/auth/utils'
import MyModule from '../../../src/module'
export default defineNuxtConfig({
modules: [
'@nuxthub/core',
'@frigear-nu/users',
MyModule,
(_options, nuxt) => {
addRole(nuxt, { name: 'wild', label: 'Wildcard staff', grant: ['events:*'] })
setRoleProvider(nuxt, { path: fileURLToPath(new URL('./server/roles/provider', import.meta.url)) })
},
],
})
import { db } from '@nuxthub/db'
import type { AuthRoleProvider } from '@frigear-nu/auth/utils'
const provider: AuthRoleProvider = {
getAssignedRoles: async (_event, userId) => {
const actor = await db.query.users.findFirst({ where: (t, { eq }) => eq(t.id, Number(userId)) })
return actor?.email.endsWith('@staff.test') ? ['eventmanager'] : []
},
setRoles: async () => {},
}
export default provider
The real example is packages/events/test/fixtures/authenticated.
- Cover the credentials. For each route, test a cookie session, an API token that carries the permission, one that
does not (
403 insufficient_scope), and for asessionOnlypermission a token that does (403 session_required).apiClient({ cookie?, bearer? })andexpectApiError(promise, status, code?)from@frigear-nu/module-kit/testingdo the calls. - Cover the wildcard. A role with
resource:*must not reach the:piipermission. - Migrations. API tokens freeze their permissions in
auth_user_tokens.permissions. Every fixture that includes a module usingauthorizeneeds theauth_user_tokensmigration with that column, and regenerate it when auth adds one: Generate test fixture migrations. Without it every token write fails, which looks like a missing session cookie. - Name permissions through the access file in tests too (
eventsAccess.permissions.refund), so a renamed permission breaks the test at compile time instead of silently testing a403 forbidden. - Unit tests. The matcher and the decision order are pure (
packages/auth/src/runtime/utils/permissions.ts). Test a rule of yours without a Nuxt server undertest/unit/.
Next steps
- Testing – boot the fixture and test API routes.
- Migrate to the permission model – what the removed APIs map to.
- Access control – the vocabulary and the fail-closed table.