This repository is a runnable reference demo for GBO. It shows how a consumer can request data from a source, normally over OpenFSC with every request evaluated by an OpenFTV policy before the source returns data. The EUDI demo also contains one deliberately unsecured HTTP source to prove that onboarding is not technically coupled to FSC; that source is not a security example.
The unsecured source is mounted only by the local Compose setup and is not part of the deployable source configuration used by simulation environments.
The demo contains two ways to start that request:
- DvTP consent — a citizen grants a consumer permission to request a specific data scope.
- EUDI wallet issuance — a citizen shares a PID from a wallet and receives a source-backed credential.
The production-oriented flows use the same core path:
consumer → FSC Outway → FSC Inway → OpenFTV PDP → source sidecar → GraphQL source
For every mode:
- Docker with the Compose plugin;
- Git, Make, Bash,
curl,jq, OpenSSL and Python 3.9 or newer; - a local
.envcopied from.env.example; - non-empty
FTV_POSTGRES_PASSWORD,FTV_ADL_READER_PASSWORD,EUDI_POSTGRES_PASSWORD,SOURCE_REGISTRY_PASSWORDandSOURCE_REGISTRY_READER_PASSWORDvalues in.env.
The complete EUDI flow additionally requires:
- the pinned nl-wallet v0.5.0 submodule;
- two public HTTPS URLs: one for the issuance server and one for the EUDI adapter;
- a compatible test wallet with a valid
urn:eudi:pid:nl:1credential; - issuer and reader trust anchors already trusted by that wallet.
The test persona that works with all included wallet offers has mock BSN
999991772. The repository configures the issuance side; it does not install
the wallet app or issue its PID.
Clone the repository with its nl-wallet submodule and create the local configuration:
git clone --recurse-submodules https://github.com/ICTU/GBO-demo.git
cd GBO-demo
cp .env.example .envSet the required values in .env:
EUDI_PUBLIC_URL=https://eudi-is.example.org/
EUDI_BRI_URL=https://eudi-bri.example.org/
EUDI_POSTGRES_PASSWORD=<local-random-password>
SOURCE_REGISTRY_PASSWORD=<local-random-writer-password>
SOURCE_REGISTRY_READER_PASSWORD=<local-random-reader-password>Before starting an EUDI demo, provide the issuer and reader CA material that
the test wallet already trusts. The setup deliberately never generates trust
anchors. By default it loads these four files from
.local/secrets/development-ca/:
issuer-ca-key.pem
issuer-ca-cert.pem
reader-ca-key.pem
reader-ca-cert.pem
Keep the private keys outside version control. To load the same managed CA
material from another location, set ONBOARDING_SECRETS_DIR to the absolute
path of that secrets root; both the provisioning command and Compose use it.
Setup fails before onboarding when any required CA file is absent.
Those CA private keys are provisioning input, not application runtime input.
After the issuer, reader and status leaf certificates have been provisioned,
the source reconciler receives only their public certificates and the two
public CA certificates. Leaf private keys are mounted exclusively into the
issuance materializer. A Kubernetes runtime Secret must not contain
issuer-ca-key.pem or reader-ca-key.pem.
EUDI_PUBLIC_URL must route to the issuance server and its hostname must be in
the reader-certificate DNS SAN. EUDI_BRI_URL must route to the EUDI adapter.
See source onboarding for the local certificate
and activation model.
With an existing reverse proxy or externally managed tunnel, start everything with:
make demo-fullTo use the bundled Cloudflare connector, also set
CLOUDFLARE_TUNNEL_TOKEN and run:
COMPOSE_FILE=docker-compose.yml:docker-compose.cloudflare-tunnel.yml make demo-fullConfigure these Cloudflare Public Hostnames:
| Public hostname | Tunnel service |
|---|---|
Host from EUDI_PUBLIC_URL |
http://eudi-issuance-server:18001 |
Host from EUDI_BRI_URL |
http://eudi-adapter:4009 |
Add a final hostname-wide Bypass cache rule for EUDI_PUBLIC_URL and purge
that hostname once. Issuer metadata and QR sessions must not be cached.
The first EUDI build can take several minutes. make demo-full performs the
complete idempotent bootstrap: FSC setup, contracts, source onboarding,
development certificates, EUDI configuration and application startup.
Open the demo at:
| Entry point | URL |
|---|---|
| Demo landing page | http://localhost:9000 |
| Consumer mock | http://localhost:9001 |
| Consent portal | http://localhost:9002 |
| Developer portal | http://localhost:9003 |
| Command | Starts |
|---|---|
make demo-minimal |
Core services and observability only |
make demo |
DvTP consent flow over OpenFSC |
make demo-eudi |
EUDI wallet flow over OpenFSC |
make demo-full |
DvTP and EUDI flows together |
make demo-manager |
Core services with OpenFTV Manager policy distribution |
make demo-down |
Stops the application and FSC stacks |
make demo-manager needs the additional variables documented in
its component README. Prefer the
demo-* targets over starting Compose directly; they create certificates,
contracts and generated configuration in the required order.
- Open the consent portal and sign in with a mock
citizen from
services/graphql-server/mockdata/citizens.json. - Grant a consumer consent for a scope such as
bd:ib:2025. - The portal returns a signed consent token to the consumer mock, which immediately runs the query.
- Open the developer portal to inspect the policy decision, trace and FSC transaction.
- Revoke the consent and repeat the query. The PDP now denies it with
CONSENT_WITHDRAWN.
The developer portal injects DVTP_CONSUMER_PEER_ID into the
dienstverlener_oin field of its predefined DvTP issuance scenarios. It uses
99999999900000000300 for local development when the variable is unset.
Deployments must configure the FSC Peer ID of their actual consumer. Custom
issuance payloads and user-saved scenarios keep the value entered by the user.
See the DvTP consent architecture and sequence diagrams for the component boundaries and the grant, use and revocation flows.
S01 is the architecture role identifier used in the demo diagrams for the
consent-register; it is not a separate service. The demo consent token is a
bearer JWT. The consent-register generates an ephemeral P-256 key
when CONSENT_SIGNING_KEY_PATH is empty, matching its default in-memory
consent store. A persistent deployment must provide a stable PKCS#8 or SEC1
P-256 private key and set an explicit CONSENT_SIGNING_KEY_ID. The demo JWKS
exposes only the current key; production rotation must retain old public keys
until their tokens expire. Production hardening should also shorten the token
lifetime and bind proof of possession
to the FSC/mTLS identity (for example with a confirmation claim); the current
explicit dienstverlener_oin check prevents cross-consumer use but does not
make a stolen bearer token non-replayable by that same consumer.
Because the token returns through the browser, the consent portal only accepts
http or https return URLs whose exact origin occurs in
VITE_ALLOWED_RETURN_ORIGINS (comma-separated). Compose derives the demo value
from DIENSTVERLENER_PUBLIC_URL; production images must supply the same value
as a build argument.
- Start
make demo-fullormake demo-eudiand open the landing page. - Select income 2024, income 2025, the BRP/RvIG death certificate or the explicitly labelled unsecured demo credential.
- Scan the newly generated QR code with the compatible test wallet.
- Share the PID for mock BSN
999991772, review the credential preview and accept issuance. - Inspect the resulting policy decision and trace in the developer portal.
QR sessions are stateful. Generate and scan a new QR after restarting the issuance server or changing activated source metadata.
The DvTP and FSC-backed EUDI entrypoints share transport, policy evaluation, identifier resolution and source access. Belastingdienst and BRP/RvIG are separate logical sources with their own metadata, data services, policies and certificate sets; this demo publishes both through one FSC participant. The EUDI adapter contains no hard-coded source or offer catalog: active source metadata generates the issuance-server products and the frontend offer list.
The demo combines production-grade components with deliberate test doubles:
| Area | In this repository |
|---|---|
| Transport | OpenFSC managers, controllers, Inways and Outways |
| Authorization | OpenFTV v0.1.0 PDP with OPA/Rego policies |
| Source access | Go GraphQL services behind source-side sidecars |
| Observability | OpenTelemetry, Jaeger, Loki and Grafana |
| Processing logs | ldv-logboek per Verantwoordelijke — Belastingdienst, RvIG and GBO (LDV v1.0.0), alongside the PDP's ADL and FSC-Logging |
| Identity and data | Synthetic citizens, deterministic BSNk and mock DigiD |
| Consent | In-memory demo register |
This is a reference architecture, not a production-ready deployment. In the EUDI demo, the disclosed subject is not yet independently cryptographically bound to the selected source record before policy evaluation. See SECURITY.md for the security boundary and known limitations.
A denied request carries a policy reason code, and most of those codes are not
the citizen's to see. ACTOR_NOT_ALLOWED or CONSTRAINT_MISMATCH tells them
nothing they can act on and describes how the policy is built.
The disclosure decision therefore sits in one place, on the server:
services/dienstverlener-backend/consumer/denial.go maps the upstream reason onto a
denial_code. Only CONSENT_WITHDRAWN and CONSENT_EXPIRED pass through —
both describe a consent the citizen gave and can give again. Everything else,
including an unrecognised code and every transport failure, becomes
UNAVAILABLE.
The consumer frontend renders that code and nothing else
(dienstverlener-mock/src/lib/denialMessage.ts). It never reads reason,
which stays technical text for logs and the developer portal. A code the
frontend does not know falls through to the generic message rather than being
shown, so a code added to the policy later cannot surface on a citizen's
screen by default.
The citizen screen shows the trace-id, which is something to quote to a helpdesk, and not the reason code, which is operator detail. The developer portal is where the full decision, its reason and the policy path are shown.
One caveat while running this demo: the FSC Inway drops the PDP's reason before it reaches the consumer (OpenFSC #308), so until that fix lands upstream every denial degrades to the generic message. The backend handles both cases; the specific messages appear once the Inway forwards the reason.
CI runs Go linting and tests, Rego validation and tests, frontend type checks, Helm validation and Docker builds. For a focused local change, run the same check in the affected component, for example:
(cd services/eudi-adapter && go test -timeout 60s ./...)
make policy-test # opa check, format diff and Rego unit tests, same image as CISee CONTRIBUTING.md for the pull-request checklist.
The PDP in the default stack loads policies/ from disk and hot-reloads on
save. A module that does not compile is not rejected loudly: OpenFTV still
logs policy added/replaced, stays healthy and keeps answering with the
previous rules (#151). A rule
whose change has no effect is therefore often a rule that never compiled. Run
make policy-checkafter every edit. It reports a syntax error with file and line in a second or
two, without the stack running. Every make target that starts the PDP runs
it first.
Start by checking container state and logs:
docker compose ps
docker compose logs --tail=200Common fixes:
- use
make demo-fullor the matchingdemo-*target instead of starting individual containers; - after changing source metadata, rerun
make onboard-demo-sources; the adapter observes the promoted release live. Runmake eudi-configto rematerialize and restart the issuance-server; - scan a fresh QR after a restart or metadata change;
- if Cloudflare reports
HITor an increasingAgefor issuer metadata, correct the cache-bypass rule and purge the issuance hostname; - use
make demo-downto stop both stacks, ormake fsc-cleanonly when you intentionally want to remove disposable local FSC state.
The detailed diagnosis for port conflicts, certificates, FSC contracts, source activation, wallet trust and cached QR sessions is in TROUBLESHOOTING.md.
- DvTP consent architecture and flow
- Source configuration and onboarding
- Source metadata cache and Type Metadata
gbo-simple-v1mapping profile- Logboek Dataverwerkingen
- Following an LDV chain across logbooks
- Observability
- Security
- Contributing
- Changelog
GBO is maintained by the Dutch Ministry of the Interior and Kingdom Relations (BZK), Digital Government Directorate, with ICTU as technical steward.
The repository owner is Jeroen de Kok (ICTU), reachable at
jeroen.dekok@ictu.nl. See publiccode.yml for additional repository
metadata.