Aviary
Guides

Identity & Authorization

How the agent learns who is calling (ActorResolver, with no insecure default) and decides — per tool, server-side — whether they may run it (roles vs abilities).

Two questions gate every turn: who is calling, and may they run this tool. The agent answers the first with an ActorResolver you configure — it never fabricates a caller — and the second with a per-tool policy that is always decided server-side. Identity is a seam you own; authorization is a set-intersection (or a delegated ability check) the loop runs before any handler executes.


Identity: the actor comes from you

Every request resolves to an actor — the principal driving the turn:

interface Actor {
  id: string;
  roles?: string[];   // authorization intersects these against a tool's `roles`
  tenantRef?: string; // your tenant handle, threaded through tools and governed SQL
}

The agent obtains it through the ActorResolver SPI. This is the identity seam: the agent never invents a caller.

interface ActorResolver {
  resolve(req: unknown): Actor | Promise<Actor>;
}

req is the raw transport request (an Express Request in a NestJS app), typed unknown so the core stays framework-agnostic. You configure exactly one resolver on AgentModule.forRoot({ actorResolver })actorResolver is a required field, not something you can leave out.

There is no insecure default identity

actorResolver is required at compile time — there's no throwing placeholder class installed when it's missing, because it can't be missing; an identity is never derived from a default. This is deliberate: an agent that can act without a known caller is an agent that can act as anyone.


HeaderActorResolver — only behind a trusted gateway

The shipped resolver reads the actor straight off request headers:

HeaderMaps toNotes
x-actor-idactor.idRequired — absent (or empty) → throws, never fabricated
x-actor-roleactor.rolesComma-separated, trimmed; absent → roles: [] (no tools)
x-tenant-refactor.tenantRefOptional; only attached when present
import { AgentModule, HeaderActorResolver } from '@dudousxd/nestjs-agent';

AgentModule.forRoot({
  model: myModelProvider,
  store: myAgentStore,
  defaultRoles: ['ADMIN'],
  actorResolver: new HeaderActorResolver(),
});
curl -N http://localhost:3000/agent/chat \
  -H 'content-type: application/json' \
  -H 'x-actor-id: u1' -H 'x-actor-role: ADMIN,OPS' -H 'x-tenant-ref: acme' \
  -d '{ "message": "purge the cache for key foo" }'

Trusting client headers is only safe behind a gateway

HeaderActorResolver trusts whatever headers arrive. That is safe only when a gateway in front of your app strips these headers off the inbound request and re-sets them from an authenticated principal. Expose it to the open internet and any client can claim any x-actor-id and any role. Use it for demos, internal tools behind a mesh, or a gateway edge — never as your production identity source.


A production resolver over a verified session

Real deployments implement ActorResolver over a principal your app has already verified — a session, a JWT, @dudousxd/nestjs-context. Because a resolver is an ordinary class, register it as a provider and inject whatever you need:

// src/agent/session-actor.resolver.ts
import type { Actor, ActorResolver } from '@dudousxd/nestjs-agent-core';
import { Injectable, UnauthorizedException } from '@nestjs/common';
import type { Request } from 'express';

@Injectable()
export class SessionActorResolver implements ActorResolver {
  resolve(req: unknown): Actor {
    // `req` is the Express request; your auth guard has already populated `req.user`.
    const user = (req as Request & { user?: { sub: string; roles: string[]; org: string } }).user;
    if (!user) {
      // Never fabricate a caller — refuse the turn instead.
      throw new UnauthorizedException('No authenticated principal on the request.');
    }
    return { id: user.sub, roles: user.roles, tenantRef: user.org };
  }
}

Wire it through the async factory so Nest can inject it:

// src/app.module.ts
import { AgentModule } from '@dudousxd/nestjs-agent';
import { SessionActorResolver } from './agent/session-actor.resolver.js';

@Module({
  imports: [
    AgentModule.forRootAsync({
      inject: [SessionActorResolver],
      useFactory: (actorResolver: SessionActorResolver) => ({
        model: myModelProvider,
        store: myAgentStore,
        defaultRoles: ['ADMIN'],
        actorResolver,
      }),
    }),
  ],
  providers: [SessionActorResolver],
})
export class AppModule {}

The resolver's only job is to turn a verified principal into { id, roles?, tenantRef? }. Do the authentication (verify the JWT, load the session) in your guard/middleware as usual; the resolver just reads the result. If it can't produce a trustworthy actor, it should throw — that fails the turn closed.


Authorization: one gate per tool

Once the actor is known, the loop checks it against each tool before running the handler. A tool declares one of two gates:

GateDeclared asDecided byFalls back to
roles@AiTool({ roles: ['ADMIN'] })Built-in set-intersectionModule defaultRoles when roles omitted
ability@AiTool({ ability: 'cache.purge' })@dudousxd/nestjs-agent-authz GateThe role policy when no ability present

Both live on the same ToolSpec, so neither is required and apps that don't use authz simply rely on roles.

The built-in role policy

