Agora
Governance

Authorization

The fail-closed governance model — the DefaultToolAuthorizer (ADMIN-only, role intersection), the offered-and-invoke double check, and the ActorResolver identity seam that never fabricates a caller.

Authorization in @adonis-agora/agent is fail-closed: nothing is reachable unless it's explicitly allowed, and the acting identity is never invented. Two seams enforce it — the actor resolver (who is calling) and the roles policy (what they may do).

The actor resolver — who is calling

Every request resolves to an Actor ({ id, roles?, tenantRef? }) through the configured ActorResolver. This is the identity seam, and it is deliberately hostile to accidents:

No resolver, no service

When config/agent.ts sets no actorResolver, the provider installs UnconfiguredActorResolver, which throws on every request. The agent will not fabricate a caller from a default. A route whose actor can't be resolved replies 401 and short-circuits — it never runs the model.

Three resolvers ship:

  • AuthActorResolver (recommended) — reads Adonis's authenticated principal off ctx.auth.user (populated by @adonisjs/auth). Fail-closed: throws when no user is authenticated. Pass a toActor mapper when your user model doesn't expose id / roles / tenantRef directly:

    new AuthActorResolver({
      toActor: (user) => ({
        id: String(user.id),
        roles: user.roleNames, // e.g. pulled from a relation
        tenantRef: user.tenantId,
      }),
    })
  • HeaderActorResolver (demos / gateways) — trusts x-actor-id (required), x-actor-role (comma-separated → roles), and x-tenant-ref. It throws when x-actor-id is absent and grants no default role (missing x-actor-roleroles: [], i.e. no tools). Only safe behind a gateway that strips and re-sets these headers from a verified principal.

  • UnconfiguredActorResolver — the throwing default described above.

You can also implement ActorResolver yourself — it's a one-method interface, resolve(req): Actor | Promise<Actor>.

The roles policy — what they may do

Tool authorization is a RolesPolicycan(actor, tool): boolean | Promise<boolean>. The default binding is DefaultToolAuthorizer:

  • Fail-closed, ADMIN-only by default. A tool that declares no roles is invocable only by an actor holding one of the configured defaultRoles (['ADMIN'] unless you override it).
  • Set intersection. Authorization is actor.roles ∩ (tool.roles ?? defaultRoles) ≠ ∅. Give a tool an explicit roles: ['MEMBER'] to open it to non-admins.

Configure it via defaultRoles, or swap the whole policy via authorizer (aliased rolesPolicy):

config/agent.ts
export default defineConfig({
  // ...
  defaultRoles: ['STAFF'], // roles a tool requires when it declares none
})

The double check

Authorization runs twice through the same can(actor, tool), which is what makes it defense-in-depth:

  1. Offered-tools filter. Before each model turn, ToolRegistry.definitionsFor(actor, policy, allowedTools) drops every tool the actor's role can't invoke — so a forbidden tool is never even described to the model. The persona/agent allow-list is applied on top as a second filter layer.
  2. Invoke-time re-check. When the loop actually runs a tool, ToolRegistry.invoke re-checks policy.can(...) (and re-validates the input schema) before calling the handler. Even if a tool leaked into a call, it's refused with ToolForbiddenError.

One policy, both layers

Both checks call the same can(actor, tool), so swapping the policy — for the ability-aware authz adapter, or your own — changes offer and invoke together. They can never disagree about what an actor may reach.

Ability-aware authorization

Every tool can also declare an ability (e.g. 'refund.issue'). The default role-based policy ignores it, but an ability-aware RolesPolicy consults it — and one ships: the @adonis-agora/authz Bouncer adapter, authzToolAuthorizer, which checks each tool's ability through authz, tenant-scoped and fail-closed. Because can may return a promise, the async authz check drops straight into the same seam — both filter layers pick it up. Swap it in via authorizer; see Authz (Bouncer) adapter. roles and ability share the SPI, so you can declare abilities now and switch the policy anytime.

Object-level ownership

Role and ability gate which tools an actor may call. They say nothing about which records an actor may address — and a chat run or a thread is a record, keyed by a runId / threadId. Without a second check, any authenticated caller who learns (or guesses) another actor's runId could re-attach to that run's live token stream, cancel it, or deliver a HITL approval on its behalf; a threadId could load a victim's full conversation history back over SSE, or append the caller's own turn into someone else's thread. That is a textbook IDOR (insecure direct object reference), and 0.6.0 closed it.

Every per-actor route now runs an object-level ownership check after resolving the actor and before touching the record. The decision core is the router-free evaluateOwnership, exported so you can unit-test it (or reuse it) without booting an app:

import { evaluateOwnership } from '@adonis-agora/agent'
import type { OwnershipVerdict } from '@adonis-agora/agent'

// evaluateOwnership(actorId, ownerRef, privileged): OwnershipVerdict
evaluateOwnership('u_1', 'u_1', false)  // → { ok: true,  status: 200 }  (the caller owns it)
evaluateOwnership('u_2', 'u_1', false)  // → { ok: false, status: 403 }  (someone else's record)
evaluateOwnership('u_2', null, false)   // → { ok: false, status: 404 }  (unknown / not owned)
evaluateOwnership('u_2', 'u_1', true)   // → { ok: true,  status: 200 }  (governance-privileged)

The three outcomes are deliberate:

