IDPartnersidp-auth-peps · reference
demo/ · compose

The demo

Every component wired together: a stub trust anchor, resources that are and are not federation members, two banks' PDPs, an estate PDP and a rogue one, coaz-pep three times in three discovery modes, the Kong plugin and the PingAccess rule in front of the same resource, and a console that shows the chain each gateway followed. Its compose file is the fullest worked configuration in the repository, and the console is the screen on which every setting's effect can be seen.

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

The cast

StubPortWhat it is
anchor9000A federation trust anchor. Its policy for members: authzen_policy_decision_points must be a subset of [pdp-a], and acr_values_required is [MFA], whatever the member says. It onboards entities on request.
member9001A federated resource. Its own RFC 9728 document names Bank A's PDP and says a password is enough; its entity configuration names the rogue PDP first. The anchor's policy strips the rogue and raises the acr floor to MFA.
pdp-a9002Bank 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-pdp9003Permits everything, logs loudly when asked, advertises no batch endpoint.
plain9004Not federated. RFC 9728 metadata names Bank A's PDP; accepts a password.
impostor9005Not federated. RFC 9728 metadata names the rogue PDP.
broken9006Federated, but signs with a key the anchor never vouched for.
stray9007No metadata of any kind.
pdp-b9008Bank B's PDP, identifier …:9008/tenants/bank-b. Same product, stricter threshold: steps up payments over 100.
bank-b9009Not federated. RFC 9728 metadata names Bank B's PDP; requires MFA.
pdp-estate9098A 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.
control9099The event feed the console traces, and the levers it pulls. Not part of any spec.
the gateway's own APIpep-federation:9192Not 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.

Then coaz-pep three times: pep-static (:9192, told where its PDP is), pep-resource (:9193, trusts each resource's own well-known), pep-federation (:9194, trusts the federation's word).

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
      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
    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"
    volumes:
      - ./kong/kong.yml:/kong/kong.yml:ro
      - ../gateways/kong/authzen-pdp:/opt/kong/plugins/authzen-pdp:ro
    ports: [ "8000:8000" ]

  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)

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

The demo console's controls: the API picker, the request picker, the token disclosure, Discover, and the levers
The controls. The API behind the gateway, the request, and the levers. The disclosure below Discover holds the token and the route's policy.
The token and route policy disclosure opened: client, acr, scopes, forward, layers
The token and the route's policy. Who the token was issued to, how the customer authenticated, which scopes it carries, whether the route forwards it, and the route's layers. Each maps to a per-route knob on the PEPs: forward_access_token, pdp_layers, and the token's claims.
ControlOptionsWhat it sets
The API behind the gatewayplain, bank-b, impostor, member, broken, stray, the gateway's own API, no resourceThe route's resource, which discovery starts from.
The requestread 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 toagent-1, agent-riskyThe token's client_id. The estate PDP has agent-risky on its watch list.
Authenticated withpassword, MFAThe token's acr.
Scopesread only, read + paymentsThe token's scope.
Forward the raw tokenyes, noThe route's forward_access_token.
Policy layersthe API's PDP alone; the estate PDP first; the same, estate layer fail-openThe route's pdp_layers: nothing, http://stubs:9098,resource, or http://stubs:9098 fail-open,resource.

The chain

The console after discovering the member resource: three columns, the federation column denying on the MFA floor
member, a read-only password token. The static gateway reads nothing and permits. The resource gateway reads the API's own document, which says a password is enough, and permits. The federation gateway resolves the anchor's policy, which strikes the rogue PDP out of the API's claim and sets the floor at MFA, hands the PDP the resolved document, and the same PDP denies. Nothing but PDP_DISCOVERY differs.
The console after discovering bank-b with a password token: the discovering gateways deny for acr
bank-b, a password token. Bank B requires MFA in its own document. The static gateway's PDP was told nothing and permits; both discovering gateways hand their PDP the requirement, and it denies, citing the document it read.

