The authz service
AuthzService and the services/main singleton — can, scope, hasRole, hasAnyRole, effectiveRoles, effectivePermissions, subjectsWithRole, the super-admin resolution and the per-request permission cache.
AuthzService is the engine every other surface delegates to. The Bouncer
abilities call it, the route middleware calls it, the
query scopes call it, and the
Inertia share is built from it. Calling it directly is how
you authorize anything that is not an HTTP request through Bouncer — a job, a
console command, an AI tool call, a scheduled task.
The service singleton
Import the singleton from the @adonis-agora/authz/services/main subpath and
use it anywhere. There is no container plumbing to write and no instance to
thread through your call sites:
import authz from '@adonis-agora/authz/services/main'
export default class PublishPost {
async handle(user: User, post: Post) {
if (!(await authz.can(user, 'posts.publish'))) {
throw new Error('not allowed to publish')
}
// ...
}
}The singleton is safe to import from a config file, which is what makes it usable as a plug for other Agora packages that take an authorizer at config time:
import { defineConfig } from '@adonis-agora/agent'
import { authzToolAuthorizer } from '@adonis-agora/agent/authz'
import authz from '@adonis-agora/authz/services/main'
export default defineConfig({
authorizer: authzToolAuthorizer({ authz }),
})It carries the whole service: the decisions (can, scope, hasRole,
hasAnyRole, effectiveRoles, effectivePermissions, subjectsWithRole),
the store for writes and admin reads, and createCache(). A controller that
grants a role, for instance:
import type { HttpContext } from '@adonisjs/core/http'
import authz from '@adonis-agora/authz/services/main'
import User from '#models/user'
export default class MembershipsController {
async promote({ params, auth, response }: HttpContext) {
if (!(await authz.can(auth.user!, 'members.promote'))) {
return response.forbidden()
}
const member = await User.findOrFail(params.id)
await authz.store.assignRole({ type: 'user', id: String(member.id) }, 'editor')
return response.noContent()
}
}The only members it leaves out are the synchronous refOf, currentScope and
scopes; for those, resolve the class from the container
(await app.container.make(AuthzService)) — it is the same instance.
can(user, permission, options?)
The wildcard-aware permission check — the one the can Bouncer ability wraps.
await authz.can(user, 'posts.edit')
await authz.can(user, 'posts.edit', { scope: { tenantId: 'acme' } })
await authz.can(user, 'posts.edit', { cache })
await authz.can(user, 'posts.edit', { resource: post })| Option | Type | Meaning |
|---|---|---|
scope | TenantScope? | Explicit tenant. Omit to use the configured resolvers. |
cache | PermissionCache? | Share one grant read across many checks. |
resource | unknown | What the check is about. RBAC ignores it; the superAdmin hook and a decision provider receive it. |
It resolves in this order:
- the super-admin verdict — may allow or deny;
- the decision provider, if configured — may allow, deny or abstain (also consulted for anonymous users);
- the user's granted permissions — the store's grants unioned with the
roleGrantsmapped over their effective roles — run through the wildcard matcher; - otherwise deny.
Without a decision provider, an anonymous or unmappable user is always false.
check(user, permission, options?) is the same resolution returning
{ allowed, reason, message? } (reason: 'super-admin', 'decision-provider',
'permission', 'anonymous' or 'no-grant'). canMany(user, [{ permission, resource? }]) /
checkMany(...) batch checks: one shared cache, and one decideMany round-trip to the
decision provider.
effectivePermissions(user, scope?)
The full permission set behind a can() decision, as a plain array. can()
answers one question; this hands you the whole set, which is what you want when
you are shipping the decision somewhere else — a UI snapshot, an audit log, a
debugging endpoint.
const permissions = await authz.effectivePermissions(user)
// ['posts.*', 'comments.moderate', 'billing.view']
const scoped = await authz.effectivePermissions(user, { tenantId: 'acme' })It is the union of the store's grants for the user (role-derived ∪ direct) and
every permission roleGrants maps onto their effective roles. Anonymous users
get []. Both reads go through the request's cache when one is passed as the
third argument's { cache }.
The set contains the granted patterns, wildcards and all — posts.* stays
posts.*. Test membership with permissionSatisfied(permissions, 'posts.edit')
rather than permissions.includes(...), so wildcards keep working.
buildAuthzShare in authz-react ships this set to the
browser for exactly that reason.
scope(user, resource, options?)
Resolves the query-scope constraint for a resource —
the collection-level counterpart of can. Use it directly when you want the
ScopeConstraint without a query builder (to serialize a query plan, or to feed
a non-Lucid data source); use
accessibleBy when you have a
Lucid query.
const constraint = await authz.scope(user, Post)
// { kind: 'all' } | { kind: 'none' } | a condition AST| Option | Type | Default | Meaning |
|---|---|---|---|
action | string? | 'viewAny' | The ability being scoped. Checked as a permission grant, and passed to the registered filter as ctx.action. |
scope | TenantScope? | — | Explicit tenant. |
cache | PermissionCache? | — | Share the grant read with the request's other checks. |
// "which posts may this user edit?" instead of the default viewAny
const editable = await authz.scope(user, Post, { action: 'posts.edit' })Because action defaults to viewAny, a user granted viewAny (or a wildcard
covering it, such as *) short-circuits to allow-all before any filter runs.
hasRole and hasAnyRole
Exact, tenant-aware role checks against the user's effective roles:
await authz.hasRole(user, 'admin')
await authz.hasRole(user, 'coordinator', { scope: { tenantId: 'acme' } })
// any-of: true when the user holds AT LEAST ONE
await authz.hasAnyRole(user, ['admin', 'editor'])
await authz.hasAnyRole(user, ['admin', 'editor'], { scope: { tenantId: 'acme' } })hasAnyRole is not sugar for a loop of hasRole calls: it reads the effective
roles once and intersects, so it costs one resolution regardless of how many
roles you pass, and it can never disagree with hasRole on the same input.
Both take a { scope, cache } options object and both honour the super-admin
verdict, so a super-admin answers true for any role name — and with the
request's cache, neither re-reads the roles the request already resolved.
Super-admin resolution
Every decision method — can, hasRole, hasAnyRole and scope — consults the
same two-step super-admin guard before doing any RBAC work:
- the
superAdminhook fromconfig/authz.ts, if configured.trueallows,falsedenies (it is the only hook whosefalseactively denies, even when a matching grant exists), anything nullish falls through; - the
superAdminRoleslist: if any of the user's global roles is in it, allow.
defineConfig({
superAdmin: (user, ability) => {
if (user.type === 'service' && ability.startsWith('jobs.')) return true
if (user.id === BANNED_ID) return false // hard deny, overrides every grant
return undefined // otherwise: normal RBAC
},
superAdminRoles: ['platform:super'],
})The hook's second argument is the thing being checked, so one hook can special-case each entry point. Its shape follows the caller:
| Caller | ability argument |
|---|---|
can(user, 'posts.edit') | 'posts.edit' (3rd arg: options.resource, when passed) |
scope(user, Post, { action: 'posts.edit' }) | 'posts.edit' (3rd arg: Post) |
hasRole(user, 'admin') | 'role:admin' |
hasAnyRole(user, ['a', 'b']) | 'role:a,b' |
In scope, the verdict maps onto the two terminals: true → allow-all (no
WHERE added), false → deny-all (no rows). See
superAdminRoles
for how the global-role half is fed.
The per-request permission cache
Every check reads the user's effective roles and granted permissions
from the store (plus the resolveRoles seam). A request that runs a dozen
checks would issue two dozen reads; a PermissionCache collapses them into one
role resolution and one permission read per (user, tenant) pair, including for
checks that run concurrently.
Inside a request you get this without passing anything: when no cache is
given, can, check, canMany, hasRole, hasAnyRole, scope,
effectiveRoles and effectivePermissions use a cache bound to the current
request (the @adonis-agora/context store, or the AdonisJS HttpContext when
useAsyncLocalStorage is on). That covers user.can() from the mixin, the
Bouncer abilities and POST /authz/can. Outside a request (no context), each
call reads the store. Pass your own cache to choose the scope explicitly, as
below.
import type { HttpContext } from '@adonisjs/core/http'
import authz from '@adonis-agora/authz/services/main'
import Post from '#models/post'
export default class PostsController {
async index({ auth, inertia }: HttpContext) {
const user = auth.user!
const cache = authz.createCache() // one per request
const posts = await authz.scope(user, Post, { cache }) // rows this user may see
return inertia.render('posts/index', {
posts,
canCreate: await authz.can(user, 'posts.create', { cache }), // no second read
canPublish: await authz.can(user, 'posts.publish', { cache }),
isAdmin: await authz.hasRole(user, 'admin', { cache }), // role read memoized too
})
}
}Create one cache per request (or per job) and let it fall out of scope with it. A cache is a snapshot: it never expires and never invalidates, so a grant written after the cache warmed is not visible to it. That is exactly what you want inside one request — every check in it decides against the same authorization state (roles included) — and exactly what you do not want in a long-lived process. Never hoist a cache to module scope.
export default class PostsController {
async index(ctx: HttpContext) {
const service = await ctx.containerResolver.make(AuthzService)
const cache = service.createCache()
const user = ctx.auth.user
return {
canCreate: await service.can(user, 'posts.create', { cache }),
canModerate: await service.can(user, 'comments.moderate', { cache }),
}
}
}PermissionCache is exported from the package barrel if you want to build one
against a store yourself (new PermissionCache(store)), and exposes
getRoles(user, scope?) and getPermissions(user, scope?) for the raw memoized
reads, plus satisfies(user, ability, scope?) for a wildcard-aware check off
the permission set.
The cache memoizes both dimensions a check reads — roles and permissions —
once per (user, tenant) per request. can() resolves the effective-role
union (the token's global roles, the resolveRoles seam and
store.getRolesForSubject) and the permission grants; with a cache the eight
can() calls of a request are one role resolution and one permission read,
not sixteen. A host that derives roles from domain tables via resolveRoles
no longer pays those queries per check.
The cache is a per-request snapshot and never invalidates: a grant made
mid-request does not flip a cached check (the next request's cache sees it).
That is the point — every decision in one request is made against one state.
Do not keep a cache across requests. The request-bound default cache behaves
the same way, so a long-running job that runs inside one Agora context sees
the grants as they were at its first check; pass a fresh createCache() per
unit of work there. A read that fails is not cached: the next check in the
request retries it.