The demo
The demo compares configured, resource-based and federation-based PDP discovery. It runs three instances of coaz-pep against stub resources and PDPs. The console shows the metadata retrieved and the decision for each request. Optional Compose profiles add Kong and PingAccess.
Run it
cd demo && docker compose up --build -d && ./demo.sh # the scripted walk
open http://localhost:8088 # the console
docker compose --profile kong up -d # add Kong on :8000, the plugin doing discovery in Lua
docker compose --profile pingaccess up -d # add PingAccess on :3000 (needs Ping DevOps credentials in demo/.env)
demo/run-local.sh --console # the same without Docker; needs Go
Demo services
| Stub | Port | What it is |
|---|---|---|
anchor | 9000 | A federation trust anchor. Its policy for members: authzen_policy_set is the estate PDP then Bank A's, and acr_values_required is [MFA], whatever the member says. It onboards entities on request. |
member | 9001 | A federated resource. Its own RFC 9728 document names Bank A's PDP and says a password is enough; its entity configuration's set names the rogue PDP first. The anchor's policy replaces that set with its own, the estate PDP then Bank A's, and raises the required authentication level to MFA. |
pdp-a | 9002 | Bank A's PDP, identifier …:9002/tenants/bank-a. Holds the token to what the resource published; steps up payments over 1000; advertises a batch endpoint. |
rogue-pdp | 9003 | Permits everything, logs loudly when asked, advertises no batch endpoint. |
plain | 9004 | Not federated. RFC 9728 metadata names Bank A's PDP; accepts a password. |
impostor | 9005 | Not federated. RFC 9728 metadata names the rogue PDP. |
broken | 9006 | Federated, but signs with a key the anchor never vouched for. |
stray | 9007 | No metadata of any kind. |
pdp-b | 9008 | Bank B's PDP, identifier …:9008/tenants/bank-b. Same product, stricter threshold: steps up payments over 100. |
bank-b | 9009 | Not federated. RFC 9728 metadata has an authzen_policy_set that puts the estate PDP in front of Bank B's; requires MFA. |
pdp-estate | 9098 | A generic PDP for the whole estate: judges the token and the client (agent-risky is on its watch list), knows nothing about any resource. The first layer. |
control | 9099 | The event feed the console traces, and the controls it pulls. Not part of any spec. |
| the gateway's own API | pep-federation:9192 | Not a stub. pep-federation is the federation entity for the API it fronts: it holds a key, publishes a minimal entity configuration, and republishes what the anchor resolves as that API's RFC 9728 document. |
The demo also runs three coaz-pep instances. pep-static on :9192 uses the configured PDP. pep-resource on :9193 uses resource metadata. pep-federation on :9194 resolves federation metadata.
The compose file
demo/docker-compose.yml, complete. Everything is plain http on a private network, hence PDP_DISCOVERY_INSECURE; do not copy that line anywhere real. Allowlists list each stub with its port because an entry matches scheme, host and port.
services:
stubs:
build: { context: ../core, target: demo-stubs }
environment:
STUB_HOST: stubs
ANCHORS_FILE: /shared/anchors.json
volumes: [ "shared:/shared" ]
ports: [ "9000-9009:9000-9009", "9098-9099:9098-9099" ]
healthcheck:
test: ["CMD", "wget", "-q", "-O", "-", "http://localhost:9099/healthz"]
interval: 2s
timeout: 2s
retries: 15
pep-static:
build: { context: ../core, target: coaz-pep }
environment: &pep-env
AUTHZEN_URL: http://stubs:9002/tenants/bank-a
AUTHZEN_API_KEY: static-pdp-key
CHECK_API_TOKEN: demo
PEP_ALLOW_INSECURE: "true" # unsigned demo tokens, no JWKS: coaz-pep refuses to start without this. Never anywhere real
PDP_DISCOVERY_INSECURE: "true"
MCP_UPSTREAM_ALLOWLIST: http://stubs:9001,http://stubs:9004,http://stubs:9005,http://stubs:9006,http://stubs:9009
PDP_METADATA_TTL: 15s # short so the console's trace shows fetches on every run; the shipped default is 5m
ports: [ "9192:9192" ]
depends_on: { stubs: { condition: service_healthy } }
pep-resource:
build: { context: ../core, target: coaz-pep }
environment:
<<: *pep-env
PDP_DISCOVERY: resource
PDP_DISCOVERY_INSECURE: "true"
RESOURCE_METADATA_ALLOWLIST: http://stubs:9001,http://stubs:9004,http://stubs:9005,http://stubs:9006,http://stubs:9007,http://stubs:9009,http://pep-federation:9192
# deliberately no PDP_ALLOWLIST (logged at boot): the impostor may name the rogue
ports: [ "9193:9192" ]
depends_on: { stubs: { condition: service_healthy } }
pep-federation:
build: { context: ../core, target: coaz-pep }
environment:
<<: *pep-env
PDP_DISCOVERY: federation
PDP_DISCOVERY_INSECURE: "true"
RESOURCE_METADATA_ALLOWLIST: http://stubs:9001,http://stubs:9004,http://stubs:9005,http://stubs:9006,http://stubs:9007,http://stubs:9009,http://pep-federation:9192
PDP_ALLOWLIST: http://stubs:9002,http://stubs:9008,http://stubs:9098
FEDERATION_TRUST_ANCHORS_FILE: /shared/anchors.json
FEDERATION_FETCH_ALLOWLIST: http://stubs:9000
FEDERATION_ENTITY_ID: http://pep-federation:9192
FEDERATION_ENTITY_KEY_FILE: /tmp/entity-key.json
FEDERATION_ENTITY_KEY_GENERATE: "true"
FEDERATION_AUTHORITY_HINTS: http://stubs:9000
volumes: [ "shared:/shared:ro" ]
ports: [ "9194:9192" ]
depends_on: { stubs: { condition: service_healthy } }
console:
build: { context: ../core, target: demo-console }
environment:
LISTEN: ":8088"
STUBS_BASE: http://stubs
STUBS_CONTROL: http://stubs:9099
PEP_STATIC: http://pep-static:9192
PEP_RESOURCE: http://pep-resource:9192
PEP_FEDERATION: http://pep-federation:9192
GATEWAY_ENTITY: http://pep-federation:9192
CHECK_API_TOKEN: demo
DOCS_DIR: /srv/docs
volumes: [ "../docs/reference:/srv/docs:ro" ]
ports: [ "8088:8088" ]
depends_on: { stubs: { condition: service_healthy } }
kong:
profiles: ["kong"]
image: kong:3.9
environment:
KONG_DATABASE: "off"
KONG_DECLARATIVE_CONFIG: /kong/kong.yml
KONG_PLUGINS: bundled,authzen-pdp
KONG_LUA_PACKAGE_PATH: /opt/?.lua;;
KONG_PROXY_LISTEN: 0.0.0.0:8000
KONG_ADMIN_LISTEN: "off"
KONG_LOG_LEVEL: notice
KONG_PROXY_ERROR_LOG: /dev/stderr
volumes:
- ./kong/kong.yml:/kong/kong.yml:ro
- ../gateways/kong/authzen-pdp:/opt/kong/plugins/authzen-pdp:ro
ports: [ "8000:8000" ]
depends_on: { stubs: { condition: service_healthy } }
pingaccess:
profiles: ["pingaccess"]
build: { context: .., dockerfile: demo/pingaccess/Dockerfile }
environment:
PING_IDENTITY_ACCEPT_EULA: "YES"
PING_IDENTITY_DEVOPS_USER: ${PING_IDENTITY_DEVOPS_USER:-}
PING_IDENTITY_DEVOPS_KEY: ${PING_IDENTITY_DEVOPS_KEY:-}
AUTHZEN_URL: http://stubs:9002
AUTHZEN_API_KEY: static-pdp-key
RESOURCE: http://stubs:9004
SITE_TARGET: stubs:9004
PEP_LABEL: "PingAccess (resource discovery)"
ports: [ "3000:3000", "9443:9000" ] # the engine; the admin console (administrator / 2FederateM0re)
depends_on: { stubs: { condition: service_healthy } }
volumes:
shared:
The console
Pick an API and a request, press Discover, and each of the three gateways shows the chain it followed, top to bottom: the document it read to find the PDP, the PDP's own metadata and the endpoint used, the context it handed the PDP, and last the decision.
The controls


