PermDock
Standards

OpenID AuthZEN

How PermDock speaks the OpenID AuthZEN Authorization API 1.0 as a policy decision point (permdock/authzen) and as a policy enforcement point (the pdp provider).

Status: planned Phase: 2 Adapter phases: permdock/authzen 2, permdock/pdp 4.

Phase 1 ships the AuthZEN-shaped decision endpoint and batched client used by the React adapter. Phase 2 ships the full permdock/authzen endpoint set and a certification run; Phase 4 ships the pdp provider.

What it is

The OpenID AuthZEN Authorization API 1.0 went final in January 2026. It standardises the request and response between a policy enforcement point (PEP, the thing asking) and a policy decision point (PDP, the thing answering):

  • POST /access/v1/evaluation: one decision for a subject, action, resource and optional context.
  • POST /access/v1/evaluations: a batched, "boxcar" request evaluating many subject/action/resource tuples in one round trip.
  • POST /access/v1/search/subject, /search/resource, /search/action: given two of the three, list the third (who can do this, which resources can this subject act on, what can this subject do on this resource).
  • A .well-known PDP metadata document for discovery.
  • A certification programme with Basic, Batch, Search and Discovery levels.

Keycloak and the NLgov profile implement it, and PDP vendors including Cerbos, Topaz, Axiomatics and PlainID participate in the interop work.

Why it matters for PermDock

PermDock needs a decision endpoint anyway: the React client asks the server for grants backed by closures, the pdp provider defers to a remote PDP, and simulate() batch-evaluates an agent's plan. Inventing a wire format for that (as Kilpi's endpoint plugin did) means every other tool has to learn it. Speaking AuthZEN means:

  • Cerbos, Topaz, Keycloak or any certified PDP can call PermDock, and PermDock can call them, without translation.
  • The catalog question "what can this user do on this resource" is AuthZEN action search; filter and where are resource search; simulate is a boxcar evaluations request.
  • Certification is a concrete, external proof of correctness that no TypeScript permissions library has today.

See ADR 0011 and wire formats.

How PermDock uses it

PermDock as a PDP

import { createPermDock } from 'permdock/authzen'
export const { handler } = createPermDock(policy, { subject: fromBearer })
// Serves /access/v1/evaluation, /evaluations, /search/action, /search/resource, /search/subject,
// and .well-known/authzen-configuration.

The Next.js permdockHandler() and the React endpoint use the same request and response schemas, so the client decision endpoint is an AuthZEN PDP with a restricted policy: it only answers for the authenticated caller's own subject. The endpoint must sit behind the application's real authentication; see the threat model on why a shared public secret is not enough.

PermDock as a hosted ADS

PermDock Cloud runs the same permdock/authzen handler against a published policy as an Authorization Decision Service (ADR 0021). Because AuthZEN is the wire format, an API gateway with an AuthZEN policy enforcement point (Kong, Envoy, Tyk, Zuplo, WSO2) or a service in Go, Python or Java enforces a TypeScript-authored policy with no PermDock SDK. Callers authenticate with a Vercel OIDC token or OAuth client credentials; the response is the standard decision plus the PermDock Decision under context.permdock, exactly as from the embedded handler. Running the handler yourself remains the default (Cloud adapter).

PermDock as a PEP

The pdp provider turns a remote AuthZEN PDP into a grant source: a role's grants can be resolved by calling /access/v1/evaluation (or /evaluations for simulate), and the response is folded into the same Decision union as local grants. deny still overrides allow, and a network failure is a denial, never a grant.

Request and response

An evaluation request for permdock.decide(permissions.post.update, post):

{
  "subject": {
    "type": "user", "id": "u_123",
    "properties": {
      "roles": [],
      "memberships": [{ "tenant": "org_9", "roles": ["member"] }, { "tenant": "org_9", "team": "t_design", "roles": ["lead"], "via": "group:9f2c" }],
      "actor": { "type": "mcp-client", "id": "https://agent.example/cimd.json" }
    }
  },
  "action": { "name": "post.update" },
  "resource": { "type": "post", "id": "p_42", "properties": { "authorId": "u_123", "orgId": "org_9", "published": false } },
  "context": { "tenant": "org_9", "delegation": { "scopes": ["post:update"] } }
}

subject.properties.memberships carries the tenancy memberships (AuthZEN 1.0 names group memberships as an example subject property) and context.tenant the active tenant, because the tenant is a property of the request, not of the subject. A single-tenant policy that still uses orgId on the principal sends it as a property as before.

The response carries the boolean AuthZEN decision plus PermDock's Decision in context so PEPs that understand it get reasons, alternatives and the approval token:

{
  "decision": true,
  "context": {
    "outcome": "granted",
    "matched": { "role": "member", "permission": "post.update" },
    "token": "sha256:..."
  }
}

A denial is "decision": false with context.outcome equal to denied and context.denials listing role and reason; an approval-required outcome is also "decision": false so AuthZEN-only PEPs fail closed, with context.outcome equal to approval-required and the token for the approval flow.

The boxcar form wraps many { action, resource } pairs under one subject and is what simulate() sends; resource search maps to filter / where; action search maps to iterating listPermissions(permissions) for one resource and returning the granted keys.

Mapping table

AuthZEN 1.0 conceptPermDock concept
subject.type, subject.idprincipal (subject.id in policy conditions)
subject.propertiesPrincipal fields returned by definePolicy's subject function, plus actor
subject.properties.membershipsprincipal.memberships (tenant, team and resource roles, tenancy)
context.tenantprincipal.tenant, the active tenant for this request; absent means no tenant, never a default
.well-known/authzen-configuration/<tenant>Per-tenant PDP metadata served by handler and the hosted ADS when the deployment is multi-tenant
action.namepermission.key (post.update); resolved with findPermission
resource.typeResource node name (post)
resource.idValue of the resource's id field
resource.propertiesThe instance passed to decide; validated at the boundary
contextdelegation (scopes, authorization_details) and request context
decision: trueoutcome: 'granted'
decision: falseoutcome: 'denied' or 'approval-required' (distinguished in context.outcome)
Response contextFull Decision: matched, denials, alternatives, token
/access/v1/evaluations (boxcar)permdock.simulate([...]) and the batched React client
/search/actionGranted keys from listPermissions for one resource (the catalog question)
/search/resourcefilter for arrays, where for query compilers
/search/subjectIterating known subjects; Phase 2, see open questions
/search/resource as a claim source (AuthZEN claims draft)permdock.roles({ tenant }) and memberships() for the authenticated subject (JWT authorization claims)
.well-known metadata.well-known/authzen-configuration served by handler
Certification levels Basic / Batch / Search / DiscoveryPhase 2 target for permdock/authzen

Sources

Open questions

  • How /search/subject is answered when PermDock has no subject store of its own; likely an adapter-supplied enumerator, or an explicit "not supported" in the metadata document until the Search certification level is attempted.
  • Whether the client decision endpoint should accept subject from the request body at all, or ignore it and always use the authenticated session (the safer default; see threat model).
  • Which AuthZEN context keys to standardise for actor, delegation and tenant so other PDPs can read them; today they are PermDock-specific.
  • Whether approval-required should be a distinct AuthZEN-level signal in a future profile rather than a false with extra context.

On this page