Driver-based authorization engine for AdonisJS 7 + Lucid: hierarchical scopes with downward-only inheritance, explicit denies that always win, expiring assignments and polymorphic holders — behind a single contract, so the backend is a config choice, not an architectural commitment.
Ships two drivers that pass the same executable contract suite:
database(default) — self-contained, engine-agnostic SQL over its ownauthz_*tables. Zero extra infrastructure.openfga— facts live in an OpenFGA server (Zanzibar model); the catalog and your scope hierarchy stay local, so switching drivers is a facts migration, not a rewrite.
import authorization from '@jantstack/adonis-authz/services/main'
import { APP_SCOPE } from '@jantstack/adonis-authz'
await authorization.grant({ type: 'users', uuid }, 'support', APP_SCOPE, {
expiresAt: new Date(Date.now() + 30 * 86_400_000),
})
await authorization.authorize({ type: 'users', uuid }, 'audit:read', APP_SCOPE) // → booleannpm i @jantstack/adonis-authz
node ace configure @jantstack/adonis-authz
node ace migration:runconfigure registers the provider, the commands and the appAccess middleware, defines the env variables, and publishes into your project what belongs to you: the migration for the authz_* tables and two config files (config/authorization.ts for drivers, config/app_acl.ts for your role catalog).
For the OpenFGA driver, also install its SDK (optional peer): npm i @openfga/sdk.
- Hierarchical scopes, inheritance only downward. A grant on a scope authorizes on that scope and all its descendants — never on siblings or ancestors. The engine only reserves the root (
app); every other level is yours, declared by theresolveAncestorsresolver you inject. - Explicit deny wins. A deny anywhere in the scope chain blocks the permission even if a role grants it. Removing the deny restores it.
- Expiry is observable. An assignment past its
expiresAtgrants nothing — enforced in SQL by thedatabasedriver and by an FGA condition inopenfga, so no scheduler is needed. - Polymorphic holders. Users, admins, API integrations — any model with a morph name. Two holders with the same uuid and different type never cross.
- Deny by default. Unknown permission, role without it, or no valid assignment →
false.authorize()doesn't throw on any of those: an unanswerable question is answered "no". A backend that is unreachable is a different matter — it raisesAuthorizationBackendError(503), never a silentfalse. - Idempotent writes. Re-granting doesn't duplicate (it refreshes the expiry); re-revoking is a safe no-op.
These aren't prose promises: they're tests/… cases in the contract suite below.
Nothing about your model is hardcoded. Three seams:
// config/authorization.ts (published by configure)
export default defineConfig({
default: env.get('AUTHZ_DRIVER', 'database'),
// 1. Your guards → FGA types (only the openfga driver uses this)
holderTypes: { users: 'user', admins: 'admin', integrations: 'integration' },
drivers: {
// 2. Your scope tree: organization → unit, project → site, whatever
database: () => new DatabaseAuthorizationDriver({ resolveAncestors }),
},
// 3. Your side-effects on every write (audit, events, notifications)
hooks: { onWrite: (event) => audit(event) },
})onWrite runs after the write succeeded, so a hook that throws is logged and swallowed: propagating it would report a failure for an operation that did happen, and invite the caller to retry it.
ScopeType is an open string, so define your own union for type safety and let resolveAncestors describe the tree. The engine never queries your tables.
router
.get('/admin/audit-log', [AuditLogsController, 'index'])
.use(middleware.appAccess({ permission: 'audit:read' }))The middleware resolves the authenticated holder from its morph name and asks the engine. Identity decides what you may do; if you also issue scoped API tokens, that's an orthogonal check — the token narrows, it never widens.
Two things it does not do. It only checks the app scope — enforcing per-organization (or per-unit) access is your controller's or your own middleware's job, because only your domain knows which scope a given route belongs to. And it requires the holder to expose uuid: the engine identifies holders by uuid, not by the model's primary key, so a model with a numeric PK is rejected with an explicit error.
Roles and permissions are config-driven and synced idempotently:
// config/app_acl.ts
permissions: [{ slug: 'audit:read' }, { slug: 'admin:manage' }],
roles: [{ slug: 'superadmin', rank: 100, permissions: '*' }],import { syncAuthzCatalog } from '@jantstack/adonis-authz'
await syncAuthzCatalog(appAclCatalog()) // additive, safe to re-runrank is metadata for your assignment policy ("nobody grants a role at or above their own rank"). The engine stores it; enforcing a privilege ceiling is a decision only your domain can make — the engine is mechanism, not policy.
Implement AuthorizationDriver, register its factory, and prove it:
import { runAuthorizationDriverContract } from '@jantstack/adonis-authz/testing'
runAuthorizationDriverContract({
name: 'my-driver',
makeDriver: () => new MyDriver(),
seedCatalog: (catalog) => syncAuthzCatalog(catalog),
cleanup: () => wipeEverything(),
})A driver that passes honors the semantics above, so call-sites never change when you swap backends.
The package runs that suite on itself: npm test judges the database driver over in-memory SQLite — no host application, no Postgres — and OPENFGA_TEST_URL=… npm test adds the openfga driver to the same verdict. CI runs both before anything ships.
node ace openfga:provision # creates a store + writes the model from your holderTypes
node ace openfga:import --dry-run # copies assignments/denies from the database driver
node ace openfga:importThe import copies, it doesn't move: your authz_* tables stay intact, so rolling back is setting AUTHZ_DRIVER=database again.
Choosing this driver adds a second runtime dependency to every authorization check: the catalog is read from your database and the facts from FGA. If FGA is unreachable, the engine throws AuthorizationBackendError — it does not quietly return false.
You don't write try/catch for this. The error carries status = 503, so a standard AdonisJS exception handler answers on its own, and with the right code: the application isn't broken (500), a dependency is unavailable. Catch it only if a particular endpoint needs a particular response.
Why not swallow it and return false? Denying silently during an outage strips every user of their permissions with nothing to indicate why, and sends you hunting for a misconfigured role that doesn't exist. Access is denied either way; only the diagnosis differs.
And why the engine's own error type instead of the driver's? Because a raw FgaError would force any call-site that wants to tell "backend down" apart from anything else to import { FgaError } from '@openfga/sdk' — coupling it to the very backend this package abstracts, and breaking that code the day you switch drivers.
So three outcomes stay distinguishable:
| Situation | Result |
|---|---|
| No permission | false |
| Invalid question (unknown permission or role) | Exception, 422 |
| Couldn't ask (backend unreachable) | AuthorizationBackendError, 503 |
The database driver has no equivalent failure: authorization is available whenever your database is, which you need anyway.
Three more properties worth knowing before putting it in front of production traffic — none of them can grant access that wasn't granted, all fail towards denied:
-
Changing an expiry is not atomic. FGA rejects deleting and writing the same tuple key in one transaction, so replacing an assignment's expiry is a delete followed by a write. Between the two,
authorize()answersfalse, and a crash in that window loses the assignment; re-running the grant restores it (writes are idempotent). The driver reads the current tuple first, so this only happens when the expiry actually changes — a first grant is a plain write, and re-granting something identical (a seeder run again) touches nothing at all. That read is a shortcut, not a precondition: if it fails, or if a concurrent writer wins the race, the grant is still written. -
Expiry follows the app server's clock. The
not_expiredcondition is evaluated against acurrent_timeyour process sends with each check, so a skewed clock makes assignments expire early or late. Keep NTP running — the same requirement your JWTs already have. -
There is no distributed transaction with your database. A
grantvalidates the role against the local catalog and then writes the tuple to FGA. Delete that role from the catalog afterwards and the tuple is orphaned — butauthorize()finds no permission→role mapping for it and denies, so the inconsistency fails closed.openfga:importis likewise not atomic; it is idempotent, so a run that dies half-way is fixed by running it again.
All of these are consequences of the facts living in another system, and none of them apply to the database driver.
| Node | ≥ 20.6 |
| AdonisJS | ^7 (peer) · Lucid ^22 (peer) |
| OpenFGA SDK | ^0.9 (optional peer, only for that driver) |
| Databases | PostgreSQL, MySQL, SQLite |
| Module format | ESM only |
Extracted from the adonis7-base chassis, where it runs in production-shaped projects. Maintained according to that chassis's needs.
MIT