Authorization
Who is calling and what they may do — the ActorResolver identity seam (anonymous per-browser by default, one line to require login), the DefaultToolAuthorizer role intersection, and the offered-and-invoke double check.
Two seams decide access — the actor resolver (who is calling) and the roles policy (what they may do). With neither configured the agent is a free public chat: every browser is its own anonymous actor and every tool without roles is callable. One line each tightens it.
The actor resolver — who is calling
Every request resolves to an Actor ({ id, roles?, tenantRef? }) through the configured ActorResolver — the identity seam.
No resolver: the endpoints are public
When config/agent.ts sets no actorResolver, the provider installs AnonymousActorResolver and says so at boot. Each browser gets its own identity: on its first request the response sets agent_anon=<32 random bytes, base64url>; Path=/; Max-Age=31536000; HttpOnly; SameSite=Lax (plus Secure over HTTPS), and the actor is { id: 'anon:' + SHA-256(token), roles: ['anonymous'] }. Threads, quota and attachments belong to that id, so visitors never see each other's. The cookie is HttpOnly (page scripts cannot read it), server-minted and needs no secret. The identity contract remains compatible with Aviary. Set a quota for a public deployment — model spend is otherwise unbounded, per browser.
To require login, one line: actorResolver: new AuthActorResolver(). A route whose actor can't be resolved then replies 401 and never runs the model.
The resolvers that ship:
-
AnonymousActorResolver— the default described above. Options:cookieName,maxAgeDays,sameSite('none'for a cross-site client, which forcesSecure),secure,path,roles. -
AuthActorResolver(recommended) — reads Adonis's authenticated principal offctx.auth.user(populated by@adonisjs/auth). The agent routes run no auth middleware, so when no guard has tried the request yet it callsctx.auth.check()itself — a session cookie or an access token works with no middleware;guards: ['web', 'api']checks those guards in order (checkUsing) instead of the default one. Fail-closed: throws (401) 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— throws on every request: an explicit "nobody" for a surface that must never serve anonymously.
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:
- Open unless restricted. A tool that declares no
rolestakesdefaultRoles, which is[]— no restriction — unless you set it.actiontools still park on approval regardless (for an anonymous visitor that is a confirmation, since the requester approves).defaultRoles: ['ADMIN']restores the old fail-closed posture. - Set intersection. Otherwise authorization is
actor.roles ∩ (tool.roles ?? defaultRoles) ≠ ∅. Give a toolroles: ['ADMIN']to restrict it.
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
})Closing the empty list
Since 0.46 an empty roles list means open: roles: [], defaultRoles: [] and registerFunctionalTool(registry, tool, []) all let every resolved actor through. Before 0.46 the same list denied everyone, and nothing warns on upgrade. If your app uses an empty list to deny — a roles computed from permissions that can come out empty, a multi-tenant MCP server — keep it closed with emptyRoles: 'deny':
export default defineConfig({
// ...
emptyRoles: 'deny', // an empty roles list reaches nobody
})Closed, the actor needs a role the tool declares: a tool with no roles and no defaultRoles, or with an explicitly empty list, is neither offered nor invocable, and an actor with no roles reaches nothing. defineMcpConfig takes the same key. Building the policy yourself, it is new ClosedRolesPolicy(defaultRoles) / new ClosedToolAuthorizer(defaultRoles), or { emptyRoles: 'deny' } as the second argument of DefaultRolesPolicy / DefaultToolAuthorizer. The default stays 'allow'.
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.
Two per-tool gates sit around the policy on both layers: enabled / isEnabled() runs before it (a disabled tool is absent, and invoking one raises ToolDisabledError), and the tool's own canUse(actor) runs after it, for a per-actor decision the app-wide policy cannot express. Neither can widen what the policy allows. See Turning a tool off, and gating it per user.
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.
Text channels
Put the agent on WhatsApp (Evolution API, Whatsmiau or the Cloud API) and Telegram — one webhook route per channel that verifies, deduplicates, answers durably and in order, converts markdown, splits long replies, attaches media, sends files, asks questions as text, turns proposals into Confirm/Cancel buttons and relays their outcomes — with hooks for the app's own flows.
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.