The levers

Four buttons change the world while it runs. None restarts a PEP or edits its configuration; the PEPs pick the change up on their next metadata refresh, and the documents in the chain change under them.

The console after relocating Bank A's PDP: the discovering gateways follow, the static one posts to a stale base
Relocate Bank A's PDP. Its metadata advertises the same AuthZEN endpoints under a new base, /tenants/bank-a-v2. The two gateways that read metadata follow; the static one keeps posting to the base it assumed, marked stale. This is the answer to "why not just set AUTHZEN_URL": because then this is a deploy.
The console with the estate PDP down and the estate layer marked fail-open: permits marked FAILED OPEN
Take the estate PDP down, with the fail-open layers option. The estate layer's PDP answers 503. Because the route marked that layer 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 gateway's own API before onboarding: the entity configuration, and the anchor knowing nothing about it
The gateway's own API, before onboarding. pep-federation publishes a minimal entity configuration; the anchor's fetch endpoint knows nothing about it, so there is no chain and the RFC 9728 document is self-asserted.
The gateway's own API after onboarding: the anchor's word, resolved, republished
After the onboarding lever. The anchor fetched the configuration, checked it was self-signed, and now vouches for the key. The chain resolves, and the gateway's RFC 9728 document has become the anchor's word: Bank A's PDP, the scopes, MFA. Nothing on the PEP changed.

The control API

The stubs' control endpoint at :9099/control, which the console relays as /api/control. GET returns the state; POST {"op": …} pulls a lever.

OpEffect
move_pdpBank 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_plainThe plain resource's metadata names Bank B's PDP instead. No gateway is touched.
estate_down / estate_upThe 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.
resetEverything 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 walk

demo/demo.sh runs the same requests against the three PEPs' HTTP check APIs and prints the decision, the status and the body. Its sections, in order: what the metadata says; where the PDP comes from and what it was told; two banks, one gateway; what the resource requires is the PDP's to enforce, and the federation sets the floor; layers; failing open, deliberately; the gateway as the resource's federation face; the challenge contract; and Kong, when the profile is up. Shape the token with CLIENT, ACR and SCOPE; set FORWARD=no or LAYERS per call.

5. Failing open, deliberately: the estate PDP goes down estate DOWN, layers as before (fail-closed): 503 {"decision":false,"status":503,"body":{"error":"authorization_failed","pep":"demo","reason":"Authorization service unreachable; denying (fail-closed)."}} estate DOWN, estate layer marked fail-open: Bank A decides {"decision":true,"status":null,"body":null,"failed_open":"http://stubs:9098 (PDP returned 503)"} estate back up, same fail-open policy: no marker {"decision":true,"status":null,"body":null} 6. The gateway as the resource's federation face: it holds the key, the anchor holds the policy its RFC 9728 document before onboarding {"authzen_policy_decision_points":["http://stubs:9002/tenants/bank-a"],"resource":"http://pep-federation:9192"} after onboarding (the PEP re-walks its chain when its cache lapses) {"acr_values_required":["urn:idp:loa:mfa"],"authzen_policy_decision_points":["http://stubs:9002/tenants/bank-a"],"resource":"http://pep-federation:9192","scopes_supported":["accounts:read","payments:write"]}

One container, hosted

demo/railway/Dockerfile builds the stubs, the three PEPs and the console into one image, and demo/railway/entrypoint.sh runs them on localhost with the console on $PORT. It is the local runner without the Go toolchain, and it is what runs at demo-production-6ee6.up.railway.app, redeployed on every push to main.

docker build -f demo/railway/Dockerfile -t idp-auth-peps-demo .
docker run --rm -p 8088:8088 idp-auth-peps-demo

On Railway: a service pointed at the repository with the Dockerfile path set to demo/railway/Dockerfile and the healthcheck at /healthz. The levers are shared state, so two people clicking at once will surprise each other, and nothing in it is authenticated: it is a demo of PDP discovery, not a service.