Configuration
The drivers-in-core config idiom — select a permission store, wire the super-admin hook, user-ref mapping and tenant resolver.
config/authz.ts uses defineConfig plus the stores factory. Each store is a
lazy thunk — the lucid store imports @adonisjs/lucid only when selected, so
the peer dependency stays optional.
import { defineConfig, stores } from '@adonis-agora/authz'
export default defineConfig({
default: 'lucid',
stores: {
memory: stores.memory(),
lucid: stores.lucid({ /* connection, tables, autoCreateSchema */ }),
},
})Stores
Persists RBAC in your database via @adonisjs/lucid.
stores.lucid({
connection: 'pg', // defaults to the app's default connection
autoCreateSchema: false, // recommended: use the published migration instead
tables: { // optional name overrides
roles: 'authz_roles',
},
})autoCreateSchema is on by default: the store runs
CREATE TABLE IF NOT EXISTS for its five tables on first use, so a fresh app
works with no migration step at all. The tables authz needs are authz's own
problem, not yours.
Turn it off in production. autoCreateSchema: false makes the store assume the
tables exist, and you own them through the migration configure published (or
through createAuthzTables in a migration of your
own) — so schema changes ship with your deploy, in order, reviewable, rather than
happening on whichever request happens to arrive first.
Both paths emit the same DDL, so it is safe to start with the default and switch later: the migration finds the tables already there and does nothing.
Super-admin hook
Return true to allow, false to deny, or undefined to fall through to the
normal RBAC resolution. It receives the resolved { type, id } reference.
defineConfig({
// ...
superAdmin: (user) => user.id === '1',
})The super-admin hook is the only hook whose false actively denies —
it short-circuits even when a matching grant exists.
superAdminRoles
Global role names that short-circuit every check to allow — no rows seeded, no permissions listed:
defineConfig({
superAdminRoles: ['platform:super'],
})The superAdmin hook is consulted first and its false outranks the list; only
the token's global roles are matched, not the whole effective union. See
Roles → superAdminRoles
for the full composition rules and when to prefer a roleGrants wildcard instead.
Mapping a user to a reference
By default user.id (and optional user.type) is used. Override to fit your
model:
defineConfig({
resolveUserRef: (user) => ({ type: 'account', id: user.uid }),
})Tenant resolver
Return a tenant id string (or { tenantId }) for the current request; omit it
for single-tenant apps.
import { HttpContext } from '@adonisjs/core/http'
defineConfig({
tenant: () => HttpContext.getOrFail().request.header('x-tenant'),
})Effective roles: resolveRoles & roleGrants
A user's effective roles are the union of three sources — and two config seams let you feed them without seeding the authz store:
effective roles = context/global roles (token claim, via authkit)
∪ app roles (the `resolveRoles` seam)
∪ store roles (assigned via store.assignRole)Every decision — can, hasRole, hasAnyRole, the
route middleware, a
query scope and the
frontend share — resolves against this same union, so they
never disagree.
resolveRoles — app roles
Roles that live in your domain (typically a user_roles-style table), not in
the token and not in the authz store. Return them and they enter the union and
get mapped by roleGrants, exactly like global roles. Optional — omit it and
only the token and store decide.
defineConfig({
resolveRoles: async (user, scope) => {
const rows = await UserRole.query()
.where('user_id', user.id)
.if(scope, (q) => q.where('tenant_id', scope!.tenantId))
return rows.map((r) => r.role)
},
})The callback receives the resolved { type, id } reference and the active
TenantScope, so app roles can themselves be tenant-scoped.
resolveRoleMembers & resolveGlobalRoleMembers — the reverse seams
resolveRoles answers "which roles does this user hold?". Two more keys answer
the opposite question — "who holds this role?" — for
usersWithRole:
defineConfig({
// reverse of `resolveRoles`: your domain tables.
resolveRoleMembers: async (role, scope) => {
const rows = await UserRole.query()
.where('role', role)
.if(scope?.tenantId, (query) => query.where('tenant_id', scope!.tenantId!))
return rows.map((row) => row.userId)
},
// reverse of the token's global-role claim: your identity store.
resolveGlobalRoleMembers: async (role) => {
const accounts = await Account.query().whereJsonSuperset('global_roles', [role])
return accounts.map((account) => ({ type: 'user', id: account.id }))
},
})Each returns bare ids or { type, id } references. Both are optional — and an
unconfigured one contributes nothing rather than failing, so a usersWithRole
call simply returns a shorter list. Wire the reverse seam for every source you
wired forwards; Roles covers the pairing
and the failure mode.
roleGrants — role → permissions, no seed
A role → permissions/wildcards map applied to the user's effective roles at
check time, without seeding the store. Union it into permission checks to grant a
role its abilities purely from config:
defineConfig({
roleGrants: {
editor: ['posts.*', 'comments.moderate'],
auditor: ['audit.*'],
},
})Now a user with the editor role (from any of the three sources) passes
can('posts.edit') even though nothing was written to authz_role_permission.
Standalone schema helpers
With autoCreateSchema: false (recommended for production) the store no longer
creates its tables — you own the schema through a Lucid migration. The package
exports the exact same DDL the store would otherwise run, so the two paths never
drift:
import { BaseSchema } from '@adonisjs/lucid/schema'
import { createAuthzTables, dropAuthzTables } from '@adonis-agora/authz'
export default class extends BaseSchema {
async up() {
this.defer((db) => createAuthzTables(db))
}
async down() {
this.defer((db) => dropAuthzTables(db))
}
}createAuthzTables(db, { tables? })— idempotent (CREATE TABLE IF NOT EXISTS), dialect-aware (Postgres / MySQL / SQLite). Safe to call from a migrationup().dropAuthzTables(db, { tables? })— idempotent (DROP TABLE IF EXISTS), child-first, for the migrationdown().AUTHZ_TABLES— the default table names (authz_roles,authz_permissions,authz_role_permission,authz_user_role,authz_user_permission).
Pass matching tables overrides to both the store config and these helpers so
they agree:
import { AUTHZ_TABLES } from '@adonis-agora/authz'
const tables = { ...AUTHZ_TABLES, roles: 'rbac_roles' }
// config/authz.ts → stores.lucid({ autoCreateSchema: false, tables })
// migration → createAuthzTables(db, { tables })this.defer((db) => …) hands the helpers a Lucid query client whose dialect is
read directly, so the created_at column type is chosen correctly
(TIMESTAMP on Postgres, DATETIME elsewhere) even inside a migration.
Sync catalog
Declare roles → permissions to seed with node ace authz:sync:
defineConfig({
catalog: {
permissions: ['system.manage'],
roles: {
editor: ['posts.*'],
viewer: ['posts.view', 'comments.view'],
},
},
})Query scopes
Register a scope filter for a resource so accessibleBy() constrains a collection to the rows a user may see — the scopes config key, ScopeRegistry.register, and the eq/where/whereIn/and/or DSL.
Bouncer integration
How @adonis-agora/authz registers static abilities backed by the store, and how to use them in controllers and Edge.