Design · docs/dh-notes.md
Data Holders and Register - build notes
The mock CDR Register and the three Data Holders (Westpac, NAB, ANZ) are authentic Ping CDR Kit participants, built from the vendored kit at vendor/pingidentity-cdr-sandbox (a pinned copy of github.com/pingidentity/pingidentity-cdr-sandbox). Nothing from idp-openbanking-demo is used, and no kit function is hand-rolled.
The earlier Node build (a Node CDR kit inside PF, a Node consent app, dh-banking-api and a Node Register) is superseded. It now lives in archive/non-kit/:
archive/non-kit/participants/register/archive/non-kit/participants/dh-{westpac,nab,anz}/(oldapi/,consent/,pingfederate/, compose, smoke)archive/non-kit/packages/{dh-pingfederate-base,dh-banking-api}/
Topology
participants/register (host :8084, Docker network `cdr`, alias cdrregister)
kit test harness: SSAs, Register JWKS, DH brands, ADR status, CDR PKI (CA, /public/sign)
▲ (DHs reach it as cdrregister:8084 -> host-gateway)
│
browser / ADR ──mTLS──► PingAccess 8.3.2 ── https://{sso,api,consent}.<brand>.localhost:944x
│ kit rules: MTLS-RequireClientAuth, HoK thumbprint, CDR Validate Status,
│ CDR Refresh Token Processor, DCR rewrite /register -> /as/clients.oauth2
├──► PingFederate 13.0.0 (:9032 mutual TLS) pf-cdr-au-modules: CDRAUPlugin DCR policy,
│ CDRAdapterSelector, cdrPolicyFragment, consent grant storage, arrangement revocation
│ └─ identifier-first (mobile) -> OTP form (LDAP PCV) -> agentless consent adapter
├──► agentless-consentapp (kit image) ── account list from mock-dh-apis, drop-off to PF
└──► PingAuthorize 10.3.0.4 (kit "PingDataGovernance" gateway, embedded policy)
└─ X-USER / X-ACCOUNTS from the token + consent -> mock-dh-apis (kit image)
PingDirectory 10.3.0.4: customers (ou=people), OAuth clients, persistent grants, Consent API, Register cache
PingDataSync 10.3.0.4 (pds-cdr-au-modules): Register status -> PD cache (10 s); consent revoked -> notify ADR
datain-configure-pf (kit side-car, one-shot): Register CA into PF/PA, Register-signed PA certs, site mTLSOne stack per bank, each with its own private Docker network, where every container keeps its kit hostname (pingfederate, pingaccess, pingdirectory, pingdatagovernance, agentless-consentapp, mock-dh-api). Only PingAccess publishes a port.
What each component does in a DH
| Component | Image | Kit artefact | Role |
|---|---|---|---|
| PingFederate | pingidentity/pingfederate:13.0.0-edge |
pf-cdr-au-modules-v1.4.11-SNAPSHOT.jar (+ data-in jar, Clickatell jar, unused) |
AS: DCR with SSA (CDRAUPlugin), PAR, request objects, CDR scopes/claims, cdr_arrangement_id, grants in PD, arrangement revocation, CDR discovery template |
| PingAccess | pingidentity/pingaccess:2601-8.3.2 |
pa-cdr-au-modules-v1.2.11-SNAPSHOT.jar |
Only public entry; mTLS; HoK; status checks against the Register cache; proxies PF, the consent app and the APIs |
| PingDirectory | pingidentity/pingdirectory:10.3.0.4-latest |
pd-cdr-au-data-in-modules, schema, kit LDIF |
Users, OAuth clients, grants, Consent API, metadata cache |
| PingDataSync | pingidentity/pingdatasync:10.3.0.4-latest |
pds-cdr-au-modules-1.0.3.jar |
Register status → PD; DH-initiated revocation → ADR |
| PingAuthorize | pingidentity/pingauthorize:10.3.0.4-latest |
kit DeploymentPackage |
API gateway policy: validates the PF token, injects X-USER/X-ACCOUNTS |
| Consent app | tamatping/agentless-consentapp:20231123 |
- | CDR account selection; drop-off to PF agentless adapter |
| Banking API | tamatping/mock-dh-apis:20231123 |
- | CDS banking endpoints from its cache |
| Side-car | tamatping/datain-configure-pf:20231123 |
- | Kit post-config (certs, listeners) |
| Register | tamatping/cdr-register-testharness:20231123_1 |
- | Mock CDR Register + PKI |
Versions: the kit pins pingfederate:13.0.0-edge and pingaccess:2601-8.3.2; both still pull and run on arm64. Its pingdirectory/pingdatasync/pingauthorize:edge tags now point at 11.1, which the kit's PD extensions predate, and Docker Hub no longer serves 10.2, so PD, PDS and PAZ are pinned to 10.3.0.4-latest. All the tamatping/* images are multi-arch.
How a DH participant is built
node deploy/kit/build-dh.mjs <brand|all> copies the kit profiles into participants/dh-<brand>/server-profiles/{pingfederate,pingaccess,pingdirectory,pingdatasync,pingauthorize} and applies a short, listed set of changes (see each server-profiles/KIT-CHANGES.md):
- Ports: the kit's local-dev listener
:6443becomes${DH_PUBLIC_PORT}(9441/9442/9443) in PF and PA, and so does the hard-codedX-Forwarded-Port: 443in two PA Groovy rules. - OTP login. The kit fragment
cdrPolicyFragmentNoMFAis identifier-first → Clickatell SMS → agentless consent. Clickatell's demo mode only accepts123456for the number12345678910. So that step is now a PF HTML Form adapter (cdrOtpFormAdapter, cloned from the kit'shtmlFormAdapter) with an LDAP PCV (cdrOtpPCV, cloned from the kit'sadrUserPCV) that finds the customer byuidormobilein PingDirectory. The demo code000789is the customer's LDAP password. The identifier-first lookup also matchesmobile. All other parts of the fragment are unchanged, and nothing outside PF is involved. - Customers: the kit's
CRN0-5LDIF is replaced with this brand's customers fromdata/customers.json. Each hasuid= DH customer id,mobile,userPassword= the OTP, andentryUUID=nameUUIDFromBytes(customerId). - Branding: the PF identifier-first page is re-skinned, the OTP page is new, and
assets/brand/{brand.css,logo.svg}are added. The consent app usesconsent-app/templates/index.htmlthroughSPRING_THYMELEAF_PREFIX. This template has the same Thymeleaf model as the kit's, but a brand skin and the CX layout: ADR name and accreditation tick, account selection with ineligible accounts greyed out and the reason given, data-language clusters, sharing period, Confirm/Cancel. The kit image is unchanged. - Banking data:
mock-dh-api/cache/pre-seeds the kit image's own cache (/tmp/cache/<model class>/<X-USER>/*.txt), keyed by the customer's entryUUID, fromdata/seed.json.
It also writes docker-compose.yml and env/dh.env (from deploy/kit/templates/), copies the kit's pf.env/pa.env, and adds scripts/smoke.{sh,mjs}.
Data flow for seeds: data/customers.json → node data/generate-seeds.mjs → participants/dh-<brand>/data/seed.json. This includes 12 months of salary credits plus noise, and the joint accounts are ineligible. Then deploy/kit/build-dh.mjs → PD LDIF + mock-dh-apis cache. Don't run the generator while a stack is booting. It rewrites the mounted profiles.
Run
docker network create cdr # once (Register + ADR use it)
docker compose -f participants/register/docker-compose.yml up -d # ~10 s
docker compose -f participants/dh-westpac/docker-compose.yml up -d # ~8-10 min to first ready
participants/dh-westpac/scripts/smoke.sh # Alex, 0412345678 / 000789NAB is the same with dh-nab, port 9442 and Alex's NAB mobile 0498765432. ANZ uses dh-anz, port 9443 and Priya on 0400000002.
You're ready when https://sso.<brand>.localhost:<port>/.well-known/openid-configuration returns 200 and dh-<brand>-kit-configure has exited. Browser: open the ADR flow, or authorize URLs, directly. *.localhost resolves to 127.0.0.1 and the TLS cert comes from the Register CA, so accept the warning once.
Smoke test (scripts/smoke.sh)
The smoke test runs the kit's own Postman sequence (cdr-au.consent.postman_collection.json) and uses the Register test tools to build the ADR-side JWTs. Software product cba-dh-smoke uses the harness helper JWKS.
- Get the SSA from the Register. Get a Register-signed mTLS client cert.
- Discovery (issuer, PAR,
cdr_arrangement_revocation_endpoint). - DCR:
- a forged SSA → 400;
- no client cert → refused;
- a valid SSA → 201
dcr-sso-….
- PAR with a signed request object →
request_uri. - Authorize:
- branded mobile page (with the "never ask for your password" notice);
- a wrong OTP is rejected;
000789→ kit consent app;- account selection (ineligible accounts greyed out) → Confirm → code at
redirect_uri.
- Token (private_key_jwt + PKCE + mTLS) → access, refresh and
cdr_arrangement_id. GET /banking/accounts(only the consented accounts come back) andGET /accounts/{id}/transactions(salary credits present). Without the bound cert → refused.POST /data-holder/arrangements/revoke→ 204. After that, the API refuses the token and refresh is refused.
Verified on 7 Oct 2026. Each stack ran on its own from a clean up, on arm64, and was ready in about 220 s:
| DH | Customer | Result |
|---|---|---|
| Westpac | Alex, 0412345678 | 36/36. 3 accounts selectable, joint account greyed out, 43 salary credits |
| NAB | Alex, 0498765432 | 35/35. 1 account (Reward Saver), no salary at NAB, as in customers.json |
| NAB | Jordan, 0400000003 | 35/35. 76 weekly salary credits |
| ANZ | Priya, 0400000002 | 36/36 |
A re-run against a live DH hits the kit's "one registration per software_id" rule. The smoke test then reuses the kit's deterministic client id (dcr-sso- + nameUUIDFromBytes(software_id)).
Known gaps
Railway (deployed): each DH's PingAccess is behind a Railway TCP proxy (raw TLS, so mTLS and certificate-bound tokens work unchanged).
BASE_HOSTNAMEis a sslip.io name for the proxy IP andDH_PUBLIC_PORTis the proxy port; seedeploy/railway/README.mdandparticipants/dh-<brand>/railway/. Browsers warn on the DH pages because the certificate comes from the Register CA.State is in-container (as in the kit compose):
- Restarting PD loses DCR clients and arrangements, so the ADR must re-register.
- The Register regenerates its SSA signing key on every start. The DHs fetch it live, so this only invalidates SSAs that were already issued.
mock-dh-apis (kit image):
- Transactions are per customer, not per account; filter by
accountId. - Balances are synthesised.
- There's no
common/customer. - Only
isOwned=falsemarks an ineligible account. The seed's reason text is shown generically.
- Transactions are per customer, not per account; filter by
Consent screen: the kit gives the template the SSA
org_name, which the Register sets to the brand id (commbank). The template displays this as "CommBank". The accreditation number isn't in the kit model, so the screen shows "Accredited Data Recipient" without it. Scope titles in the kit's properties aren't CX wording, so the template maps the scopes to CX data-language clusters itself.Shared kit demo keys: each DH uses the kit's
pf.env/pa.envkey material, and the same admin password«redacted»in every stack. This is fine for a local demo. Rotate before anything public.Data-in leftovers: the kit profile is a combined DH + data-in profile. On a DH, its data-in selector polls the Register and logs "no data holders to process". This is harmless and left as the kit ships it.
PingAuthorize Policy Editor (PAP) isn't run. The kit runs PAZ with
PDG_MODE=embeddedfrom itsDeploymentPackage, and PAP is only needed to author policy.Memory: a DH stack is heavy. Measured at steady state:
- PingAccess about 1.0 GB;
- PingAuthorize 0.8 GB (it refuses to set up with a heap under 1 GB);
- PingDirectory 0.78 GB;
- PingFederate 0.65-0.75 GB;
- PingDataSync 0.62 GB;
- consent app 0.18 GB;
- mock API 0.15 GB.
That's about 4.2 GB per DH, plus 0.2 GB for the Register. The compose limits add up to about 7.4 GB, because PD, PDS and PAZ need headroom during setup. Run one DH at a time on a 20 GB Docker host that's shared with other stacks.