forward_access_token, pdp_layers, and the token's claims.| Control | Options | What it sets |
|---|---|---|
| The API behind the gateway | plain, bank-b, impostor, member, broken, stray, the gateway's own API, no resource | The route's resource, which discovery starts from. |
| The request | read a balance, pay 50, pay 500, pay 5000, MCP transfer (batch) | The method, path and body; the MCP one is a tools/call whose mapping is a two-evaluation boxcar. |
| The token was issued to | agent-1, agent-risky | The token's client_id. The estate PDP has agent-risky on its watch list. |
| Authenticated with | password, MFA | The token's acr. |
| Scopes | read only, read + payments | The token's scope. |
| Forward the raw token | yes, no | The route's forward_access_token. |
| Policy layers | the API's PDP alone; the estate PDP first; the same, estate layer fail-open | The route's pdp_layers: nothing, http://stubs:9098,resource, or http://stubs:9098 fail-open,resource. |
The chain

member request uses a read-only token authenticated with a password. The static and resource-discovery evaluations permit it. Federation metadata policy removes the rogue PDP and requires multi-factor authentication (MFA). The PDP receives that resolved metadata and denies the request.
Demo controls
The console controls change the demo state while it runs. Enforcement points read the updated metadata on their next refresh. They keep their existing configuration.