ownerRefprivilegedVerdictWhy
=== actorIdany{ ok: true, status: 200 }The caller owns the record.
=== nullany{ ok: false, status: 404 }Unknown record — reply 404, not 403, so the response never confirms to a non-owner that an id they don't own exists.
someone else'sfalse{ ok: false, status: 403 }Not the owner, not privileged.
someone else'strue{ ok: true, status: 200 }A cross-actor privileged caller (see below) may act across actors.

On every owner-scoped route the request is checked before the handler runs: the record's owner is looked up, privileged is computed by running the same governanceAuthorize gate used for the governance read-model, and evaluateOwnership produces the verdict. On a non-ok verdict the request is answered with the verdict's status (404"<kind> not found", 403"forbidden") and the handler never runs.

Which routes are owner-scoped

POST /agent/chat (only when continuing an existing threadId), GET /agent/chat/:runId/stream, POST /agent/chat/:runId/cancel, POST /agent/tool-call/approve, POST /agent/tool-call/reject, GET /agent/threads/:id, DELETE /agent/threads/:id, and POST /agent/threads/:id/fork-from/:messageId all enforce ownership. A new chat (no threadId) has no owner to check; the actor-scoped list routes (GET /agent/threads, GET /agent/quota/today) are already filtered to actor.id.

With no governance gate, ownership is strict

privileged is true only when a governanceAuthorize gate is configured and this actor passes it. With no gate configured, privileged is always false — so ownership is strict and even an admin can only touch its own runs and threads through these routes. That is intentional: cross-actor access is a governance privilege, and the app opts into it by configuring the gate.

Governance route authorization

The cross-actor governance read-model/agent/governance/* — exposes every actor's spend, usage, threads, runs, and pending approvals. It is not per-actor data, so ownership doesn't apply; instead it is gated by a single predicate, governanceAuthorize:

config/agent.ts
import { defineConfig } from '@adonis-agora/agent'

export default defineConfig({
  // ...
  // Runs AFTER the actor is resolved (the caller is already authenticated).
  // Return false → the route replies 403. Typically an ADMIN check.
  governanceAuthorize: (actor, _ctx) => actor.roles?.includes('ADMIN') ?? false,
})

Its type is AgentGovernanceAuthorize = (actor, ctx) => boolean | Promise<boolean>, and it is applied by evaluateGovernanceGate, which is fail-closed: an authorize that throws denies with 403 exactly like a false return.

A thrown message never reaches production clients

When authorize throws, the 403 body carries the error's message only outside production. In production it is always the generic { "error": "forbidden" }, because an authorization predicate routinely throws with details a caller must not see — a missing tenant, a user id, a policy name. So throw freely for your own logs, but don't design a client around reading that message: outside dev it will not be there.

evaluateGovernanceGate is exported for tests and for gating your own routes with the same predicate:

import { evaluateGovernanceGate } from '@adonis-agora/agent'
import type { GovernanceGateVerdict } from '@adonis-agora/agent'

// evaluateGovernanceGate(actor, ctx, authorize?, debug?): Promise<GovernanceGateVerdict>
const verdict = await evaluateGovernanceGate(actor, ctx, config.governanceAuthorize, !app.inProduction)

if (!verdict.ok) {
  return ctx.response.status(verdict.status).json({ error: verdict.error })
}

Three branches, and the first is the one to know about:

authorizeOutcome
undefined{ ok: true, status: 200 }no gate means no denial.
returns falsy{ ok: false, status: 403, error: 'forbidden' }
throws{ ok: false, status: 403, error: debug ? message : 'forbidden' }

That first row looks like a fail-open, and it would be if the routes mounted without a gate. They don't: with no governanceAuthorize the /agent/governance/* routes are never registered, so the gate is only ever consulted on a route that exists because a gate exists. If you call evaluateGovernanceGate yourself on a route of your own, that guarantee is yours to keep — pass a predicate, or check for one before mounting.

  • Omit it and /agent/governance/* is not mounted at all — every one of those paths answers 404, so an ungated cross-actor read-model can never be served by accident. The provider logs a boot warning naming both ways forward: set the gate (typically an ADMIN check) to mount the routes gated, or governanceAuthorize: () => true to deliberately restore the old behaviour of letting ANY authenticated actor read them.
  • The gate does not cover GET /agent/approvals/mine, which is unaffected — it stays mounted whenever the read-model resolves, gate or no gate, and is always scoped to the calling actor's own pending approvals, so a non-admin surface can poll its own approvals even while the cross-actor read-model is ADMIN-only.
  • The same predicate also feeds the object-level ownership check above: passing governanceAuthorize is what makes a caller privileged (may act across actors). It mirrors @adonis-agora/agent-dashboard's authorize hook, so the JSON routes and the console SPA that reads them can be gated with the same predicate — and because the console is a pure consumer of those routes, it refuses to mount without this gate too.

What governance covers

  • Which tools an actor's role may reach (this page).
  • Which tools a persona/agent exposes — a second allow-list layer. See Personas & agents.
  • Which tools need a human before they run — the action kind and HITL. See Streaming & HTTP.
  • Which records (runs, threads) an actor may address — object-level ownership (this page).
  • Who may read the cross-actor governance read-model — the governanceAuthorize gate (this page).
  • How much an actor may spend — the daily quota, checked before the model runs. See Quota & cost.

On this page