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 offctx.auth.user(populated by@adonisjs/auth). Fail-closed: throws when no user is authenticated. Pass atoActormapper when your user model doesn't exposeid/roles/tenantRefdirectly:new AuthActorResolver({ toActor: (user) => ({ id: String(user.id), roles: user.roleNames, // e.g. pulled from a relation tenantRef: user.tenantId, }), }) -
HeaderActorResolver(demos / gateways) — trustsx-actor-id(required),x-actor-role(comma-separated →roles), andx-tenant-ref. It throws whenx-actor-idis absent and grants no default role (missingx-actor-role→roles: [], 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 RolesPolicy — can(actor, tool): boolean | Promise<boolean>. The default binding is DefaultToolAuthorizer:
- Fail-closed, ADMIN-only by default. A tool that declares no
rolesis invocable only by an actor holding one of the configureddefaultRoles(['ADMIN']unless you override it). - Set intersection. Authorization is
actor.roles ∩ (tool.roles ?? defaultRoles) ≠ ∅. Give a tool an explicitroles: ['MEMBER']to open it to non-admins.
Configure it via defaultRoles, or swap the whole policy via authorizer (aliased rolesPolicy):
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:
- 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. - Invoke-time re-check. When the loop actually runs a tool,
ToolRegistry.invokere-checkspolicy.can(...)(and re-validates the input schema) before calling the handler. Even if a tool leaked into a call, it's refused withToolForbiddenError.
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:
ownerRef | privileged | Verdict | Why |
|---|---|---|---|
=== actorId | any | { ok: true, status: 200 } | The caller owns the record. |
=== null | any | { 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's | false | { ok: false, status: 403 } | Not the owner, not privileged. |
| someone else's | true | { 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:
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:
authorize | Outcome |
|---|---|
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 answers404, 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, orgovernanceAuthorize: () => trueto 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
governanceAuthorizeis what makes a callerprivileged(may act across actors). It mirrors@adonis-agora/agent-dashboard'sauthorizehook, 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
actionkind 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
governanceAuthorizegate (this page). - How much an actor may spend — the daily quota, checked before the model runs. See Quota & cost.
Generative UI
Let a tool stream a typed UI component — a chart, a card, a form — into the reply instead of (or alongside) plain text, via ctx.emitComponent, the text|component stream frame, and the component SSE frame.
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.