External policy engines
Hand decisions to an external policy decision point (Cerbos, OPA, …) with the DecisionProvider seam — allow, deny or abstain on can(), batch checks with canMany(), and plan query scopes for accessibleBy(). Ships a Cerbos adapter at @adonis-agora/authz/cerbos.
The RBAC store is grant-only: it can say "yes, a role grants posts.edit", never "no".
A decision provider is the authoritative, resource-aware counterpart — a seam for an
external policy decision point (PDP) such as Cerbos, OPA/OpenFGA or a
remote authz service. It can allow, deny or abstain on a check, batch a list page's
checks into one round-trip, and turn the engine's query plan into the
scope constraint that accessibleBy compiles to a WHERE.
Configuring it
import { defineConfig, stores } from '@adonis-agora/authz'
import { CerbosDecisionProvider } from '@adonis-agora/authz/cerbos'
import { HTTP } from '@cerbos/http'
import env from '#start/env'
import Post from '#models/post'
export default defineConfig({
default: 'lucid',
stores: { lucid: stores.lucid() },
decisionProvider: new CerbosDecisionProvider({
client: new HTTP(env.get('CERBOS_URL')), // or (user, ctx) => per-tenant client | undefined
principal: (user, { ref }) =>
ref && { id: ref.id, roles: (user as User).roles, attr: { tenantId: (user as User).tenantId } },
resource: (_permission, post) =>
post instanceof Post
? { kind: 'app:post', id: String(post.id), attr: { owner: post.ownerId, status: post.status } }
: undefined, // not a Post → abstain, RBAC decides
scope: {
resource: (model) => (model === Post ? { kind: 'app:post' } : undefined),
field: (attribute) => ({ id: 'id', owner: 'owner_id', status: 'status' })[attribute],
},
}),
})Any object implementing DecisionProvider works — the Cerbos adapter is just one:
import type { DecisionProvider } from '@adonis-agora/authz'
const provider: DecisionProvider = {
// user = the host user as passed to the check; ctx = { ref, tenant } already resolved
decide(user, permission, resource, ctx) {
if (!(resource instanceof Invoice)) return undefined // abstain
return resource.lockedAt ? { allowed: false, message: 'Invoice is locked' } : undefined
},
// optional: decideMany(user, requests, ctx) and planScope(user, resourceKey, action, ctx)
}A verdict is true / { allowed: true } (allow), false / { allowed: false, message? }
(deny), or undefined / null (abstain — no opinion, keep resolving).
Precedence
For authz.can(user, permission, { resource }), authz.check(...), authz.canMany(...) and the
Bouncer can ability:
- Super-admin — the
superAdminhook (it now also receives theresourceas its 3rd argument) andsuperAdminRoles. Allow/deny short-circuit. - Decision provider
decide— allow/deny short-circuit;undefinedabstains. - RBAC — the store's grants ∪
roleGrantsover the effective roles, wildcard-aware. - Otherwise deny.
The provider is consulted for anonymous callers too (user is whatever you passed, ctx.ref
is undefined), so an engine can allow public access; abstain to keep the default anonymous
deny. The Bouncer abilities themselves still reject guests before calling the service, so
public access goes through authz.can(null, …) / authz.scope(null, …).
Role checks (hasRole / hasAnyRole) do not go through the provider — roles are data,
not decisions. Your own Bouncer abilities and policies run outside the service; they see the
provider's verdict only if they delegate to authz.can.
authz.check(user, permission, options?) returns { allowed, reason, message? } where
reason is 'super-admin' | 'decision-provider' | 'permission' | 'anonymous' | 'no-grant'.
The Bouncer can ability uses it, so a provider's deny message becomes the
E_AUTHORIZATION_FAILURE message.
Batching: canMany / checkMany
const flags = await authz.canMany(user, posts.map((post) => ({ permission: 'update', resource: post })))Super-admin is resolved per item first; the rest go to decideMany in one call (the Cerbos
adapter sends one CheckResources request, de-duplicating resources and chunking by
maxBatchSize, default 50). Without decideMany — or when it throws or returns the wrong number
of verdicts — each item falls back to its own decide. RBAC reads share one per-request cache.
Query scopes: planScope
authz.scope(user, Model, { action }) (and therefore accessibleBy) consults planScope
right after super-admin, before the permission grant and the registered scope filter. A
returned constraint is used as-is; undefined abstains. planScope is consulted for anonymous
users too.
The Cerbos adapter calls PlanResources and maps the plan with cerbosPlanToScope:
KIND_ALWAYS_ALLOWED→allow-all,KIND_ALWAYS_DENIED→deny-all;and/or→ groups,notpushed down to the leaves (De Morgan);eq ne lt gt le ge inwith the operands in either order;eq null→isNull;- anything else (arithmetic, lambdas, membership on an attribute, principal variables, unmapped
attributes) throws
CerbosPlanUnsupportedError— which the adapter turns intodeny-all.
field maps a Cerbos resource attribute to the column accessibleBy filters on. The
default keeps plain names as-is and rejects nested attributes (owner.id): in the scope AST
a dotted field means table.column, so map nested attributes explicitly (a qualified
'posts.owner_id' is fine). Mapped columns must match SAFE_IDENTIFIER.
Failure mode
The Cerbos adapter fails closed by default: an unreachable PDP denies, and an unsupported
query plan scopes to no rows. Set onFailure: 'abstain' to fall back to your own RBAC instead,
and onError(error, { phase, ability }) to observe failures. A custom provider's thrown errors
propagate (the check fails loudly).
Testing against a real Cerbos
The repo's integration spec runs against a real PDP loaded with
packages/core/test/fixtures/cerbos/doc.yaml:
docker run --rm -d --name authz-cerbos -p 3592:3592 \
-v "$PWD/packages/core/test/fixtures/cerbos:/policies:ro" ghcr.io/cerbos/cerbos:0.55.0 server
CERBOS_TEST_URL=http://127.0.0.1:3592 pnpm --filter @adonis-agora/authz exec vitest run test/cerbos_integration.spec.tsQuery 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.
Configuration
The drivers-in-core config idiom — select a permission store, wire the super-admin hook, user-ref mapping and tenant resolver.