PermDock
Adapters

AuthZEN

permdock/authzen serves the OpenID AuthZEN Authorization API 1.0 (evaluation, evaluations, search, discovery) from a PermDock policy so the decision endpoint is a standard PDP.

permdock/authzen exposes a PermDock policy as an AuthZEN Policy Decision Point. One permdockHandler serves the evaluation, batched evaluations, search and discovery endpoints. The React decision endpoint (permdockHandler in permdock/next, the endpoint option of PermDockProvider) and the pdp provider use the same request and response schemas, so PermDock speaks one wire format whether it is the PDP or the PEP.

Purpose

The OpenID AuthZEN Authorization API 1.0 (final January 2026) standardises how a PEP asks a PDP "may this subject perform this action on this resource in this context". It defines /access/v1/evaluation, batched /access/v1/evaluations (boxcar), /access/v1/search/subject, /search/resource, /search/action, and a .well-known/authzen-configuration metadata document, plus a certification programme with Basic, Batch, Search and Discovery levels. Keycloak and the NLgov profile implement it. PermDock adopts it instead of a bespoke format. In-repo tests cover all four shapes, and testAuthzen from permdock/testing runs the official interop Todo vectors (conformance).

API

import { createPermDock } from "permdock/authzen";

export const { permdockHandler } = createPermDock(policy, {
  subject: fromBearer, // (request) => principal | null; MUST be real authentication
  resources: {
    post: {
      data: (id) => loadPost(id),
      list: ({ where }) => db.posts.where(where),
    },
  },
});

// Fetch-first: mount under /access/v1 and /.well-known
const pdp = permdockHandler();
app.all("/access/v1/*", (c) => pdp(c.req.raw));
app.get("/.well-known/authzen-configuration", (c) => pdp(c.req.raw));
EndpointPermDock callCertification level
POST /access/v1/evaluationdecide(permission, resource)Basic
POST /access/v1/evaluationsevaluateBatch(items, semantic, …), one decide per itemBatch
POST /access/v1/search/actioncatalog of permitted actions on a resource for the subject ("what can I do")Search
POST /access/v1/search/resourcefilter / where over a resource type, materialised via resources.<type>.list; results are { type, id } entitiesSearch
POST /access/v1/search/subjectsubjects permitted for an action on a resource (requires subjects, an AuthzenSubjectLister)Search
GET /.well-known/authzen-configurationPDP metadata: endpoint URLs, supported featuresDiscovery
  • permdockHandler() returns a Fetch handler (Request to Response), so it mounts on Hono, Next.js route handlers, Node and any server kernel adapter.
  • The same handler is what PermDock Cloud runs as a hosted Authorization Decision Service: publish the policy, and Kong, Envoy, Tyk, Zuplo or a service in another language calls the hosted /access/v1/evaluation with a Vercel OIDC or client-credentials token and gets the same context (outcome, denial reasons, approval token) that the embedded handler produces. Running the handler yourself and using the Cloud are interchangeable; the decision semantics are one code path (Cloud adapter, PermDock Cloud).
  • resources tells the handler how to load an instance by id (data(id), for where conditions on instance actions) and how to enumerate for resource search (list({ where })).
  • subjects is an AuthzenSubjectLister, { name?, list(query) }. list receives { type, action, resource, context? } and answers candidate records { id, ...properties }; the handler decides each one and keeps the granted ones, so a lister may answer a superset.
  • subject authenticates the calling PEP or end user; see "decision endpoint auth" below.
  • trustedPep(pep) is the allow-list of authenticated PEPs that may evaluate on behalf of another subject. It is off by default; a missing predicate, false, a non-function value or a throw means the request-body subject, actor and delegation are ignored.

Batch helpers

A PDP that serves /access/v1/evaluations from its own evaluator, such as PermDock Cloud, reuses the batch rules permdockHandler follows:

import {
  evaluateBatch,
  evaluationsSemantic,
  mergeEvaluationItem,
  type AuthzenItem,
} from "permdock/authzen";

const semantic = evaluationsSemantic(body); // undefined: answer 400
const shared: AuthzenItem = body; // top-level subject, action, resource, context
const rows = await evaluateBatch(body.evaluations, semantic, (item) =>
  evaluate(mergeEvaluationItem(shared, item)),
);
  • EVALUATIONS_SEMANTICS lists execute_all, deny_on_first_deny and permit_on_first_permit, typed EvaluationsSemantic.
  • evaluationsSemantic(body) reads options.evaluations_semantic: execute_all when absent, undefined for any other value.
  • evaluateBatch(items, semantic, rowOf) runs every item at once for execute_all, and otherwise in order, stopping after the first row whose decision is false (deny_on_first_deny) or true (permit_on_first_permit).
  • mergeEvaluationItem(shared, item) fills an item's missing subject, action, resource and context from the request's top-level fields; a non-object item is the shared fields alone.
  • AuthzenItem and AuthzenEntity type the unvalidated request; every field is unknown until the evaluator checks it.