The default RolesPolicy is a plain set-intersection: the actor passes if any of its roles is in the tool's allowed set. Omit roles on the tool and it falls back to the module's defaultRoles.

// effectively, inside DefaultRolesPolicy:
can(actor, tool) {
  const allowed = tool.roles ?? defaultRoles; // defaultRoles defaults to ['ADMIN']
  return (actor.roles ?? []).some((role) => allowed.includes(role));
}

Tools reach the policy with roles: undefined, not a baked-in default

Discovery doesn't bake defaultRoles into a tool's spec.roles — a tool that declares no roles reaches whichever RolesPolicy is active with roles: undefined, and DefaultRolesPolicy is what applies defaultRoles at that point. This matters if you write a custom RolesPolicy (or use AuthzRolesPolicy's fallthrough): you'll see undefined, not a pre-filled ['ADMIN'].

import { AiTool, type ToolHandler } from '@dudousxd/nestjs-agent';
import { z } from 'zod';

@AiTool({
  name: 'listOrders',
  kind: 'read',
  description: 'List recent orders.',
  input: z.object({ limit: z.number().max(100) }),
  roles: ['ADMIN', 'SUPPORT'], // ADMIN or SUPPORT may call it
})
export class ListOrdersTool implements ToolHandler<{ limit: number }> {
  async execute(input: { limit: number }) {
    /* ... */
  }
}

An actor with roles: [] (e.g. a HeaderActorResolver request that sent no x-actor-role) intersects with nothing and can call no tools — the fail-closed default.


Ability-gated tools (-authz)

For anything richer than role names — policies, ownership, per-resource rules — delegate to @dudousxd/nestjs-authz. Add AgentAuthzModule, and a tool's ability is checked via gate.forUser(actor).allows(ability):

import { AuthzModule } from '@dudousxd/nestjs-authz';
import { AgentModule } from '@dudousxd/nestjs-agent';
import { AgentAuthzModule } from '@dudousxd/nestjs-agent-authz';

@Module({
  imports: [
    AuthzModule.forRoot(/* your abilities & policies */),
    AgentModule.forRoot({ /* model, store, actorResolver, ... */ }),
    AgentAuthzModule.forRoot(),
  ],
})
export class AppModule {}
@AiTool({
  name: 'purgeCache',
  kind: 'action', // an action tool: never auto-executes, waits for HITL approval
  description: 'Purge a cache key.',
  input: z.object({ key: z.string() }),
  ability: 'cache.purge', // delegated to the authz Gate
})
export class PurgeCacheTool implements ToolHandler<{ key: string }> {
  async execute(input: { key: string }) {
    /* ... */
  }
}

Under the hood AgentAuthzModule binds an AuthzRolesPolicy as the agent's RolesPolicy:

  • Tool declares an ability → decision goes to the Gate: gate.forUser({ id, roles }).allows(ability).
  • Tool has no ability → falls through to the built-in role policy (its fallbackRoles default to ADMIN-only, overridable via AgentAuthzModule.forRoot({ fallbackRoles })).

The actor is all authz needs

The Gate is handed { id, roles } derived from the actor — exactly what authz's default role resolver reads. No host user entity is required, so a role-based ability resolves from the same roles the built-in policy uses.

AgentAuthzModule does not import AuthzModule

It injects the Gate from your app's global AuthzModule. Register AuthzModule.forRoot(...) once at the root; otherwise the Gate provider is missing and DI fails at boot.


The governance endpoints are ownership-scoped

POST /agent/tool-call/approve · /reject, the /agent/threads/:id family (GET, DELETE, POST .../fork-from/:messageId), and POST /agent/chat/:runId/cancel resolve the acting actor via the same ActorResolver and assert they own the target thread, tool call, or run before acting on it: someone else's returns 403, a missing one returns 404. There's no path to approve, reject, read, fork, delete, or cancel another actor's state by guessing an id.


Why this is safe by construction

  • Identity is never invented. actorResolver is required — there's no default to omit it in favor of. The header resolver throws on a missing x-actor-id. A custom resolver should throw when it can't verify a principal. There is no code path where the agent runs as an unknown or default caller.
  • Authorization is server-side and per-tool. The model can ask to call any tool it can see, but the loop runs the policy check against the resolved actor before the handler executes. A prompt can't talk its way past a role intersection or a Gate denial.
  • Fail-closed defaults. Empty roles intersect with nothing; an unset tool roles falls back to defaultRoles (ADMIN-only out of the box), and an ability with no matching policy is denied.

The same resolver backs the approvals console

Chat isn't the only surface that needs to know who's acting. @dudousxd/nestjs-agent-dashboard's approvals inbox (see Human-in-the-loop & Durability) stamps who decided an approval via an approvalActorRef dashboard option that defaults to the module-configured ActorResolver — the same one resolving chat's actor. A console whose auth matches chat's needs zero extra identity wiring; supply approvalActorRef only when the console's request shape differs from chat's (a different guard, a different header set).


On this page