Agora

Bouncer integration

The can / hasRole Bouncer abilities backed by the store, and how to use them in controllers and Edge.

The abilities

@adonis-agora/authz registers two Bouncer abilities whose body consults the store, so a permission granted in the database is a permission Bouncer knows about. configure publishes them to app/abilities/authz.ts:

app/abilities/authz.ts
import { defineAuthzAbilities } from '@adonis-agora/authz'

export const { can, hasRole } = defineAuthzAbilities()
  • can(user, permission, resource?) — allows when the user's grants satisfy permission (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

With no argument, defineAuthzAbilities() uses the app's authz service — the same one import authz from '@adonis-agora/authz/services/main' gives you — and reaches for it on the first check, so the file can be imported before the app has booted. Pass a service to use another one, which is what a test that constructs the service by hand does (see Testing):

const service = new AuthzService({ store: new MemoryPermissionStore() })
const { can, hasRole } = defineAuthzAbilities(service)

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>
@end

RBAC 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'])

Inside a request (the @adonis-agora/context store, or HttpContext with useAsyncLocalStorage), these calls and the Bouncer can / hasRole abilities already share one cache per request, so the role resolution and the grant set are each read once without passing anything. Outside a request, or to pick the scope yourself, share a cache explicitly:

app/controllers/posts_controller.ts
import type { HttpContext } from '@adonisjs/core/http'
import authz from '@adonis-agora/authz/services/main'

export default class PostsController {
  async show({ auth, params, inertia }: HttpContext) {
    const cache = authz.createCache() // one per request
    return inertia.render('posts/show', {
      canEdit: await authz.can(auth.user!, 'posts.edit', { cache }),
      canDelete: await authz.can(auth.user!, 'posts.delete', { cache }), // same fetch
    })
  }
}

See 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 SubjectRef. When you pair it with an auth provider such as @adonis-agora/authkit, point resolveSubjectRef at the identitySubjectRef seam so the provider's identity maps straight to a SubjectRef:

config/authz.ts
import { defineConfig, identitySubjectRef, stores } from '@adonis-agora/authz'

export default defineConfig({
  default: 'lucid',
  stores: { lucid: stores.lucid() },
  resolveSubjectRef: identitySubjectRef,
})

identitySubjectRef 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.subjectsWithRole('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.

On this page

Partner

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

Become a partner →