Bouncer integration
How @adonis-agora/authz registers static abilities backed by the store, and how to use them in controllers and Edge.
Why static abilities
Bouncer abilities are defined ahead of time:
Bouncer.ability((user, ...args) => boolean | AuthorizationResponse). There is
no runtime API to register one ability per database row. So @adonis-agora/authz
registers a small fixed set of abilities whose body consults the DB-backed
store.
configure publishes them to app/abilities/authz.ts:
import app from '@adonisjs/core/services/app'
import { AuthzService, defineAuthzAbilities } from '@adonis-agora/authz'
const service = await app.container.make(AuthzService)
export const { can, hasRole } = defineAuthzAbilities(service)can(user, permission, resource?)— allows when the user's grants satisfypermission(with wildcards). Denies with HTTP 403 otherwise.hasRole(user, role)— allows when the user holds the named role.
Both deny anonymous users.
These are abilities, not policies. Policies are auto-indexed into
.adonisjs/policies.ts by the indexPolicies() init hook in your app's
adonisrc.ts; abilities are registered by passing them to your Bouncer
instance / middleware. @adonis-agora/authz publishes abilities, so it needs
no indexPolicies() wiring.
Building the abilities yourself
defineAuthzAbilities(service) takes a service instance — the form the published
file uses, and the one you want in a test where you construct the service by
hand. When you would rather hand it a resolver than await the container first,
authzAbilities takes the thunk instead and awaits it for you:
import app from '@adonisjs/core/services/app'
import { AuthzService, authzAbilities } from '@adonis-agora/authz'
export const { can, hasRole } = await authzAbilities(() => app.container.make(AuthzService))Both return the same { can, hasRole } pair. Prefer defineAuthzAbilities when
you already hold a service (see Testing), authzAbilities
when you only have a way to get one.
Using them
import { can, hasRole } from '#abilities/authz'
// Boolean (no throw):
await ctx.bouncer.allows('can', 'posts.edit', post)
await ctx.bouncer.denies('can', 'posts.delete')
// Throwing (E_AUTHORIZATION_FAILURE → 403):
await ctx.bouncer.authorize('hasRole', 'admin')In Edge templates (share bouncer.edgeHelpers with the view as usual):
@can('can', 'posts.edit')
<a href="/posts/{{ post.id }}/edit">Edit</a>
@end
@cannot('hasRole', 'admin')
<p>Admins only.</p>
@endRBAC grants are model-less, so the optional resource is not inspected by the
default can ability. For per-record ownership ("can edit this post"),
pair @adonis-agora/authz with a normal Bouncer policy and combine the checks.
Using the engine directly
Bouncer covers the HTTP request. For everything else — a queued job, an ace command, a scheduled task — import the service singleton and ask it straight:
import authz from '@adonis-agora/authz/services/main'
await authz.can(user, 'posts.edit') // wildcard-aware
await authz.hasRole(user, 'admin')
await authz.hasAnyRole(user, ['admin', 'editor'])For a request that runs many checks, share a cache so the grant set is read once:
import { AuthzService } from '@adonis-agora/authz'
const service = await app.container.make(AuthzService)
const cache = service.createCache()
await service.can(user, 'posts.edit', { cache })
await service.can(user, 'posts.delete', { cache }) // reuses the same fetchSee The authz service for the full surface — the
scope() signature, effectivePermissions, the super-admin resolution and the
cache's lifetime.
Pairing with an authentication provider (AuthKit)
@adonis-agora/authz never owns a users table — it identifies users by a
polymorphic UserRef. When you pair it with an auth provider such as
@adonis-agora/authkit, point resolveUserRef at the
identityUserRef seam so the provider's identity maps straight to a UserRef:
import { defineConfig, identityUserRef, stores } from '@adonis-agora/authz'
export default defineConfig({
default: 'lucid',
stores: { lucid: stores.lucid() },
resolveUserRef: identityUserRef,
})identityUserRef accepts any identity carrying a userId (or an id) and
returns { type: 'user', id: String(userId ?? id) }, falling back to the default
resolver when neither is present. It never imports authkit, so pairing the two
packages stays optional in both directions.
Division of responsibility
Authentication answers who this is. authz answers what they may do — and that includes roles. A global role asserted by the token is one of the three sources authz unions into a user's effective roles, right next to your domain tables and the authz store, so every role question goes to authz regardless of where the answer happens to live:
// Roles — always authz, whatever the source.
await ctx.bouncer.authorize('hasRole', 'admin')
await authz.hasRole(user, 'admin')
await authz.effectiveRoles(user) // the whole union
await authz.usersWithRole('admin') // and the reverse
// Permissions — authz too, with wildcards and tenancy.
await ctx.bouncer.allows('can', 'posts.edit')Do not check a role through the auth provider. An
identity.hasGlobalRole('admin') call sees only the token claim: it misses the
same role assigned in the authz store or resolved from your own tables, so it
disagrees with bouncer.allows('hasRole', 'admin') on the same user. Route
every role check through authz and the two can never drift.
Still keep the stores separate: do not seed or mirror the provider's roles into the authz tables. authz reads the token claim at check time — copying it in would give you two copies to keep in sync, which is the problem the union exists to avoid.