Skip to content

Route auth in ai-manager

Status: Current | Last reviewed: 2026-10-14

Every handler under apps/ai-manager/app/api/ must establish who the caller is before it does anything else. This page is the lookup for which wrapper to use, how the CI gate decides a route is covered, and what to do when it fails.

There is no requirePermission() function in this codebase. Older notes claimed one; the real convention is the wrappers below.

Pick a wrapper

Wrapper From Use for Rejects
withAuth(permission, handler) lib/with-auth.ts The default. One required permission. 401 with no session/token, 403 without the permission, 403 for a token caller missing the mapped scope
withAnyAuth([permA, permB], handler) same Read/use endpoints reachable from more than one authoring surface, e.g. a model picker used by both the agent and workflow editors as above, but any one permission suffices
withSessionAuth(handler) same Self-service profile routes. Cookie sessions only — API-token callers are excluded on purpose, so a bearer token cannot mint or manage more bearer tokens 401 with no cookie session; token callers always
withManagementAuth(handler) lib/management/auth.ts The external management surface (/api/v1/management, /api/mcp/authoring, /api/a2a/management). Fine-grained management scopes, never the platform-admin bypass per-tool/per-route scope failures

Use Run Agents for browsing or executing existing agents, including workflows. Use Manage Agents for definition changes, publishing and deletion. Neither agent type has a separate permission gate.

Platform operations call requirePlatformAdmin(). Tenant membership operations call requireTenantAdmin(targetTenantId), which also admits the Admin of the selected target tenant. The audit recognizes both guards.

The four permissions

withAuth takes exactly one of these. Everything else in lib/permissions.ts is a legacy name that upgrades into one of them.

Permission Mapped API-token scope
AIManager:ManageAgents agents:manage
AIManager:RunAgents agents:run
AIManager:ManageTools tools:manage
AIManager:ManageConfiguration configuration:manage

The scope column is lib/permission-scope-map.ts. A token caller must hold both the permission (derived from its owner's current tenant membership) and the mapped scope. WithAuthOptions.allowTokenWithoutScope waives only the scope half, and only for routes already narrowed some other way — a signed payload the route verifies itself.

Writing one

export const GET = withAuth('AIManager:RunAgents', async (req, ctx) => {
  return NextResponse.json({ tenantId: ctx.tenantId });
});

export const POST = withAuth(
  'AIManager:ManageAgents',
  async (req, ctx, { params }) => {
    const { agentId } = await params;
    // ...
  },
);

The wrapper must be the direct initializer of the exported const. someWrapper(withAuth(...)) and aliases of a separately wrapped handler are not recognized. When methods share a handler, wrap each exported method directly, as the retired Allianz review route does.

The CI gate

cd apps/ai-manager && npm run audit:routes

scripts/audit/audit-route-auth.ts parses every route.ts with ts-morph and, for each exported GET/POST/PUT/PATCH/DELETE/OPTIONS/HEAD, accepts one of:

  1. the export is directly a call to one of the four wrappers, or
  2. the handler body contains a recognized hand-rolled check — getTenantContext() plus a permission/scope read, or one of the named helpers (authenticateApiToken, requirePlatformAdmin, requireCatalogScope, a service-token verifier, …).

Anything else is reported unless the route is allowlisted. Green output looks like:

audit-route-auth: scanned 410 route files
audit-route-auth: OK — every route handler has auth

It runs as its own job in test.yml, separate from the unit suites, so a failing test can't mask a missing wrapper.

Warning: Tier 2 of that check is a text scan of the handler body, not a proof. A body that merely mentions x-internal-service-token in a string satisfies it. It exists to let pre-withAuth routes pass while they are migrated, not to certify them — when you touch a legacy route, convert it to a wrapper rather than relying on the pattern match.

The allowlist

scripts/audit/route-auth-allowlist.yaml is the audit trail for routes with another authentication mechanism or intentionally public access.

routes:
  - path: workflows/[workflowId]/openapi.json/route.ts   # relative to app/api/
    methods: [OPTIONS]                                    # omit to cover every method
    reason: CORS preflight for per-workflow OpenAPI documents; GET is authenticated and tenant-scoped by withAuth.

reason is required and is the point of the file — it must say what authorizes the caller instead, specifically enough to review. The existing entries fall into five groups: infra probes, self-authenticating routes (HMAC, service token, signed embed stamp, scoped job token), the session login/logout flow, the external /api/v1/** partner API with its own authenticateApiToken path, and thin proxies whose backend re-checks.

Add an entry only when there is genuinely no session to check. "This route is internal" is not a reason — an internal caller still presents something, and the reason should name it.

The Connect Preview routing lookup uses SCV_SERVICE_TOKEN through isPreviewRoutingAuthorized. Its GET exemption also requires a server-owned Redis contact binding and revalidation of the tenant's active Connect integration and routing configuration; the route returns routing metadata without credentials.

See also

Request proxy and runtime parity

The request proxy validates sessions and page access. API permissions are checked by the handler, with no duplicate prefix-level role map: the same /api/workflows prefix contains browse/run and authoring operations. Bearer requests already authenticate in the handler. Keep npm run audit:routes passing for every export. The async-agent and conversation-api token resolvers mirror the role, legacy permission and run/manage scope mappings; scripts/audit/test_runtime_access.py checks both actual token verifiers without a database.