Skip to content
Admin surface

Admin surface

Every admin screen — a grants table, a policy browser, an audit view, a bounds form — is a pure function of declarations the engine already holds. So instead of shipping a UI, the library serves the vocabulary as data and a stable HTTP contract over it. Any UI, in any framework, on any runtime, renders from that.

describe() — the descriptor

const descriptor = authz.describe();
// or standalone, without an engine:
import { describeAuthz } from "agentauthz/core";
const descriptor = describeAuthz({ actionRegistry, conditionKeys, scopeKinds });
{
  "version": 1,
  "actions": [
    { "action": "docs.read", "title": "Read documents" },
    { "action": "docs.write", "derivesFrom": "docs.read" },
  ],
  "actionClosures": { // descendantsOf, precomputed — a UI shows what
    "docs.read": ["docs.write"], // a grant REALLY confers without walking the graph
    "docs.write": [],
  },
  "conditionKeys": {
    "app:Region": {
      "type": "string",
      "operators": [
        "StringEquals",
        "StringNotEquals",
        "StringLike",
        "StringIn",
        "StringNotIn",
        "StringLikeIn",
      ],
    },
    "request:SourceIp": {
      "type": "ip",
      "builtIn": true,
      "operators": ["IpAddress", "NotIpAddress"],
    },
  },
  "scopeKinds": ["root", "project"],
  "setOperators": ["StringIn", "StringNotIn", "StringLikeIn"],
}

Design notes worth knowing:

  • operators per key is derived, never authored — from the key’s declared type. A UI that duplicated that table client-side would drift from it.
  • setOperators tells a UI which operators take values rather than value.
  • builtIn: true marks engine-populated request:* keys — a UI must not offer them as authorable bounds, and must not let an author think a host key will populate itself.
  • version lets a UI refuse a document it does not understand rather than render a half-correct form. It bumps only when an old consumer would render a wrong form — never for an additive field.
  • title/description on actions and condition keys are optional human labels, straight from the registry’s annotations.

The handlers

Plain (Request) => Promise<Response> over Web-standard types — the same functions run under Bun, Node, Deno and Workers with no framework adapter:

import { createAdminHandler } from "agentauthz/admin";

const handler = createAdminHandler({
  descriptor: authz.describe(),
  store, // PgAuthzStore satisfies AdminStore as-is
  basePath: "/api/v1/authz",
  actor: (req) => resolveAdmin(req), // recorded as creator + audit subject
  auditSink: store, // admin writes land in the same audit trail
  subjects: { list: (q) => findSubjects(q) }, // optional; absent ⇒ /subjects is 501
});
RoutePurpose
GET /descriptorthe document above
GET /grantslist, filterable by subject, scope, policy
POST /grantscreate — validates bounds through validateConditions first
DELETE /grants/:idrevoke
GET /policiesthe named policies a grant can reference
GET /subjectsvia the host’s subject lister
GET /auditthe decision trail, filterable, keyset-paginated

A rejected write returns the engine’s own validation message verbatim (condition 0: unknown key "app:Nope"), so a UI surfaces exactly what the evaluator would have said — the two cannot disagree about what is legal.

The handlers never decide who may administer. That is the host’s, by design. Mounting them without authentication in front exposes grant creation to anyone who can reach the path.

Mounting under Bun

Bun.serve({
  fetch(req) {
    const url = new URL(req.url);
    if (url.pathname.startsWith("/api/v1/authz")) {
      if (!isAdmin(req)) return new Response("forbidden", { status: 403 });
      return handler(req);
    }
    return app(req);
  },
});

The same handler mounts under Node’s http (via a Request/Response adapter), Deno, or a Cloudflare Worker unchanged. A host on a runtime that cannot import JavaScript at all can still implement the same routes over its own storage and be administered by the same UI — the descriptor document is the contract.