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 .envEvery mode needs these five values in .env. Any non-empty random value works
on the local demo network, for example from openssl rand -hex 16; use a
different value for each:
FTV_POSTGRES_PASSWORD=<local-random-password>
FTV_ADL_READER_PASSWORD=<local-random-reader-password>
EUDI_POSTGRES_PASSWORD=<local-random-password>
SOURCE_REGISTRY_PASSWORD=<local-random-writer-password>
SOURCE_REGISTRY_READER_PASSWORD=<local-random-reader-password>Start the DvTP demo. It needs no public URLs, trust anchors or wallet:
make demoThe first run builds every image and takes a few minutes. 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 |
| Jaeger (traces) | http://localhost:9686 |
Then follow the DvTP consent walkthrough.
make demo-full adds the EUDI wallet flow to DvTP. It additionally needs the
public URLs in .env:
EUDI_PUBLIC_URL=https://eudi-is.example.org/
EUDI_BRI_URL=https://eudi-bri.example.org/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.
| 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.
The flow starts at the consumer, not at the consent portal: the portal only shows consents that already exist.
- Open the consumer mock and click
Inloggen met DigiD. The consumer sends you to the consent portal with
its request: service, purpose and the scopes
bd:ib:2025andbd:ib:2024. - Pick any sign-in method. The DigiD mock takes any username and a mock BSN
as the password, for example
123456789; the mock citizens are inservices/graphql-server/mockdata/citizens.json. - Choose the data sets to share and click Geef toestemming. The portal returns a signed consent token to the consumer mock, which immediately runs the query and shows the income data.
- Open the developer portal. History · Replay lists the issuance and the query with their outcome and trace id.
- In another tab, open the consent portal, sign in
as the same citizen, open the consent and click Toestemming intrekken.
Back in the consumer mock, expand Inkomensgegevens and click
↻ Verversen to repeat the query with the same token. The PDP now denies
it. The consumer is told only
FIELD_NOT_PERMITTED, so the consumer mock offers to consent again without naming a cause. The developer portal's history lists the request asDENY; the field's own reason,CONSENT_WITHDRAWN, is in the PDP decision log (docker compose logs openftv-pdp).
Starting again from the consumer mock's home page begins a new consent; it does not repeat the query, because the token only lives in that result page.
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.
A service provider can also query through an integrator: a processor with an
FSC peer of its own that connects on the provider's behalf over a delegated
connection. The consent then binds the provider, the FSC delegator, and the
integrator needs a mandate in the DvTP onboarding register. The demo uses the
direct path by default; make demo-integrator adds the integrator to a running
demo. See docs/integrator.md.
A qualified trust service provider (QTSP) can verify attributes of a citizen
against the BRP through the ASIP mock of the ETSI TS 119 478 route
(services/asi-provider, services/asi-authorization-server). The ASIP is an
FSC consumer peer of its own (...1200). make demo-asi adds it to a running
demo-eudi or demo-full; a stub of the Discover Interface
(services/asi-discovery-stub) finds its data services per attribute, and a
reference QTSP (services/qtsp-reference, http://localhost:9427) is where a
user starts. The developer portal's ASI tab lays a request down at that QTSP,
with scenarios for each verification result, and shows the outcome next to
the chain. After a successful result, the QTSP offers a clearly marked,
non-qualified test statement to the same citizen's NL Wallet. The existing
central issuance server uses a separate QTSP usecase and certificate set and
fetches the pending statement over internal HTTPS. See
services/asi-provider/README.md, and
docs/asi-route.md for what in this route is real,
stubbed or a GBO interpretation.
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 the code the policy gives the consumer, following the FTV GraphQL profile: it says that the request was refused, never why or which field. The reason per field stays with the PDP and the developer portal.
The disclosure decision sits in one place, on the server:
services/dienstverlener-backend/consumer/denial.go maps the upstream reason onto a
denial_code. Only FIELD_NOT_PERMITTED passes through — a refused field is
the one denial the citizen can act on, by giving consent again. Everything
else, including ACCESS_DENIED, 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
- An integrator acting for a service provider
- Source configuration and onboarding
- Source metadata cache and Type Metadata
gbo-simple-v1mapping profile- How GBO implements the FTV GraphQL 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.