A live, runnable reference architecture for GBO (Gemeenschappelijke Bronontsluiting). It shows how a consumer (data requester) can obtain data from a source-holder over a trusted transport channel, where every request is authorized against machine-readable policy, and where the source retains control over identifier resolution.
Two access flows sit side-by-side on the same authorization pipeline:
- Consent flow — a citizen grants a consumer permission to query a specific scope of source data. The consumer uses a consent-id to trigger the query; the source resolves it against the citizen's real identifier (BSN) inside its own trust boundary.
- Wallet flow — a citizen holds an EUDI-wallet credential and discloses it to a consumer, who then requests source data using the disclosed identifier. Same policy engine, same transport, different front door.
Both flows share one authorization pipeline: FSC-Inway (transport) → PDP (context handler) → OPA (policy engine) → source-side sidecar (identifier substitution) → source.
- Docker with Compose plugin (Docker Desktop 4.x or Docker Engine +
docker compose). - ~8 GB RAM allocated to Docker (Preferences → Resources), ~10 GB disk for images.
Every mode also needs two Postgres passwords set via env-files (compose fails loud if unset — any string works for the local demo network):
EUDI_POSTGRES_PASSWORDin.env— password forpostgres-eudi(issuance-server + migrations).FSC_POSTGRES_PASSWORDinfsc-infra/.env— shared password for all FSC-infra database users (three orgs × controller/manager/txlog + directory).
That covers the default (make demo, DvTP-only). For the wallet flow (make demo-eudi / make demo-full) you also need:
- nl-wallet sources — pinned as a git submodule at
vendor/nl-wallet(v0.4.1, required: the preprod wallet app rejects v0.5.0's scheme-prefixedclient_id). Init once withgit submodule update --init vendor/nl-wallet. Used to build the issuance-server binary from source. Override withNLWALLET_PATHin.envif you need another checkout. - Three public HTTPS URLs —
EUDI_PUBLIC_URL(wallet reaches issuance-server),EUDI_READER_ORIGIN_URL(published asrequestOriginBaseUrlinreader_auth.json; usually the same host asEUDI_PUBLIC_URL), andEUDI_BRI_URL(issuance-server reaches eudi-adapter). See EUDI public reachability for the three supported options (own domain / bundled Cloudflare tunnel / ad-hoc tunnel). - Six EUDI crypto slots in
.env—EUDI_READER_KEY/CERT,EUDI_ISSUER_KEY/CERT,EUDI_STATUS_KEY/CERT.make eudi-config(auto-run bymake demo-eudi) rendersservices/eudi-issuance-server/config/{issuance_server.toml,reader_auth.json,...}from their.exampletemplates viaenvsubst. The.examplefiles contain public trust-anchors and URL placeholders; the 6 private keys/certs are not in the public repo — request them out-of-band from the maintainer for a working wallet-QR flow. Requiresenvsubst(brew install gettexton macOS).
Copy the templates and fill them in:
cp .env.example .env
cp fsc-infra/.env.example fsc-infra/.env
# then edit bothcd 05-demo
make demo # DvTP (consent) flow only — no wallet, no public URLs neededmake demo-minimal # Base only (~30s, ~13 services)
# Curl directly at pdp-service /evaluation for policy tests.
make demo-eudi # Wallet flow only (~5-10 min first boot; PKI + FSC-infra + contract seed)
# Requires the vendor/nl-wallet submodule + two public HTTPS URLs
# — see "EUDI public reachability" below.
make demo-full # Both flows on
make demo-down # Bring everything downFour front-ends run in parallel (in default/full mode):
- Landing page (
http://localhost:9000) — the demo's public front door: what GBO is, the three flows, and links into the other front-ends. Static; no backend of its own. - Consumer mock (
http://localhost:9001) — a stand-in for a data-consuming party (e.g. a mortgage lender). Talks todienstverlener-backend. - Consent portal (
http://localhost:9002) — a citizen-facing UI to grant and revoke consent. Talks toconsent-portal-backend. - Developer portal (
http://localhost:9003) — architect inspection UI: live trace view + policy inspection + FSC txlog per hop.
The developer portal also runs in demo-minimal and demo-eudi — flow tabs stay empty until the matching backend services are up.
The source exposes GraphQL introspection and serves a playground on
http://localhost:9400/playground, with two tabs:
- Query — GraphiQL 5 with the explorer plugin: tick fields in the left-hand
pane to assemble a query, run it against the baked mock data, browse the
types in Docs. Opens on a runnable demo query;
?query=<urlencoded>prefills a different one. - Schema — GraphQL Voyager,
which draws the schema as a type graph (
Query→IngeschrevenPersoon→BelastingjaarAangifte→AangifteIH→Bedrag).
The BRON block in the developer portal links to the Query tab. Opening
/graphql in a browser redirects to /playground.
The playground talks straight to the source, so the request bypasses FSC-Inway, the source sidecar, the PEP and the PDP: no consent is checked and no BSN is pseudonymised. It explores the source profile schema; it does not demonstrate the authorization chain — use the flow tabs for that. The page says so in its header bar.
Machine paths are unaffected: POST /graphql and GET /graphql?raw keep
returning GraphQL results rather than being redirected. Loading the playground
needs internet access — its assets come from esm.sh and jsdelivr, pinned by
exact version and checked with SRI hashes (see the comment at the top of
services/graphql-server/playground.html before bumping them).
- Open the consent portal (
:9002) and log in as a citizen (mock DigiD, BSN fromgraphql-server/mockdata/citizens.json). - Grant consent for a scope (e.g.
bd:ib:2025) to a consumer. - Open the consumer mock (
:9001), enter the consent-id, click "Run query" — the consumer queries income data via HV-Outway → BD-Inway → AuthZen call to PDP → OPA → source sidecar (PI→BSN) → GraphQL. - Open the developer portal (
:9003) → Use tab → click "Watch" → the live arch strip lights up hop by hop. - Revoke consent from the portal and repeat the query — OPA denies with
CONSENT_WITHDRAWN.
OPA watches the policies/ directory. Edit any Rego file and save — OPA reloads automatically.
# Example: force OPA to deny everything
echo 'package dvtp.authz
import rego.v1
default allow := false
default reason := "policy_override"' > policies/dvtp/authz.rego
# Run a query — the deny surfaces in the consumer UI and the developer portal.
# Restore the original policy
git checkout policies/dvtp/authz.regoClick "Revoke consent" in the consent portal (:9002), repeat the query. OPA reads the consent register, sees status=REVOKED → DENY.
docker compose logs -f opa| Service | Port | Description | Real/Mock |
|---|---|---|---|
| Landing page | 9000 | Public entry point (React/Vite) | Demo frontend |
| Consumer mock | 9001 | Consumer UI (React/Vite) | Demo frontend |
| Consent portal | 9002 | Citizen UI (React/Vite) | Demo frontend |
| Developer portal | 9003 | Architect inspection (React/Vite) | Demo frontend |
| dev-portal-backend | 9407 | Trace hub + explain endpoint | Real (Go) |
| GraphQL Server | 9400 | Sample source with income data; playground on /playground |
Real (Go) |
| pdp-service | 9408 | AuthZen endpoint behind FSC-Inway (P3 context handler) | Real (Go) |
| bron-sidecar | 9411 | Source-side gateway; PI→BSN via BSNk (subject_id_type-driven) | Real (Go) |
| additional-claims-service | 9412 | Provider policy that enriches OpenFSC access tokens | Demo configuration (Go) |
| Consent Register | 9402 | Consent store (PIP) | Mock (Go, in-memory) |
| BSNk Mock | 9403 | Pseudonymization service | Mock (Go, deterministic) |
| HV-Manager UI | 8096 | Consumer-org FSC-Controller (mortgage-lender demo org) | Real (OpenFSC v2.4.0) |
| EDI-Manager UI | 8094 | Consumer-org FSC-Controller (EUDI issuer) | Real (OpenFSC v2.4.0) |
| BD-Manager UI | 8092 | Provider-org FSC-Controller (source-holder demo org) | Real (OpenFSC v2.4.0) |
| OPA | 9181 | Policy Decision Point (Rego) | Real |
| Jaeger | 9686 | Distributed tracing UI | Real |
| OTel Collector | 9317 | Trace collection | Real |
| Component | Status | Notes |
|---|---|---|
| OPA / Rego policies | Real | Production OPA container with real Rego evaluation |
| OpenTelemetry + Jaeger | Real | Production-grade distributed tracing |
| GraphQL Server | Real | Real Go GraphQL implementation |
| FSC (Manager/Inway/Outway/Controller/txlog) | Real | OpenFSC v2.4.0 upstream containers, three orgs (consumer, EUDI-issuer, provider) each with their own PostgreSQL + certs |
| pdp-service | Real | AuthZen endpoint behind FSC-Inway; the only policy endpoint for both flows |
| bron-sidecar | Real | Source-side gateway; PI→BSN driven by the signed subject_id_type additional claim |
| additional-claims-service | Demo | GitOps-style provider policy; production should resolve claims from the authoritative onboarding or authorization source |
| Consent Register | Mock | In-memory; production would be a persistent store |
| BSNk Mock | Mock | Deterministic SHA-256; real BSNk uses ElGamal on elliptic curves |
The five-factor authorization model demonstrated:
| # | Factor | Implementation in demo |
|---|---|---|
| ① | Org identity (mTLS) | FSC-Manager validates peer-certs; FSC-Inway includes peer_cert_chain in the AuthZen context |
| ② | Org permission (JWT) | Provider FSC-Manager validates the grant and signs add.{flow, subject_id_type} returned by its Additional Claims API |
| ③ | Access basis (consent) | pdp-service fetches consent via GET /consents?pi=<pi>&scope=... on consent-register |
| ④ | Data scope (GraphQL) | OPA checks requested fields against the dienstencatalogus (rules DVT0001/EUD0001) |
| ⑤ | Request validity | OPA validates pip.consent + resource.pi binding + expiry |
make up # Build and start all services
make down # Stop all services
make logs # Tail all service logs
make clean # Stop, remove volumes and images
make certs # Generate self-signed TLS certificatesReal OpenFSC transport with self-hosted root-CA + directory-peer. Fully standalone from the main stack.
make fsc-ca # Generate root-CA + intermediate-CA in fsc-infra/pki/ca/ (idempotent)
make fsc-up # Start cfssl + certportal (implies make fsc-ca)
make fsc-test # Verify: test-CSR → certportal → chain-check
make fsc-down # Stop the fsc-infra containers
make fsc-clean # Wipe everything: containers, images, CA materialThree FSC orgs run alongside the main stack:
- Consumer-org (mortgage lender) — consent-flow consumer; provider claims resolve to
flow=dvtp:query,subject_id_type=pseudonym - EDI-Issuer — wallet-flow consumer; provider claims resolve to
flow=eudi:attestation,subject_id_type=direct - Provider (source-holder) — provides the
briservice; endpoint routes through the bron-sidecar
make demo orchestrates the full sequence automatically:
- PKI generation (root-CA + per-org certs)
- FSC-infra start (three orgs + directory-peer)
- Contract seed (bri-service + publication + two connection contracts + grant-links)
- Main stack with dienstverlener-backend, eudi-adapter, pdp-service, bron-sidecar
Step-by-step targets are available for debugging:
-
make fsc-all-up— FSC-infra + orgs. The directory-manager runs with--auto-sign-grants=servicePublication; the provider-manager runs with--auto-sign-grants=serviceConnection. Contracts reachCONTRACT_STATE_VALIDwithout manual review. -
make fsc-seed-bri+bash fsc-infra/scripts/seed-bri-connection-hv.sh— services + contracts + grant-links. Registers thebriservice in the provider-Controller (endpoint = bron-sidecar), posts publication + two connection contracts, and upserts the grant-links per consumer. The provider Manager obtains flow and identifier semantics fromadditional-claims-servicewhen issuing an access token. Idempotent.Grant-link upsert goes via direct SQL — v2.4.0 has no REST endpoint for grant-link CRUD.
-
Generate pdp-service TLS cert + restart:
bash fsc-infra/pki/generate-pdp-cert.sh docker compose up -d --force-recreate dienstverlener-backend eudi-adapter pdp-service graphql-server bron-sidecar docker compose -f fsc-infra/docker-compose.yml up -d --force-recreate bd-inway
generate-pdp-cert.shproduces a self-signed cert (SAN=pdp-service) — FSC-Inway's AuthZen plugin requires HTTPS+CA. The same.pemis mounted by the provider-inway asAUTHZEN_ROOT_CA.
Reset:
make fsc-down
docker volume rm fsc-infra_postgres-data # wipe all contracts/publicationsWithout a volume wipe, contracts and grant-links survive a restart.
The EUDI flow needs two publicly-reachable HTTPS URLs:
EUDI_PUBLIC_URL— the wallet on a phone opens this to talk to theissuance-server.EUDI_BRI_URL— theissuance-serverfetches attestations from theeudi-adapterat this URL.
Both values are read from .env. Pick whichever way to expose the two services fits your setup:
The developer-portal container writes EUDI_PUBLIC_URL to
/runtime-config.js when it starts. This allows Kubernetes and other runtime
environments to configure wallet QR links without rebuilding the frontend.
For backwards compatibility, the container also accepts
VITE_EUDI_PUBLIC_URL.
(a) Own domain / reverse proxy — point two HTTPS hostnames at the compose ports and set the URLs. Nothing else to install.
(b) Cloudflare named tunnel (bundled) — one Cloudflare tunnel with two Public Hostnames configured in the dashboard, plus the connector token in .env:
# In .env
CLOUDFLARE_TUNNEL_TOKEN=eyJ...
EUDI_PUBLIC_URL=https://eudi-is.your-cf-hostname.tld/
EUDI_BRI_URL=https://eudi-bri.your-cf-hostname.tld/
# Start the tunnel alongside the EUDI stack
docker compose -f docker-compose.yml -f docker-compose.cloudflare-tunnel.yml --profile eudi up -d(c) Ad-hoc tunnel (ngrok, cloudflared --url, tailscale funnel, …) — start it yourself, paste the two URLs into .env, then bring up the stack without the tunnel file.
# Go happy-path integration tests (per service)
for svc in additional-claims-service bron-sidecar bsnk-mock consent-portal-backend consent-register \
dev-portal-backend dienstverlener-backend eudi-adapter \
graphql-server pdp-service sector-pip; do
(cd services/$svc && go test -timeout 60s ./...)
done
# OPA policy unit tests
docker compose exec opa opa test /policies -vCI (.github/workflows/ci.yml) runs both on every PR.
Pushing a SemVer tag with a v prefix starts .github/workflows/release.yml.
The workflow builds 13 application images for linux/amd64 and linux/arm64
and builds eudi-issuance-server for linux/amd64. It publishes them to GHCR
with a tag without the v prefix plus latest, and then publishes the generic
gbo-app Helm chart with the same normalized version.
The Compose development flow is unchanged.
The eudi-issuance-server build uses the pinned vendor/nl-wallet submodule.
Clone or update this repository with submodules enabled before building it
locally:
git submodule update --init --recursiveCreate a release after the release changes are on main:
git tag v0.1.0
git push origin v0.1.0The resulting image and chart versions are 0.1.0:
docker pull ghcr.io/ictu/gbo-demo/bsnk-mock:0.1.0
helm pull oci://ghcr.io/ictu/gbo-demo-charts/gbo-app --version 0.1.0GHCR packages are private by default. After the first publication, set the 14
image packages and gbo-demo-charts/gbo-app to Public in the organization
package settings before testing anonymous pulls from tenant clusters.
For a local chart smoke test:
helm lint deploy/helm/gbo-app
helm template bsnk-mock deploy/helm/gbo-app \
--set image.repository=ghcr.io/ictu/gbo-demo/bsnk-mock \
--set image.tag=0.1.0 \
--set containerPort=4003 \
--set healthPath=/healthThe chart also supports overriding the container entrypoint, passing arguments, mounting native Kubernetes volumes, and selecting the health probe scheme. The example values files cover an OPA policy ConfigMap and a TLS-enabled PDP service:
helm template opa deploy/helm/gbo-app \
--values deploy/helm/gbo-app/examples/opa-values.yaml
helm template pdp-service deploy/helm/gbo-app \
--values deploy/helm/gbo-app/examples/pdp-service-values.yamlServices not starting?
docker compose logs <service-name>OPA returning unexpected results?
# Check OPA decision logs
docker compose logs opa | grep "decision"
# Test OPA directly
curl -X POST http://localhost:9181/v1/data/dvtp/authz -d '{"input": {...}}'Frontend not loading?
- Check the four frontends (
landing-page:9000,dienstverlener-mock:9001,toestemmingsportaal-frontend:9002,developer-portal:9003) and their backends. docker compose logs <service>for the container in question.
The architecture is designed for incremental extension. Every new flow shares the same FSC-Inway → pdp-service (AuthZen) → OPA → bron-sidecar → GraphQL chain. Flow-specific context is provider-owned: OpenFSC asks additional-claims-service during token issuance and signs the returned values into the access token's add claim.
The checked-in mapping is deliberately small demo policy, not a second production contract register. A production deployment should resolve these claims from the same authoritative onboarding or authorization source that governs the relationship.
- Legal-basis (gov-to-gov): add
policies/legal-basis/*.rego, add a new FSC consumer org and configure provider claims withflow=g2g:legal-basis; the PDP dispatches on the signed token claim. - Wallet flow (already implemented): see
eudi-adapter. - AS4 / SDG-OOTS: add an AS4 bridge mock + Domibus mock.
Owner: Jeroen de Kok — jeroen.dekok@ictu.nl
Please open an issue for questions or feature requests. For security issues see SECURITY.md.