Agora
Governance

Authz (Bouncer) adapter

Swap the ADMIN-only role gate for ability-based authorization — the @adonis-agora/agent/authz adapter checks each tool's declared ability through @adonis-agora/authz (Bouncer), tenant-scoped and fail-closed.

Every tool can declare an ability (e.g. 'refund.issue'). The default role-based policy ignores it and gates on roles; the authz adapter consults it instead, resolving each decision through @adonis-agora/authz (AdonisJS Bouncer) with wildcard matching and tenant scoping. It lives on the @adonis-agora/agent/authz subpath, so apps that don't use authz never load it.

Wire it

Replace the ADMIN-only DefaultToolAuthorizer with authzToolAuthorizer, passing your app's AuthzService:

config/agent.ts
import { defineConfig } from '@adonis-agora/agent'
import { authzToolAuthorizer } from '@adonis-agora/agent/authz'
import authz from '@adonis-agora/authz/services/main'

export default defineConfig({
  model: () => aiSdkModel(openai('gpt-4o-mini')),
  authorizer: authzToolAuthorizer({ authz }),
})

@adonis-agora/authz/services/main is the canonical way to reach the service: it is the already-resolved singleton, so a config file gets it with a plain import and no container access at module scope.

For each tool it checks the actor against the tool's declared ability via authz.can(user, ability, { scope }), with wildcard matching (posts.*posts.edit). Declare abilities on your tools:

@AiTool({
  name: 'issueRefund',
  kind: 'action',
  description: 'Issue a refund.',
  input: z.object({ orderId: z.string() }),
  ability: 'refund.issue',
})

Security posture — fail-closed

No ability, no access — and no ADMIN fallback

Unlike the role-based default, the authz adapter has no ADMIN escape hatch: a tool that declares no ability is never authorized (deny), and any error thrown while resolving the decision denies. With authz selected, an un-annotated tool is simply unreachable.

  • Tenant isolation. actor.tenantRef is passed as the authz scope ({ tenantId }), so a permission granted in one tenant never authorizes a tool call in another. No tenantRef → the check runs against authz's global scope.
  • The double check applies. The offered-tools filter and the invoke-time re-check both run through this same can(actor, tool), so offer and invoke stay consistent.

Delegation needs an ability too

The "no ability, no access" rule has one consequence that surprises people: a delegate tool is a tool. When an orchestrator declares delegatesTo: ['researcher'], the synthesized ask_researcher carries no ability — so under this adapter, every delegation is denied, and the model gets a ToolForbiddenError back on each attempt.

Declare the ability on the edge:

config/agent.ts
export default defineConfig({
  authorizer: authzToolAuthorizer({ authz }),
  agents: [
    { name: 'orchestrator', delegatesTo: [{ agent: 'researcher', ability: 'agent.delegate' }] },
    { name: 'researcher', systemPrompt: 'You research things.' },
  ],
})

Then grant agent.delegate to whoever may orchestrate. Because it is an ordinary ability, wildcards and tenant scoping apply as usual — agent.* covers it, and a grant made in one tenant does not let an actor delegate in another. Give each edge its own ability (agent.delegate.researcher) when different callers should reach different sub-agents. See Multi-agent delegation.

By default the adapter maps an Actor onto { id: actor.id } (enough for authz's defaultResolveUserRef). Pass userFromActor to attach a polymorphic type or a host user entity:

authzToolAuthorizer({
  authz,
  userFromActor: (actor) => ({ id: actor.id, type: 'user' }),
})

Requires @adonis-agora/authz installed and configured (config/authz.ts).

Resolving the actor from authz

The @adonis-agora/agent/authz subpath also exports a matching actor resolver, authzActorResolver (class AuthzActorResolver), so identity and authorization can come from the same stack. Where AuthActorResolver reads ctx.auth.user, this resolver reads the caller from the active Agora context authkit populates (userRef, tenantId) and takes the caller's roles from authz's effectiveRoles (the union of global ∪ app ∪ store roles). It ignores the transport request entirely, so it's framework-agnostic and needs no ctx.auth.

config/agent.ts
import { defineConfig } from '@adonis-agora/agent'
import { authzActorResolver, authzToolAuthorizer } from '@adonis-agora/agent/authz'
import authz from '@adonis-agora/authz/services/main'

export default defineConfig({
  model: () => aiSdkModel(openai('gpt-4o-mini')),
  actorResolver: authzActorResolver({ authz }),   // identity + roles from context/authz
  authorizer: authzToolAuthorizer({ authz }),      // ability checks through Bouncer
})

The resolver hands the context userRef straight to effectiveRoles (scoped to the context tenantId), so global roles like ADMIN land in actor.roles with no extra wiring, and actor.tenantRef is set from the context tenant.

Fail-closed — never fabricates an identity

If there's no userRef.id in the Agora context, authzActorResolver throws (and the provider replies 401). It never invents a caller. The agent route must therefore sit behind authkit auth middleware (which populates the context), with the @adonis-agora/context provider registered — otherwise the context slot is absent and every request fails closed.

On this page