Federated Enforcement · finding the PDP
diagrams

Finding the PDP

How an enforcement point works out which PDP decides a request, how several PDPs and their information points combine behind one API, and how the answer moves from operator configuration to the API's own document to what a federation vouches for.

The resolver

The resolver answers one question for each layer of a route's policy: given the protected resource this request is for, where is the AuthZEN evaluation endpoint? It answers in two hops. First it finds the PDP identifier, from operator configuration, from the resource's RFC 9728 document, or from the federation's resolved metadata for the resource. Then it reads that PDP's /.well-known/authzen-configuration for its endpoints, and uses the spec's default paths when the PDP publishes none.

Both hops are cached per identifier. On top of them, a route names an ordered list of layers:

  • resource means whatever discovery finds for the route's resource. It is the default.
  • static is the configured AUTHZEN_URL, whatever discovery finds. It is the slot for an estate-wide PDP.
  • A PDP identifier is an explicit PDP, read like any other and checked against PDP_ALLOWLIST.

The resource's document can name more than one PDP. Its authzen_policy_set lists the PDPs to ask and how their answers combine. That set is what the resource layer means, and layers configured on the PEP go in front of it. A document without a set publishes nothing about who decides, and the static PDP answers for it.

One request, end to end

The sequence below follows a tool call to the demo's bank-b API through coaz-pep, with nothing set on the route. Bank B's document names its own PDP and puts the estate PDP in front of it. The estate PDP permits. Bank B's PDP denies with step-up advice, and the agent gets a challenge it can act on.

resolve · answers cached per identifier evaluate · every member must permit Agent MCP client coaz-pep front door Layer resolver route + policy set PDP discovery two TTL caches Resource doc RFC 9728 or chain PDP well-known AuthZEN 1.0 §9 estate PDP set member 1 bank-b PDP set member 2 tools/call + tokens layers: [resource] resolve R resource allowlist GET (cache miss) policy set + doc GET pdp-b authzen-configuration endpoints · 404 → default paths bank-b + its doc resolve estate PDP PDP allowlist · cached estate endpoints combine · dedupe · fail_open estate, then bank-b POST access/v1/evaluation · context: resource_metadata, request permit POST access/v1/evaluation · same request, same context deny · step_up_required, scope payments:write 401 + challenge
FIG 1Resolve first, then evaluate. The PEP knows every PDP it will ask before it asks any of them. The estate PDP is asked only because Bank B's document put it in front. The challenge goes out as WWW-Authenticate: … insufficient_scope on REST, and as data.authz_challenge.type = resource_authorisation in a JSON-RPC error on MCP.

What the sequence shows

  • A PDP that can't be resolved stops the request before any PDP is asked, unless it is allowed to fail open. The PEP never runs part of a policy without saying so.
  • The resource allowlist is checked before any cache or fetch. A refused resource costs nothing and can't pick up another caller's cached answer.
  • Every PDP the document names passes the same PDP_ALLOWLIST as one named in configuration, however deeply it is nested in the set.
  • Every member gets the same request and context. In a deny-overrides set the first deny is the answer, advice included, and later members aren't asked. Obligations from the members that permitted carry forward.
  • On a warm cache, steps 5 to 8 and 11 make no network calls. Both caches serve the last good answer for a bounded time if a fetch fails.

Several PDPs, and the PIPs behind them

The PEP's job ends at the PDP call. Policy information points (PIPs) belong to the PDPs: a PDP that wants risk scores, entitlements or consent fetches them itself. The PEP adds one thing every PDP can use. It forwards the resource's metadata document verbatim in context.resource_metadata, and resource_metadata_source says whether that document was self-asserted (rfc9728) or vouched for (federation).

AuthZEN PDPs behind one engine

PEPS SHARED ENGINE PDPS · AUTHZEN 1.0 PIPS Kong · authzen-pdp Lua, coaz_url set PingAccess rule Java Add-on SDK Envoy / agentgateway ext_authz, config only Node SDK Express or McpGuard HTTP :9192 HTTP :9192 gRPC :9191 optional coaz-pep token + DPoP verification MCP tool-call mapping PDP discovery layers + policy set combine · challenges estate PDP token + client, any resource Bank A PDP pdp-a Bank B PDP pdp-b rogue PDP permits everything member 1 member 2 member 2 off allowlist risk signals client watch list accounts consent records accounts consent records reads reads reads GET · cached per identifier RFC 9728 document bank-b: estate, then pdp-b Trust Chain member: pinned estate, pdp-a authzen-configuration one per PDP metadata that discovery reads
FIG 2One engine, several PDPs, and the PIPs stay behind their PDPs. The estate PDP judges the token and the client, and knows nothing about any one API. The bank that owns the API decides second. Bank A sits behind a federation member, and Bank B names itself in its own document. The rogue PDP is refused because it isn't on PDP_ALLOWLIST, whatever a document says. The bank PIPs are examples. Without coaz-pep, Kong and PingAccess decide REST routes natively with the same discovery. Every MCP request still goes to coaz-pep, so that one parser reads every MCP body.

