Agora

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.

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

database/migrations/xxxx_create_authz_tables.ts
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 migration up().
  • dropAuthzTables(db, { tables? }) — idempotent (DROP TABLE IF EXISTS), child-first, for the migration down().
  • 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'],
    },
  },
})

On this page