A hosted PDP answers with the same 400s, pages and search results as permdockHandler by calling the functions the handler calls:

import {
  authzenPage,
  parseAuthzenRequest,
  readAuthzenRequest,
  searchActions,
  searchSubjects,
} from "permdock/authzen";

const parsed = await readAuthzenRequest("search/action", request);
if (!parsed.ok) {
  return Response.json(parsed.problem, {
    status: parsed.problem.status,
    headers: { "content-type": "application/problem+json" },
  });
}
const { item, page } = parsed.request;
const actions = await searchActions(
  policy,
  user,
  { type: "post", id: "p1" },
  { data: row },
);
return Response.json(authzenPage(actions, page));
  • readAuthzenRequest(kind, request, { maxEvaluations? }) checks Content-Type: application/json, the 1 MiB body cap (413) and the JSON, then calls parseAuthzenRequest.
  • parseAuthzenRequest(kind, body, { maxEvaluations? }) takes evaluation, evaluations, search/subject, search/resource or search/action and answers { ok: true, request } or { ok: false, problem }, the Problem Details the handler sends. The checks and their details are listed under "What it validates" below, in order. An evaluations body with no items parses as kind: "evaluation"; with items, each item already carries the shared top-level fields, and an over-size batch is a 413 (maxEvaluations defaults to 256).
  • readAuthzenPage(body) reads page.token, else page.next_token, as a decimal offset (anything else is 0) and page.limit, else page.size, capped at AUTHZEN_MAX_PAGE (200) and 50 by default. authzenPage(items, page) answers { results, page: { next_token, count, total } }, with an empty next_token on the last page.
  • searchActions(policy, user, resource, options?) decides every permission of resource.type in the policy for user and answers the distinct granted actions, { name }[], in catalog order. options.data is the row the server loaded, trusted; without it resource.properties (or { id }) is boundary-validated. tenant, actor, delegation and the InstanceOptions set the instance.
  • searchSubjects(policy, lister, request, options?) asks an AuthzenSubjectLister for candidates, decides each as the user it names and answers the granted ones as { type: "user", id }[]. An unknown action, a subject type other than user or a lister that throws answers none. A hosted PDP lists directory principals this way, as resources.<type>.list lists rows.

Request lifecycle

  1. The handler authenticates the request via subject. Unauthenticated requests get 401; there is no anonymous evaluation unless the policy declares anonymous grants and the deployment opts in.
  2. The AuthZEN request is validated: subject, action, resource, optional context, each with type, id and properties.
  3. Mapping to PermDock:
AuthZEN fieldPermDock
subject.type, subject.id, subject.propertiesprincipal, only for a PEP that trustedPep accepts; properties.actor and properties.delegation (a scope string and authorization_details, or the same under context.delegation) fill the agent half of the subject under the same rule. Otherwise the authenticated caller from subject is the subject and the body's subject is ignored
action.namejoined with resource.type to look up findPermission(permissions, 'post.update'); action.properties.scope accepted as an alternative
resource.type, resource.id, resource.propertiesthe resource instance: properties used directly when present, otherwise loaded via resources.<type>.data(id); id: "*" names the collection, a check with no row, as for create
contextcontext.<key> values available to conditions
  1. The decision runs: one decide per evaluation, and for evaluations one per item through evaluateBatch under the request's evaluations_semantic.
  2. The response is built: decision: true|false plus a context object whose permdock member carries outcome, denials (role and reason only) and token for approval-required. A denied or approval-required result adds reason_admin and reason_user (AuthZEN 1.0 section 7.2.1, in en); reason_user names only the permission. Matched grants, conditions and alternatives are never included.
  3. on('decision') fires once per evaluation with the AuthZEN request id for correlation.

