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: {
      load: (id) => loadPost(id),
      list: ({ where }) => db.posts.where(where),
    },
  },
});

// Fetch-first: mount under /access/v1 and /.well-known
app.all("/access/v1/*", (c) => permdockHandler(c.req.raw));
app.get("/.well-known/authzen-configuration", (c) =>
  permdockHandler(c.req.raw),
);
EndpointPermDock callCertification level
POST /access/v1/evaluationdecide(permission, resource)Basic
POST /access/v1/evaluationssimulate([[permission, resource], ...])Batch
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 a subject enumerator)Search
GET /.well-known/authzen-configurationPDP metadata: endpoint URLs, supported featuresDiscovery
  • permdockHandler is 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 (for where conditions on instance actions) and how to enumerate for resource search.
  • 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.

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 (scopes / authorization_details) 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 complete, otherwise loaded via resources.<type>.load
contextcontext.<key> values available to conditions
  1. The decision runs; evaluations uses simulate so a plan is evaluated as one boxcar with shared subject resolution.
  2. The response is built: decision: true|false plus a context object carrying outcome, denials (role and reason only) and token for approval-required. 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 against the AuthZEN schemas; malformed requests get 400 with Problem Details.
  • 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" }] } } }. 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.
  • Transport errors use RFC 9457 Problem Details (400, 401, 413 for oversized batches).

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 permdockHandler 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.

  • 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