The API says what it needs. Three PDPs enforce it.
An API already publishes RFC 9728 metadata to say which authorisation servers it trusts. This work extends that document so it also says which PDPs decide and how their answers combine, what the access token must carry, which risk policy applies, and how each operation's responses are filtered and masked. Three PingAuthorize PDPs read it and apply it, one for the token, one for risk and one for the response. Each has a generic policy that names no API, path, scope or account.
Built and tested on Kong 3.9 with the sideband-pdp plugin, three PingAuthorize 11.1 engines in external PDP mode, an entitlements PIP that speaks AuthZEN 1.0, and a 47-case test harness.
Move the policy parameters to the resource
Today each API gets its own policy: its scopes, its step-up rule, its idea of which accounts a caller may see. That knowledge belongs to the API owner, yet it lives in a PDP someone else runs, so every change to the API means a policy change too.
The resource already has a public document for its authorisation story. RFC 9728 protected resource metadata names the authorisation servers and scopes. In an OpenID Federation it is the oauth_resource metadata in the entity statement, where a trust anchor can constrain it with metadata_policy. We already use it to discover who decides: the PDPs and how their answers combine (authzen_policy_set).
So the resource publishes the parameters of its policy, and generic policies hold the logic: one per PDP, three PDPs. The PEP still judges nothing. It forwards the request and the response, and applies the answer.
Three rules carried over from the existing design
A PEP forwards metadata and never interprets it. A published document can name who decides but cannot widen the PDP allowlist. Anything an anchor must be able to constrain stays a flat array of strings, because subset_of, superset_of and essential cannot reach inside nested objects.
| Who | Owns |
|---|---|
| API owner | Operations, required scopes and acr, risk thresholds, which lists to filter and which fields to mask |
| Federation anchor | Floors and ceilings via metadata_policy: trusted issuers, minimum scopes, mandatory estate PDP |
| PDP operator | The generic policy, the risk engine, the allowlist, what fails open |
| Entitlements system | Who may do what to which resource, behind AuthZEN evaluation and search |
One API response, cut to fit each caller
The accounts API has no authorisation logic. It sends every account and every field to anyone who asks. Its metadata says what is sensitive: for GET /accounts, the list is $.data, each item is identified by id, and balance and tax_id are only for callers who may view the balance. Between the API and the client, two things can happen to the response:
- Filtering removes a whole item that the caller may not
read. - Masking removes the sensitive fields from an item the caller may read but may not
view_balanceon. The fields are deleted, not replaced with asterisks, so the client cannot tell a masked item from an item that never had those fields.
filtered item removed masked fields removed in full unchanged. Every payload below is from the demo stack.
A list: GET /accounts
The request. The request is the same for every caller. Only the token's subject differs.
GET /accounts HTTP/1.1
Host: api.demo.example
Authorization: Bearer eyJ… // sub: alice | bob | carol, scope: accounts:read
What the API returns, to every caller. Four accounts, every field.
200 OK
{
"data": [
{ "id": "acc-1", "name": "Everyday", "owner": "alice", "balance": 1520.1, "tax_id": "TFN-111" },
{ "id": "acc-2", "name": "Savings", "owner": "alice", "balance": 20400.0, "tax_id": "TFN-111" },
{ "id": "acc-3", "name": "Business", "owner": "bob", "balance": 98000.55, "tax_id": "ABN-333" },
{ "id": "acc-4", "name": "Joint", "owner": "bob", "balance": 310.0, "tax_id": "TFN-444" }
],
"meta": { "total": 4 }
}
What Alice receives. She may read acc-1, acc-2 and acc-4, and may view balances on acc-1 and acc-2. So acc-3 is filtered out, and acc-4 is kept with its balance and tax id removed.
200 OK
{
"data": [
{ "id": "acc-1", "name": "Everyday", "owner": "alice", "balance": 1520.1, "tax_id": "TFN-111" }, // in full
{ "id": "acc-2", "name": "Savings", "owner": "alice", "balance": 20400.0, "tax_id": "TFN-111" }, // in full
{ "id": "acc-3", "name": "Business", "owner": "bob", "balance": 98000.55, "tax_id": "ABN-333" }, // filtered: may not read
{ "id": "acc-4", "name": "Joint", "owner": "bob", "balance": 310.0, "tax_id": "TFN-444" } // masked: may read, may not view_balance
],
"meta": { "total": 4 } // not corrected yet: see below
}
What Bob receives. He may read and view balances on acc-3 and acc-4. Alice's two accounts are filtered out, and nothing is masked.
200 OK
{
"data": [
{ "id": "acc-1", "name": "Everyday", "owner": "alice", "balance": 1520.1, "tax_id": "TFN-111" }, // filtered
{ "id": "acc-2", "name": "Savings", "owner": "alice", "balance": 20400.0, "tax_id": "TFN-111" }, // filtered
{ "id": "acc-3", "name": "Business", "owner": "bob", "balance": 98000.55, "tax_id": "ABN-333" }, // in full
{ "id": "acc-4", "name": "Joint", "owner": "bob", "balance": 310.0, "tax_id": "TFN-444" } // in full
],
"meta": { "total": 4 }
}
What Carol receives. She has no entitlements, so every item is filtered out. An empty or failed entitlements answer has the same effect, so this path fails closed.
200 OK
{ "data": [], "meta": { "total": 4 } }
One item: GET /accounts/{accountId}
For a single resource the metadata names the item ("resource": { "type": "account", "id": "{accountId}" }). The read check therefore runs before the request reaches the API, and only masking is left for the response.
Alice, GET /accounts/acc-4. She may read it but may not view the balance. The API returns the whole account, and she receives it masked.
// API → PEP
{ "id": "acc-4", "name": "Joint", "owner": "bob", "balance": 310.0, "tax_id": "TFN-444" }
// PEP → Alice: 200 OK
{ "id": "acc-4", "name": "Joint", "owner": "bob", "balance": 310.0, "tax_id": "TFN-444" }
Bob, the same request. He receives the account unchanged.
Alice, GET /accounts/acc-3. She may not read it. The request is denied before it is proxied, so the API never sees it and there is no response to filter.
// PEP → Alice (the API is not called)
403 Forbidden
{ "status": 403, "error": "access_denied",
"error_description": "alice may not read acc-3." }
What decides each item
Each account's outcome comes down to two facts about the caller: whether they may read it, and whether they may view_balance on it. The metadata names both actions. The answers come from the entitlements system, and the sections below show how the PDP gets them and turns them into the edits above.
| Caller | acc-1 | acc-2 | acc-3 | acc-4 |
|---|---|---|---|---|
| alice | in full | in full | filtered | masked |
| bob | filtered | filtered | in full | in full |
| carol | filtered | filtered | filtered | filtered |
Counts and shapes. The metadata names meta.total as the list's count, so it is set to what each caller sees: 3 for Alice, 2 for Bob. Left alone it would say 4, which tells Alice that an account exists she can't see. A response shaped differently from what the metadata says (an object where it expects a list) is withheld, and a top-level member it doesn't name (a second list) is removed. The tests below cover both.
Four extensions to the resource's document
1. A policy set: who decides, and how their answers combine
authzen_policy_set names the resource's PDPs. A member is a PDP identifier, {"pdp", "fail_open"}, or a nested set {"combining", "members", "fail_open"}; at most 4 levels, 8 members a set, 16 distinct PDPs. Members are asked in document order and asking stops once the answer is known, so every PEP makes the same calls in the same order. AuthZEN answers only true or false, so an outage is a PEP's to judge, not a PDP's:
| A member's PDP | Counts as |
|---|---|
decision: true | Permit |
decision: false | Deny |
| Unavailable (no answer, a 5xx or 429), on a fail-open member | Skipped, and a permit is marked |
| Unavailable on a fail-closed member, or a refusal (a 3xx or 4xx) | Deny, answered as an unavailable PDP |
Fail-open: inherited. A member without its own fail_open takes its set's, and at the top the PEP's rule for the resource. A set is skipped for an outage only on a fail_open it carries itself. A published true counts in federation mode unless a superior pins authzen_fail_open_permitted to false, and in RFC 9728 mode only when the operator opts in. A published false always counts: it only closes.
2. Access-token requirements
| Claim | Parameter | Anchor can |
|---|---|---|
iss | authorization_servers (RFC 9728) | subset_of |
aud | resource + access_token_audiences | subset_of |
scope | scopes_required, all of | superset_of |
acr | acr_values_required, any of | subset_of |
3. Risk policies
There is no standard risk claim, so the policy names the claim by path: {"name":"payments-risk","claim":"risk_session","max_score":600}. The PDP's risk engine scores the session; the policy compares the score with the threshold.
4. Operations
authzen_operations is keyed by method and path template. An operation can add requirements but never relax the resource's floor, so a member can't undo an anchor's constraint one operation at a time. It also names the resource it acts on (its id from a path parameter, {query.x} or {body.a.b}) and its response policy: one or more filter lists (items a dotted path, item_id, and an optional count field corrected to what the subject sees), a mask, keep for other top-level members, and max_items, the page size (default 200). Members the metadata doesn't name are removed.
The combining algorithms
A member that is itself a set is worked out completely and then combined like a PDP. A PDP named twice is one call. A set whose answer can only be a deny before anything is asked (a member whose metadata can't be read, under deny-overrides) asks nobody.
| Algorithm | Answer | Stops asking at | On a deny, the challenge is | Use it for |
|---|---|---|---|---|
deny-overrides | Every member must permit; obligations of those that did accumulate, the first to require something owning it | The first deny | The first member that denied | Gates in front of a resource: the token, the risk, then the resource's own PDP |
permit-overrides | The first member that permits decides, with its own obligations | The first permit | The first member that denied | Alternatives: the resource's PDP, or a break-glass PDP |
first-applicable | The first member that answers decides; one that is unavailable passes to the next | The first answer | The member that answered | A fallback chain: a primary PDP and its standby |
The XACML algorithms that differ from these only in how they treat an error (permit-unless-deny, deny-unless-permit) or that need a "not applicable" answer (only-one-applicable) aren't accepted: what an error does is fail_open's job, and AuthZEN has no "not applicable".
Allowlist and response phase. Every PDP in the set, however deep, has to be on the PEP's allowlist before anything is asked; one that isn't fails the request. Fail-open never applies to the response phase, because an unfiltered response is a leak.
How a set looks in the metadata
{
"resource": "https://api.bank-b.example",
"authzen_policy_set": {
"combining": "deny-overrides",
"members": [
"https://pdp.estate.example", // estate gate: watch list, client standing
{ "pdp": "https://pdp.risk.example", "fail_open": true }, // risk can be skipped in an outage
{ "combining": "permit-overrides",
"members": ["https://pdp.bank-b.example", // the resource's own PDP
"https://pdp.breakglass.example"] } // ops override, audited
]
}
}
How it evaluates. The estate PDP is asked first. If it denies, the answer is its deny and nothing else is called. If the risk PDP is down and its fail_open is honoured, it is skipped and the eventual permit is marked. The inner set then asks the resource's PDP; if that permits, the break-glass PDP is never called. If it denies, the break-glass PDP decides, and if both deny, the client gets the resource PDP's challenge.
A superior can only pin a nested structure whole with value. Putting its own PDP in front of a member's set without pinning the whole set needs an operator the standard doesn't have; it will be a custom one, and isn't defined yet.
The demo API's metadata, as published
{
"resource": "http://api:8080",
"authorization_servers": ["https://as.demo.example"],
"access_token_audiences": ["accounts-api"],
"acr_values_required": ["urn:demo:acr:basic", "urn:demo:acr:strong"],
"authzen_policy_set": { "combining": "deny-overrides",
"members": ["http://paz-token:1080", "http://paz-risk:1080", "http://paz-response:1080"] },
"authzen_entitlements_pip": "http://entitlements:8080",
"authzen_risk_policies": [{ "name": "payments-risk", "claim": "risk_session", "max_score": 600 }],
"authzen_operations": [
{ "method": "GET", "path": "/accounts", "scopes_required": ["accounts:read"],
"response": {
"filter": { "items": "$.data", "item_id": "id", "resource_type": "account", "action": "read",
"count": "$.meta.total" },
"mask": { "fields": ["balance", "tax_id"], "action": "view_balance" },
"keep": ["meta"], "max_items": 100 } },
{ "method": "GET", "path": "/accounts/{accountId}", "scopes_required": ["accounts:read"],
"resource": { "type": "account", "id": "{accountId}" }, "action": "read",
"response": { "mask": { "fields": ["balance", "tax_id"], "action": "view_balance" } } },
{ "method": "POST", "path": "/accounts/{accountId}/payments", "scopes_required": ["payments:write"],
"acr_values_required": ["urn:demo:acr:strong"], "risk_policies": ["payments-risk"],
"resource": { "type": "account", "id": "{accountId}" }, "action": "pay" }
]
}
Three PingAuthorize PDPs apply it, over the Sideband API
The first design put response mapping in the PEPs, as CEL. That meant writing a CEL evaluator in Lua for Kong and adding ext_proc for Envoy. With PingAuthorize doing the work instead, the PEPs just move bytes, and mutation moves to the PDP.
That choice settles the protocol. An AuthZEN decision is a boolean plus a context, and has nowhere to put a rewritten body. PingAuthorize's Sideband API does: the PEP posts the request to /sideband/request and the upstream's response to /sideband/response, and sends back whatever the answer says. Our Kong sideband-pdp plugin already did both, through every layer.
Each concern is its own PDP: a PingAuthorize engine at its own URL, deciding from its own policy. paz-token judges the access token, paz-risk the session's risk, and paz-response the entitlement, the filtering and the masking. The resource's metadata names all three in its policy set, deny-overrides over paz-token, paz-risk and paz-response. The plugin asks them in that order, and every one must permit. The route marks the token and risk layers request-only, so only paz-response sees the response. X-PDP-Layer tells the client which PDP refused.
Why the metadata uses JSONPath, not CEL
PingAuthorize can't run CEL. Its policy language is SpEL, plus advice that takes JSONPath. A spike settled what a policy can do with selectors that arrive as data rather than as configuration:
{{attribute}}inside a SpEL expression becomes a variable, so one expression can read two attributes. That makes operation matching possible: compare the method, and the path against each published template.- Only
java.lang.Stringis allowed as a type. Jayway'sJsonPathisn't visible, so the policy can't evaluate a JSONPath it reads from metadata. It can walk a dotted path key by key, and tell a map from a list by its class name, which is how it checks that each list is where the metadata says. - The policy can still write one, though, and PingAuthorize's own advice applies it. That turned out to be the whole trick.
Stand-ins in the demo
- A demo issuer signs tokens (RS256) for anyone who asks. Each PingAuthorize engine validates them against its JWKS with a JWT access token validator, and the policies read the verified claims. An unsigned token counts as no token.
- The sideband exposes the method only as the decision's action (
inbound-GET), so the plugin sends the method and the resource identifier as headers, and strips any copy a client sent. - Found and fixed along the way: the plugin passed PingAuthorize's echoed
Content-Lengthbeside a filtered, shorter body, so clients hung waiting for bytes that never came.
What the three PDPs do on every request
Find the resource and its metadata
A PIP fetches
no match → 403 operation_not_published<resource>/.well-known/oauth-protected-resource.Match the operation
The first entry whose method matches and whose template, turned into a regex, matches the path.
paz-token: check the token
→ 401 invalid_token, WWW-Authenticate with resource_metadataissagainstauthorization_servers;aud, string or array, against the resource and its audiences.paz-token: check scopes
The resource's floor plus the operation's own, all required.
→ 403 insufficient_scope, scope="…" (the scopes missing)paz-token: check authentication strength
The operation's
→ 401 insufficient_user_authentication, acr_values="…" (RFC 9470 step-up)acrlist, else the resource's.paz-risk: check risk
Read the claim the risk policy names, score it, compare with
→ 403 risk_too_highmax_score.paz-response: check entitlement
For an operation on one resource (its id from a path parameter, a query parameter or the JSON body): an AuthZEN evaluation at the entitlements PIP the metadata names.
→ 403 access_deniedpaz-response, on the response: filter and mask
Check that each list is where the metadata says and within the page size, then two AuthZEN resource searches, one JSONPath payload, and PingAuthorize's built-in
→ 502 response_withheld, or 502 response_too_largeexclude-attributesadvice. Members the metadata doesn't name are removed, and a named count is set to what the subject sees. The next section shows how.
Same challenge contract as the other PEPs. Every denial carries the error and challenge a client already knows how to handle. Step-up uses RFC 9470, and the missing-token challenge points back at the resource's metadata, which is how RFC 9728 discovery starts.
$ walkthrough.sh (through Kong, live)
No token 401 invalid_token
Unsigned token 401 invalid_token
Untrusted issuer 401 invalid_token
Wrong scope 403 insufficient_scope scope="accounts:read"
Alice lists accounts 200 acc-3 removed, acc-4 masked
Bob lists accounts 200 acc-3, acc-4 in full
Alice reads acc-3 403 access_denied
Alice pays, basic acr 401 insufficient_user_authentication
Alice pays, risky session 403 risk_too_high (870 > 600)
Alice pays, low-risk session 201 accepted
DELETE (not published) 403 operation_not_published
Three policies, each with first-applicable rules: response-phase rules first, then each request check in order, then permit. Defaults deny: a missing claim resolves to none, which fails its check, and an attribute that errors falls back to a value that withholds. Every step finds the resource and its operation first; paz-token and paz-response both refuse an unpublished operation.
Neither the PEP nor the PIP touches the body
The policy only works out which JSONPaths to remove. It asks the entitlements PIP which ids the subject may read and which they may view_balance, writes each answer into a JSONPath filter literal, and attaches the result to the permit as exclude-attributes advice. PingAuthorize deletes whatever matches. Filtering removes whole items that match; masking is the same path with .field on the end.
Try it below. The page works the result out the way the policy does, from the demo's entitlement table.
1 · What PingAuthorize (the PDP) asks the entitlements PIP
2 · The advice payload it writes
3 · What PingAuthorize does to the upstream's body
4 · What the client receives
Why this shape
- One mechanism for both. Filtering and masking need no per-item decisions: two searches and one advice.
- Fails closed by construction. An empty or failed search gives
nin [], which matches every item, so everything is removed. - Generic. Nothing in the policy names
data,id,balanceor an action. Change the metadata and the next response changes.
Escaping, learned the hard way
The Policy Editor strips one level of backslashes from SpEL and from statement payloads before they run. Template regexes therefore use brace classes ([{][^/]+[}]) instead of \{, and the quotes inside a WWW-Authenticate payload are escaped twice. The image's built-in Default sideband endpoint on / also shadows a new one, along with its error template, so the demo deletes it.
What actually went over the wire
These are two real requests through the running stack, as Alice. Every payload below is what the component sent or received: the API's response before PingAuthorize touched it, each AuthZEN call PingAuthorize made to the entitlements PIP, the advice PingAuthorize applied, and what reached the client.
The entitlements behind every answer
The entitlements PIP is an AuthZEN 1.0 service over this table. AuthZEN is a PDP protocol, but PingAuthorize is the PDP here: it asks this service for facts (may alice read acc-4? which accounts may she read?) and makes the decision itself. It stands in for whatever system of record holds account access. read decides the filter, and view_balance decides the mask.
| Subject | read | view_balance | pay |
|---|---|---|---|
| alice | acc-1 acc-2 acc-4 | acc-1 acc-2 | acc-1 |
| bob | acc-3 acc-4 | acc-3 acc-4 | acc-3 acc-4 |
The access token
Unsigned (alg: none) for the demo. Kong decodes these claims and hands them to the policy; sub becomes the AuthZEN subject.
An unused call on every request. Calls marked unused were made even though the branch that needs them wasn't taken. PingAuthorize resolves every attribute a SpEL expression names before it evaluates the expression. On the list request it evaluates view_balance on resource none; on the single account it searches for action none. Both come back empty and change nothing, but each costs a round trip to the PIP. Splitting the expression per branch would remove it.
47 cases, run through the live stack
A test harness publishes one operation, one payload and one set of entitlements per case, calls it through Kong, and compares the answer with what the metadata means. It covers 32 response shapes and 15 ways of naming the resource in a request. The last run: 46 pass, none leak, and one fails closed (an entitled id holding a backslash is filtered out).
34 more APIs, 13 of them CDR banking endpoints, were captured hop by hop. They found one leak, a list mixing two resource types. CDR lists keep the links and meta the standards require once their metadata declares them in keep, with count for meta.totalRecords.
What held
The list can be at the top level, deep, or in HAL, and one response can hold several lists. A list under a key with a dot in it, or nested in every item, can't be walked, so it is withheld rather than filtered. Masked fields can be nested, inside arrays inside items, whole sub-objects, or named with hyphens or spaces. Ids can be nested, numeric, unicode, or contain JSONPath syntax. Junk items are removed. Oversized bodies, CSV and error statuses all fail closed or pass through as they should.
An allow-list, built from exclusion
Exclusion is a deny-list: it removes only what the metadata names, so on its own it fails open when a response is shaped differently. The obvious allow-list, include-attributes, is worse: when none of its paths match, PingAuthorize returns the whole body. So the policy checks each list's shape first, withholds the response when a list isn't where the metadata says, and removes every top-level member the metadata doesn't name.
The hard limit is time
PingAuthorize decides in about 0.2 s. Applying the advice is the expensive part: it re-evaluates the filter for every node it deletes, so the cost grows roughly as deletions × items × length of the id list. Holding the list at 1,000 items shows which factor matters:
| Readable | Viewable | What dominates | Time |
|---|---|---|---|
| 5 | 5 | 995 deletions, short id list | 1.4 s |
| 995 | 995 | 5 deletions, long id list | 1.3 s |
| 995 | 5 | 1,980 masked fields | 4.6 s |
| 500 | 250 | both | 33 s |
In practice that means pages of about 200 items or fewer, so the metadata bounds the page (max_items, default 200) and a longer page is withheld at once. For bigger pages the options would be: inject the permitted ids into the upstream query, write a one-pass advice extension with the Java SDK, or use filter-response (one decision per item, which is linear but makes a round trip each in external PDP mode).
What the metadata can express today
| Mapping | Today | Next |
|---|---|---|
items | $ or a dotted path of up to four plain keys, which the policy walks to check the shape | As is |
filter | One, or a list of up to three sharing one action and type; count names a field corrected to what the caller sees | As is |
item_id | A property path relative to the item (id, ref.id), escaped in the JSONPath | As is |
fields | Paths relative to the item, including [*] and bracket names | As is |
| Other members | Removed unless keep names them; unnamed: "pass" opts out | As is |
| Page size | max_items, default 200: a longer list is withheld | As is |
| Resource id | A path parameter by name, {query.x} given once, or {body.a.b} | Headers |
| Operations | Method plus template, literals quoted | As is |
| Response types | JSON only. Others are refused on filtered operations. | As is |
The plan
| Phase | What | State |
|---|---|---|
| 1 | Spike: can a PingAuthorize policy apply selectors that arrive as data? | done |
| 2 | Docker demo: Kong sideband-pdp → PingAuthorize (external PDP mode) → Policy Editor, with the entitlements PIP and risk engine | done |
| 3 | Harden matching and response shapes: named path parameters, escaped templates, a shape check, several lists, query and body ids, a page-size bound | done |
| 4 | PEP support: the plugin sends resource and method, PingAuthorize validates the token, the response phase never fails open, resource-relative paths, counts corrected | done |
| 5 | The metadata parameters in every enforcement point: policy sets with combining, token and risk requirements, operations | in progress |
| 6 | Write-up: a spec-style parameter reference, and the generic policy explained | open |
| 7 | Three PingAuthorize PDPs, one per concern: token, risk, response | done |
Questions still open
- Is splitting fail-open by trust level (self-asserted versus federation-vouched) the right line?
- Should AuthZEN gain a "not applicable" answer, so that
first-applicableandonly-one-applicablemean something?
Decided
- Responses default to an allow-list built from exclusion: unnamed members are removed and list shapes checked.
- Big lists are paged at the source:
max_itemsbounds the page. - Each concern is its own PingAuthorize PDP, all over the Sideband API.
Read on
- Resource policy payloads: 34 APIs, each with its OpenAPI, metadata, request, response, and every PDP and PIP call.
- How a gateway discovers its AuthZEN PDP: the RFC 9728 and federation discovery this builds on.
- The Kong
sideband-pdpplugin: the PEP in this demo. - Ping Authorize Entitlements Accelerator: the entitlements side of the same question.