What it validates

  • Request bodies, in this order, each failure a 400 with Problem Details (parseAuthzenRequest answers the same):

    1. Content-Type: application/json (section 10.1): AuthZEN requests must use Content-Type: application/json. A body over 1 MiB is a 413.
    2. Valid JSON: AuthZEN body was not valid JSON.
    3. An object: <kind> body must be an object, such as evaluation body must be an object.
    4. The required entities and fields (section 10.1.1), checked subject, then resource, then action: subject.type is required, subject.id is required, resource.type is required, resource.id is required, action.name is required. An id must be a string.
    5. For evaluations: evaluations must be an array, then the batch size (413), then options.evaluations_semantic must be one of execute_all, deny_on_first_deny, permit_on_first_permit.
    Routesubjectresourceaction.name
    evaluationtype, idtype, idrequired
    search/actiontype, idtype, idnot read
    search/resourcetype, idtyperequired
    search/subjecttypetype, idrequired

    An evaluations body with no items needs the evaluation fields at the top level; batch items are not checked one by one, and an item with no action fails closed as unknown-permission. The body subject is still only trusted from a trusted PEP; it is required because the specification requires it.

  • resource.properties against the resource's Standard Schema when they are used as the instance (boundary validation): a PEP is a trust boundary. When the handler loads the row itself, no validation runs.

  • Unknown resource.type or action.name: decision: false with a context.permdock.reason of unknown-permission; never an exception.

  • Decision-endpoint authentication must be real authentication (bearer tokens, mTLS, session), not a shared static secret; Kilpi's public-secret obfuscation is an explicit anti-pattern (threat model). In-app, subject reads the application's session or a subjectFromJwt result; on the hosted ADS, callers present a Vercel OIDC token or an OAuth client-credentials token verified with permdock/jwt.

How denials surface

  • evaluation: { "decision": false, "context": { "permdock": { "outcome": "denied", "denials": [{ "role": "member", "reason": "condition" }] }, "reason_admin": { "en": "post.update denied: member (condition)." }, "reason_user": { "en": "post.update is not permitted." } } }. The context member is optional in AuthZEN and PermDock always fills it so a PEP can explain the refusal. A PEP that wants what else the subject may do asks search/action.
  • approval-required: decision: false with context.permdock.outcome: 'approval-required' and context.permdock.token; the PEP decides how to obtain approval. AuthZEN has no third outcome, so this is a false with a reason in context, not a profile-specific extension.
  • evaluations: one result per item, in order; a batch never fails partially because one item is denied. options.evaluations_semantic is execute_all (the default), deny_on_first_deny or permit_on_first_permit; the two short-circuit semantics evaluate in order and stop after the first matching result, and any other value is a 400. A request without an evaluations array, or with an empty one, is a single evaluation and answers { decision, context }.
  • Search endpoints return the permitted subset; an empty page is the denial. Resource search filters the rows resources.<type>.list yields in memory and returns each as a { type, id } entity, reading id from the resource's id field and dropping rows without one. Subject search answers only for subject.type user (or no type). Pagination follows the AuthZEN page object: the request's page.limit (default 50, at most 200) and page.token, the response's offset next_token (empty on the last page), count and total.
  • Discovery at /.well-known/authzen-configuration/<path> names <origin>/<path> as policy_decision_point and lists every endpoint beneath it, so a path-qualified PDP identifier resolves to its own metadata.
  • Every response echoes the request's X-Request-ID header, on permdock/authzen and on permdockHandler alike.
  • Transport errors use RFC 9457 Problem Details (400, 401, 413 for a batch over the limit or a body over 1 MiB, counted in bytes whatever Content-Length says).

Why

An AuthZEN PEP is another party: a gateway, a service in another language, sometimes another team's system. It needs enough to enforce and explain a refusal, and no more. The context therefore carries the outcome, the denial reasons and the approval token, and never the matched grant, its conditions or alternatives. Conditions reveal policy structure (which column gates a row), and alternatives reveal what else the subject could do; a PEP that needs the second asks search/action, which is an explicit, auditable question rather than a side effect of every denial. The application's own decision endpoint (permdockHandler) keeps returning the full Decision because its caller is the application's UI, inside the same trust boundary. Both nest PermDock's data under context.permdock: AuthZEN leaves context open to every PDP, so one namespaced member cannot collide with another vendor's keys, and a PEP that talks to several PDPs reads PermDock's fields from the same place in both responses.

Example app

apps/examples/authzen-pdp: node:http wrapper around the Fetch handler permdockHandler() returns, on 127.0.0.1:3470. GET /health. POST /access/v1/evaluation with Authorization: Bearer test grants post.update on the member's own post and denies post.publish. src/pep.ts is the PEP half: remotePdp against that PDP, authenticated as the one caller trustedPep lets name the subject. src/pep-cloud.ts points the same PEP at the hosted ADS with OAuth client credentials. tests/parity.test.ts runs the policy matrix locally, then every cell through the PEP; with the PERMDOCK_CLOUD_* variables set it also runs every cell through the hosted ADS and testCloudApi against the environment.

  • AuthZEN: request and response schemas, search semantics, discovery, certification levels.
  • Wire formats: the evaluation context shape.
  • Problem Details: transport errors.

Last updated on

On this page