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',
  },
  resolveClient: () => authzClient.getStore(), // optional; see "Joining a transaction"
  subjectIdType: 'integer',   // optional; see "Native key-type pivots" below
})

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.

Native key-type pivots

The subject pivots (subject_role, subject_permission) default to a TEXT subject_id, which fits every subject kind in one table — integer ids, UUIDs, ULIDs, even in the same app. The roles relation works on it with any host key type, so this is a storage choice, not a correctness one — with one exception: has / whereHas('roles') on Postgres with an integer PK compares the real columns and errors on integer = character varying (see the relation page). When all your subjects hold integer ids, the pivot can carry the host's native type instead —

stores.lucid({
  subjectIdType: 'integer', // increments()   → INTEGER
  // subjectIdType: 'bigint', // bigIncrements() → BIGINT
})

— which gives the pivot the same storage and index shape as your primary key and makes a foreign key to it possible (MySQL needs the exact integer size for that, which is why there are two values). Non-integer ids then fail LOUD on write instead of silently matching nothing. Mixed apps (integer users and UUID subjects in one store) keep the TEXT default; nothing else changes for them.

Choose at setup: the store, createAuthzTables and the published migration all take the same option, and they must agree. Switching an existing install later is a data migration, not a flag flip (BIGINT instead of INTEGER for 'bigint'):

-- Postgres (fails loudly on non-numeric rows — good)
ALTER TABLE authz_subject_role ALTER COLUMN subject_id TYPE INTEGER USING subject_id::integer;
ALTER TABLE authz_subject_permission ALTER COLUMN subject_id TYPE INTEGER USING subject_id::integer;
-- MySQL (strict mode errors on non-numeric rows — good)
ALTER TABLE authz_subject_role MODIFY subject_id INTEGER NOT NULL;
ALTER TABLE authz_subject_permission MODIFY subject_id INTEGER NOT NULL;
-- SQLite: rebuild (no ALTER COLUMN) —
CREATE TABLE authz_subject_role_new (subject_type VARCHAR(191) NOT NULL, subject_id INTEGER NOT NULL, role_id VARCHAR(191) NOT NULL, tenant_id VARCHAR(191) NOT NULL DEFAULT '', PRIMARY KEY (subject_type, subject_id, role_id, tenant_id));
INSERT INTO authz_subject_role_new SELECT subject_type, CAST(subject_id AS INTEGER), role_id, tenant_id FROM authz_subject_role;
DROP TABLE authz_subject_role;
ALTER TABLE authz_subject_role_new RENAME TO authz_subject_role;
-- (same for authz_subject_permission, then recreate the _subject_idx indexes)

Joining a transaction

Granting a role is almost never an isolated act — it happens inside the same db.transaction that creates the user and writes the audit trail. A store call outside that transaction commits on its own: a rollback later leaves the role granted (or a guard reading member counts sees a state the transaction will never write). Three layers, most local wins:

app/authz/transaction.ts
import { AsyncLocalStorage } from 'node:async_hooks'
import type { StoreQueryClient } from '@adonis-agora/authz'

// The ambient seam: set the config's `resolveClient` to read this.
export const authzClient = new AsyncLocalStorage<StoreQueryClient>()
  1. opts.client (per call) — the Lucid-style escape hatch: await authz.store.assignRole(ref, 'editor', undefined, { client: trx }).

  2. store.withClient(trx) (one injection point) — returns a view that is itself a PermissionStore; every call on it joins the transaction:

    await db.transaction(async (trx) => {
      const authz = store.withClient(trx)
      await authz.assignRole(ref, 'editor')
      const n = await authz.countSubjectsForRole('admin') // the last-admin guard
      if (n === 0) throw new Error('cannot empty the admins')
    })
  3. resolveClient (config, above) — an ambient AsyncLocalStorage read, so plain store calls auto-join the active transaction with no per-call work.

A scoped view (and any call carrying a client) never runs DDL and never touches the root connection: ensure the schema at boot — or use the migration — before opening the transaction. On a single-connection pool (SQLite) a root DDL mid-transaction would wait on it; that is what this rule avoids.

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.

decisionProvider

An external policy decision point (Cerbos, OPA, …) consulted right after the super-admin check: it can allow, deny or abstain on can(), batch canMany(), and plan query scopes. See External policy engines; a Cerbos adapter ships at @adonis-agora/authz/cerbos.

import { CerbosDecisionProvider } from '@adonis-agora/authz/cerbos'

defineConfig({
  // ...
  decisionProvider: new CerbosDecisionProvider({ client, principal, resource }),
})

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({
  resolveSubjectRef: (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 SubjectRole.query()
      .where('subject_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 subjectsWithRole:

defineConfig({
  // reverse of `resolveRoles`: your domain tables.
  resolveRoleMembers: async (role, scope) => {
    const rows = await SubjectRole.query()
      .where('role', role)
      .if(scope?.tenantId, (query) => query.where('tenant_id', scope!.tenantId!))
    return rows.map((row) => row.subjectId)
  },

  // 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 subjectsWithRole 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(). MySQL 8 has no CREATE INDEX IF NOT EXISTS (a MariaDB-only extension), so there the index DDL drops the clause and swallows the duplicate-key error — same idempotency, no version-sniffing. Verified against a real MySQL 8.
  • It also upgrades a pivot created by an earlier version: the source column of role sources is added non-destructively. Widening that table's primary key is a manual, one-off step (SQL in the linked section).
  • 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_subject_role, authz_subject_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

Partner

Your product as the official integration for Authz, right here.

Become a partner →