A policy set the API publishes

An authzen_policy_set is a tree. A member is a PDP identifier, a PDP with options, or a nested set. A set combines its members' answers with deny-overrides (every member must permit), permit-overrides (the first permit decides) or first-applicable (the first answer decides). Members are asked in document order and asking stops as soon as the answer is known, so every PEP makes the same calls in the same order.

policy set deny-overrides every member must permit permit X-PDP-Fail-Open: risk 1 · estate PDP "https://pdp.estate.example" 2 · risk PDP {"pdp": "…risk…", "fail_open": true} 3 · nested set permit-overrides a · bank-b PDP b · break-glass PDP permit asked first timeout · skipped it may fail open, so the set goes on and names it permit the nested set's answer is known not asked permit-overrides stopped at the first permit
FIG 3Order is fixed, asking stops early, and a skipped member is visible. A failed member counts as a deny unless it may fail open, in which case it is skipped. A refusal (a 3xx or 4xx, or an answer that isn't a decision) is a deny whatever fail_open says. A set is limited to 4 levels of nesting, 8 members per set and 16 distinct PDPs, and every PDP in it passes the allowlist before anything is asked.

Whose word counts on failing open. A member without fail_open takes its set's setting, and at the top the PEP's failure mode, which is closed unless the operator chose otherwise. In federation mode a published fail_open: true counts unless a superior pins authzen_fail_open_permitted to false. In RFC 9728 mode the document is self-asserted, so a published fail_open: true is ignored unless the operator allows it. A published fail_open: false always counts, because it can only close.

Three generic PingAuthorize PDPs over Sideband

With the sideband-pdp Kong plugin, the PDPs are PingAuthorize engines, each running one generic policy: token, risk or response. None of the policies contains an API, path, scope or account. Everything specific comes from the API's RFC 9728 document, which every policy reads as a PIP. The same document publishes the three engines as its policy set.

Agent MCP or REST client Kong · sideband-pdp asks each member in order Bank API the resource, R request filtered response only if every member permits response 1 · request 2 · request 3 · request, 4 · response token PDP iss, aud, scopes, acr request-only risk PDP the operation's risk policy request-only response PDP entitlement, filter, mask, counts request and response verify token score the session entitled? which ids? issuer JWKS via AS metadata, RFC 8414 risk engine GET /score entitlements PIP AuthZEN evaluation + search reads reads reads publishes the API's RFC 9728 document authzen_policy_set · authzen_operations · authzen_risk_policies · response policy
FIG 4Three PDPs, three PIPs and the shared document. The set is deny-overrides over the token, risk and response PDPs. Each engine validates the access token against the issuer's JWKS, so the policies only read verified claims. The token and risk PDPs are request-only on the route. The response PDP is asked twice, once for the request and once for the API's response, which it filters and masks using answers from the entitlements PIP (authzen_entitlements_pip). The risk engine's URL is operator configuration and never comes from the document. The details are in the resource policy design note.

Resolution modes

The mode decides where the first hop gets its answer: PDP_DISCOVERY on coaz-pep, and pdp_discovery on the Kong plugins. Read the rows from top to bottom as moving the source of truth further from the PEP. The second hop, from PDP identifier to endpoints, is the same in every AuthZEN mode.

MODE WHAT NAMES THE PDP HOW ITS ENDPOINTS ARE FOUND WHOSE WORD IT IS off the default AUTHZEN_URL operator configuration, nothing fetched the resource identifier is not consulted PDP id default paths {pdp}/access/v1/evaluation the operator set at deploy time authzen AUTHZEN_URL operator configuration PDP id GET authzen-configuration 404 → default paths the operator the PDP describes itself resource GET {R}/.well-known/oauth-protected-resource + signed_metadata, checked against its own jwks_uri 404 or no PDP named → AUTHZEN_URL PDP id GET authzen-configuration 404 → default paths the API itself self-asserted federation Trust Chain: R → intermediates → anchor resolved oauth_resource metadata not a member → AUTHZEN_URL · R's own document is never read PDP id GET authzen-configuration 404 → default paths the federation verified to a trust anchor federation- resolver sideband-pdp only GET {resolve}?sub=R&anchor=TA resolver's answer, trusted on TLS, not verified resolver doesn't know R → service_url PDP id no probe {pdp}/sideband/request, /response the resolver TLS only, chain not checked
FIG 5Each row trusts someone different. The orange boxes are the fetches each mode adds. Federation mode never falls back to the API's own document: an API outside the federation gets the operator's PDP, not the one it names for itself. Verified federation runs in coaz-pep. Kong, PingAccess and the Node SDK implement resource and authzen natively, and get federation by delegating to coaz-pep.

What a lookup can come back with

A metadata lookup has four possible outcomes, and the resolver keeps them apart on purpose. The important split is between "publishes nothing" and "can't be reached". If an outage fell through to the static PDP, anyone who could take an API's metadata offline could choose a different judge for it.

metadata lookup for resource R names a PDP a valid document naming who decides publishes nothing 404, names no PDP, not a federation member outage or unreadable timeout, 5xx, a document that won't parse refusal allowlist miss, invalid chain, unusable doc use it cached to its TTL, or to signed_metadata's exp the static PDP decides AUTHZEN_URL, by design · cached like any answer serve the last good copy up to PDP_METADATA_MAX_STALE, never past its own expiry then unavailable: fails by its mode, closed by default fail closed evicts what was cached · fail-open never applies
FIG 6Two outcomes are answers and two are failures. Fail-open covers availability only: an unreachable PDP, a timeout, a 5xx or 429, or metadata that can't be fetched. A PDP that answers 3xx or 4xx has refused, and a refusal fails closed everywhere. A permit that skipped anything is marked with X-PDP-Fail-Open.

Up to federation

An API's own document can't vouch for itself. A careless or compromised API can name a PDP that permits everything, which is what the demo's impostor does. In federation mode the PEP doesn't read the API's document at all. It walks a Trust Chain from the API up to an anchor whose keys the operator configured, and uses the metadata that survives every superior's metadata_policy.

Walking the chain

PDP discovery federation mode Chain resolver cached per subject the API, R leaf entity Intermediate zero or more Trust Anchor keys set out of band resolve R GET Entity Configuration self-signed · authority_hints 404: not a member GET Entity Configuration federation_fetch_endpoint fetch?sub=R Subordinate Statement: R's keys + metadata_policy GET Entity Configuration checked against the configured anchor keys fetch?sub=Intermediate Subordinate Statement: intermediate's keys + metadata_policy verify every signature · §4 invariants · §6.2 constraints apply metadata_policy, anchor down to leaf (§6.1) resolved metadata policy set, or a refusal
FIG 7Every link is fetched, then every link is verified. The leaf's Entity Configuration sits at {R}/.well-known/openid-federation, a suffix rather than an insertion after the host. The walk follows authority_hints breadth-first and tracks loops per path, so an intermediate reachable two ways can't hide the chain to the preferred anchor. The resolved answer expires at the earliest exp in the chain.

Once the federation vouches for an API, its resolved metadata is the only word that counts. If that metadata has no oauth_resource block, or has no usable authzen_policy_set, the result is a refusal, not a fallback to the static PDP. A fallback would quietly drop the PDPs and the acr floor the anchor set.

What the policy does to a member's metadata

The demo's member shows the effect. Its own Entity Configuration's set names the rogue PDP first and says a password is enough. The anchor's policy replaces both: it pins a set with the estate PDP first, then Bank A's.

ParameterMember's Entity ConfigurationAnchor's metadata_policyResolved, forwarded to the PDP
authzen_policy_set deny-overrides [rogue-pdp, pdp-a] value: deny-overrides [pdp-estate, pdp-a], essential: true deny-overrides [pdp-estate, pdp-a]
acr_values_required password value: [MFA] [MFA]
TABLE 1The member can't lower the floor by editing its own document, because a federation-mode PEP never reads it. essential makes a resolved document without a set invalid. The PEP forwards the resolved document with resource_metadata_source: federation, so the PDP sees MFA as the requirement and denies a password token. A set is a nested structure, so value, which pins it whole, is the only standard operator that applies to it.

The PEP as the API's federation face

An API in a federation has to publish an Entity Configuration and its RFC 9728 metadata. The gateway is the API's public face, so coaz-pep can publish both. The PEP holds a key and an entity identifier. The trust anchor, acting as controller, holds the API's policy.

Trust Anchor maintains the API's metadata coaz-pep the API's federation entity holds a key and an entity id an RFC 9728 consumer another PEP in resource mode or any client 1 · onboards: fetches the EC 2 · resolves its own chain Subordinate Statement: metadata + metadata_policy 3 · serves the resolved metadata as RFC 9728 signed_metadata + jwks_uri · X-Resource-Metadata-Source
FIG 8A plain RFC 9728 consumer reads the federation's answer without walking a chain itself. The PEP's Entity Configuration carries only its key, its authority_hints and {"resource": id}. Policy changes go in the anchor's Subordinate Statement and reach every consumer on its next refresh, with no PEP redeploy. Until the anchor onboards the PEP, its document is self-asserted and names only the configured PDP, and the header says so. Kong and PingAccess relay both documents from coaz-pep. The Node SDK signs its own, which is always self-asserted.

Rules that hold in every mode

No fallback after a refusalA URL outside an allowlist, or a chain that fails validation, fails closed. It never drops to a weaker source.
The static API key stays putThe key is bound to AUTHZEN_URL. A discovered PDP never receives it.
Documents can't widen the allowlistA document says which PDPs decide and how their answers combine. Every PDP it names must already be on the operator's allowlist.
Fail-open is visibleIt covers outages only. A deny never opens, a refusal never opens, and a permit that skipped anything is marked.
No batch to a guessed pathA batch needs the PDP to advertise access_evaluations_endpoint.
Allowlists apply on every callThe cache is shared but policy is per route, so allowlists are checked on each call, not only at fetch time.

More detail: the one-page overview, the resource policy design note, the demo guide and the architecture. Try each mode in the hosted demo.