/tenants/bank-a-v2. The two gateways that read metadata follow; the static one keeps posting to the base it assumed, marked stale. A gateway using a configured endpoint needs a configuration update to use the new address.
fail-open, it is skipped and Bank A's PDP alone decides; the permit is marked and X-PDP-Fail-Open names what was skipped. With the plain layers option the same outage is a 503.

The control API
The console relays /api/control to the stubs' :9099/control endpoint. GET returns the current state. POST {"op": …} changes it.
| Op | Effect |
|---|---|
move_pdp | Bank A's PDP advertises its endpoints under /tenants/bank-a-v2. It still answers on the old base, so nothing breaks mid-flight; the trace shows who followed. |
repoint_plain | The plain resource's metadata names Bank B's PDP instead. No gateway is touched. |
estate_down / estate_up | The estate PDP's evaluation endpoints answer 503; its metadata stays up. |
onboard / offboard with "entity" | The anchor fetches the entity's configuration, checks it is self-signed, and starts (or stops) vouching for its keys, saying what the resource requires in its subordinate statement. |
reset | Everything back. |
curl -s -X POST -H 'Content-Type: application/json' -d '{"op":"onboard","entity":"http://pep-federation:9192"}' http://localhost:9099/control
curl -s http://localhost:9099/control | jq .
{ "onboarded": ["http://pep-federation:9192"], "pdp_down": [],
"pdp_evaluation_path": { "pdp-a": "/tenants/bank-a/access/v1/evaluation", … },
"pdp_home": { "pdp-a": "/tenants/bank-a", … },
"resource_pdps": { "plain": ["http://stubs:9002/tenants/bank-a"], … } }
The scripted walkthrough
demo/demo.sh sends the same requests to the three PEPs' HTTP check APIs. It prints each decision with the response status and body. The walkthrough covers discovery and federation metadata policy. It also demonstrates policy layers and challenge responses. Set token claims through CLIENT, ACR and SCOPE. Set FORWARD=no or LAYERS for each call.
Hosted, on the site
The demo runs at federated-enforcement.idpartners.global/demo/, in the site's own container. site/Dockerfile builds the PEPs (coaz-pep), the stubs and the console from core/, or takes them from the coaz-pep and coaz-pep-demo images named by the COAZ_PEP_IMAGE and DEMO_IMAGE build arguments; site/start.sh starts them on localhost and nginx serves the console at /demo/. The same, on your machine, from the repository root:
docker build -f site/Dockerfile -t federated-enforcement .
docker run --rm -p 8080:8080 federated-enforcement # then http://localhost:8080/demo/
The controls change shared state and require no authentication. Concurrent users can change each other's scenarios.