From 3588057a751cebe24edf49c8cab133b9d4fca976 Mon Sep 17 00:00:00 2001 From: Edwin van den Houdt Date: Thu, 30 Jul 2026 12:27:21 +0200 Subject: [PATCH] feat(openzaak): real secrets + TLS for the production OpenZaak harness (WP-55) docker-compose.openzaak.prod.yml layers real SECRET_KEY/DB password/site domain/allowed-hosts (all required, fail-fast via ${VAR:?...}) on top of the WP-54 dev harness, switches Postgres off trust auth, and sets IS_HTTPS for a front-facing reverse-proxy TLS setup. The ZGW client secret lives inside a file setup_configuration reads rather than a compose env var, so it's templated (data.prod.yaml.template, no secret) and rendered host-side via render-prod-secrets.sh into a gitignored data.prod.yaml, mounted over the container's dev data.yaml. ZgwOptions.cs already binds from IConfiguration, so the BFF side needed no code change. Co-Authored-By: Claude Sonnet 5 --- .gitignore | 3 ++ backend/openzaak/README.md | 42 +++++++++++++++++++ .../openzaak/docker-compose.openzaak.prod.yml | 37 ++++++++++++++++ backend/openzaak/render-prod-secrets.sh | 16 +++++++ .../data.prod.yaml.template | 28 +++++++++++++ docs/project/backlog/README.md | 8 ++-- .../backlog/WP-55-openzaak-secrets-tls.md | 33 +++++++++++++-- .../WP-63-aanvraag-status-lifecycle.md | 4 +- .../WP-65-behandelportal-beoordeling.md | 2 +- 9 files changed, 162 insertions(+), 11 deletions(-) create mode 100644 backend/openzaak/docker-compose.openzaak.prod.yml create mode 100755 backend/openzaak/render-prod-secrets.sh create mode 100644 backend/openzaak/setup_configuration/data.prod.yaml.template diff --git a/.gitignore b/.gitignore index 0c79ad0..a9b0fde 100644 --- a/.gitignore +++ b/.gitignore @@ -54,3 +54,6 @@ storybook-static # WP-54: bootstrap-catalogus.sh's own record of what it seeded into a local OpenZaak run backend/openzaak/seeded.env + +# WP-55: render-prod-secrets.sh's output — the real client secret, never committed +backend/openzaak/setup_configuration/data.prod.yaml diff --git a/backend/openzaak/README.md b/backend/openzaak/README.md index 2ab446c..f709c7f 100644 --- a/backend/openzaak/README.md +++ b/backend/openzaak/README.md @@ -50,6 +50,40 @@ up, so it never runs where the harness doesn't exist. docker compose -f docker-compose.openzaak.yml down -v ``` +## Production (WP-55) + +This dev harness stays dev-only: hardcoded `SECRET_KEY`, `POSTGRES_HOST_AUTH_METHOD=trust`, +`IS_HTTPS: 'no'`, a client secret checked into `setup_configuration/data.yaml`. A real +deployment layers `docker-compose.openzaak.prod.yml` on top instead of replacing anything: + +```bash +export OPENZAAK_SECRET_KEY=... # Django SECRET_KEY — generate, don't reuse the dev value +export OPENZAAK_DB_PASSWORD=... # postgres password (switches auth off `trust`) +export OPENZAAK_SITE_DOMAIN=... # e.g. open-zaak.example.org — no scheme/port +export OPENZAAK_ALLOWED_HOSTS=... # Django ALLOWED_HOSTS, usually the same domain +export OPENZAAK_CLIENT_ID=... # the BFF's OpenZaak client id (ZgwOptions:ClientId) +export OPENZAAK_CLIENT_SECRET=... # the BFF's JWT signing secret (ZgwOptions:Secret) +export OPENZAAK_APPLICATIE_UUID=$(uuidgen) + +./render-prod-secrets.sh # writes the gitignored setup_configuration/data.prod.yaml +docker compose -f docker-compose.openzaak.yml -f docker-compose.openzaak.prod.yml up -d +``` + +Every one of those env vars is `${VAR:?...}`-checked — compose (and `render-prod-secrets.sh` +for the client secret) refuses to start rather than silently falling back to a dev-looking +default. There is no env var that "means insecure default"; if it's unset, it's a hard error. + +**TLS**: OpenZaak itself does no certificate handling. Put a reverse proxy/ingress (the same +one fronting the BFF) in front of `web`'s `:8000`, terminate TLS there, and forward to +`http://web:8000` over the compose network. `IS_HTTPS: 'yes'` in the prod override only tells +Django it's being served over HTTPS (secure cookies, `SECURE_*` redirects) — it does not open +a TLS listener itself. + +The BFF side needs no code change: `ZgwOptions` already binds `ClientId`/`Secret`/the base +URLs from `IConfiguration`, so pointing it at a production OpenZaak is a config change +(`Zgw:ClientId`/`Zgw:Secret`/`Zgw:ZrcBaseUrl` etc. via env vars or a secrets manager), not an +app change. + ## What's in here / what isn't - `docker-compose.openzaak.yml` — postgres (postgis), redis, a one-shot `web-init` (runs @@ -72,3 +106,11 @@ docker compose -f docker-compose.openzaak.yml down -v `procesType` on the public VNG selectielijst API). - **Not here**: Documenten (DRC) / Notificaties (NRC) content — add if a later WP needs to prove those round-trips against a live instance too (WP-51/52 are fixture-tested today). +- `docker-compose.openzaak.prod.yml` (WP-55) — production overrides layered on top of + `docker-compose.openzaak.yml`: real `SECRET_KEY`/DB password/site domain/allowed-hosts from + required env vars (fails fast if unset), password DB auth instead of `trust`, `IS_HTTPS: 'yes'`. + Adds no image/service of its own — see "Production" above for the full flow. +- `setup_configuration/data.prod.yaml.template` (WP-55) — the prod counterpart of `data.yaml` + with no secret in it (`${OPENZAAK_CLIENT_SECRET}` etc. as placeholders); `render-prod-secrets.sh` + fills it in to the gitignored `data.prod.yaml`, which the prod compose override mounts over + the container's `data.yaml`. diff --git a/backend/openzaak/docker-compose.openzaak.prod.yml b/backend/openzaak/docker-compose.openzaak.prod.yml new file mode 100644 index 0000000..6528c89 --- /dev/null +++ b/backend/openzaak/docker-compose.openzaak.prod.yml @@ -0,0 +1,37 @@ +# WP-55 — production overrides for docker-compose.openzaak.yml: real secrets, real DB auth, +# HTTPS-aware settings. Use ON TOP of the base file, never alone (it has no image/ports of its +# own to add — see backend/openzaak/README.md for the required env vars and full flow): +# +# ./render-prod-secrets.sh # renders setup_configuration/data.prod.yaml (gitignored) +# docker compose -f docker-compose.openzaak.yml -f docker-compose.openzaak.prod.yml up -d +# +# TLS is NOT terminated here — OpenZaak sits behind a reverse proxy/ingress that owns the +# certificate; this file only tells OpenZaak (via IS_HTTPS) that it's being served over HTTPS +# so it sets secure cookies / redirects correctly. +services: + db: + environment: + - POSTGRES_HOST_AUTH_METHOD=md5 + - POSTGRES_PASSWORD=${OPENZAAK_DB_PASSWORD:?OPENZAAK_DB_PASSWORD must be set} + + web-init: + environment: + SECRET_KEY: ${OPENZAAK_SECRET_KEY:?OPENZAAK_SECRET_KEY must be set} + DB_PASSWORD: ${OPENZAAK_DB_PASSWORD:?OPENZAAK_DB_PASSWORD must be set} + IS_HTTPS: 'yes' + SITE_DOMAIN: ${OPENZAAK_SITE_DOMAIN:?OPENZAAK_SITE_DOMAIN must be set} + ALLOWED_HOSTS: ${OPENZAAK_ALLOWED_HOSTS:?OPENZAAK_ALLOWED_HOSTS must be set} + DISABLE_2FA: 'false' + volumes: + # Shadows the dev data.yaml (still mounted read-only from the base file) with the + # secret-free template rendered by render-prod-secrets.sh. + - ./setup_configuration/data.prod.yaml:/app/setup_configuration/data.yaml:ro + + web: + environment: + SECRET_KEY: ${OPENZAAK_SECRET_KEY:?OPENZAAK_SECRET_KEY must be set} + DB_PASSWORD: ${OPENZAAK_DB_PASSWORD:?OPENZAAK_DB_PASSWORD must be set} + IS_HTTPS: 'yes' + SITE_DOMAIN: ${OPENZAAK_SITE_DOMAIN:?OPENZAAK_SITE_DOMAIN must be set} + ALLOWED_HOSTS: ${OPENZAAK_ALLOWED_HOSTS:?OPENZAAK_ALLOWED_HOSTS must be set} + DISABLE_2FA: 'false' diff --git a/backend/openzaak/render-prod-secrets.sh b/backend/openzaak/render-prod-secrets.sh new file mode 100755 index 0000000..cc7bf52 --- /dev/null +++ b/backend/openzaak/render-prod-secrets.sh @@ -0,0 +1,16 @@ +#!/usr/bin/env bash +# WP-55 — renders setup_configuration/data.prod.yaml.template into the gitignored +# data.prod.yaml docker-compose.openzaak.prod.yml mounts over the container's data.yaml. +# Run this once before `docker compose ... up` in a production deploy; re-run whenever the +# secrets rotate. Fails fast (no output file) if a required env var is missing — never +# silently falls back to a real-looking default. +set -euo pipefail +cd "$(dirname "${BASH_SOURCE[0]}")" + +: "${OPENZAAK_SITE_DOMAIN:?OPENZAAK_SITE_DOMAIN must be set (e.g. open-zaak.example.org)}" +: "${OPENZAAK_CLIENT_ID:?OPENZAAK_CLIENT_ID must be set}" +: "${OPENZAAK_CLIENT_SECRET:?OPENZAAK_CLIENT_SECRET must be set}" +: "${OPENZAAK_APPLICATIE_UUID:?OPENZAAK_APPLICATIE_UUID must be set (a fresh UUID, e.g. \$(uuidgen))}" + +envsubst < setup_configuration/data.prod.yaml.template > setup_configuration/data.prod.yaml +echo "Wrote setup_configuration/data.prod.yaml" diff --git a/backend/openzaak/setup_configuration/data.prod.yaml.template b/backend/openzaak/setup_configuration/data.prod.yaml.template new file mode 100644 index 0000000..ec553f9 --- /dev/null +++ b/backend/openzaak/setup_configuration/data.prod.yaml.template @@ -0,0 +1,28 @@ +# Prod counterpart of data.yaml (WP-54's dev-only version, kept as-is for local iteration — +# see docker-compose.openzaak.yml's own comment on why it hardcodes a client secret). This +# template has no secret in it; render-prod-secrets.sh substitutes OPENZAAK_CLIENT_SECRET +# into it to produce the gitignored data.prod.yaml that docker-compose.openzaak.prod.yml +# mounts over the container's data.yaml. +# +# Least-privilege client scopes (heeft_alle_autorisaties: true below) are WP-57's job, not +# this WP's — left matching the dev harness on purpose. +sites_config_enable: true +sites_config: + items: + - domain: ${OPENZAAK_SITE_DOMAIN} + name: OpenZaak (production) + +vng_api_common_credentials_config_enable: true +vng_api_common_credentials: + items: + - identifier: ${OPENZAAK_CLIENT_ID} + secret: ${OPENZAAK_CLIENT_SECRET} + +vng_api_common_applicaties_config_enable: true +vng_api_common_applicaties: + items: + - uuid: ${OPENZAAK_APPLICATIE_UUID} + client_ids: + - ${OPENZAAK_CLIENT_ID} + label: BIG-register BFF (production) + heeft_alle_autorisaties: true diff --git a/docs/project/backlog/README.md b/docs/project/backlog/README.md index 9865d4c..aa65145 100644 --- a/docs/project/backlog/README.md +++ b/docs/project/backlog/README.md @@ -105,15 +105,15 @@ for its existing violations, so every WP ends green. | [WP-52](WP-52-openzaak-notificaties.md) | OpenZaak Notificaties (NRC) live status via webhook | 9 · OpenZaak/ZGW | done | | [WP-53](WP-53-inbound-identity-and-citizen-scoping.md) | Inbound identity seam + citizen-scoping (per-request BSN, ZGW audit claims) | 9 · OpenZaak/ZGW | done | | [WP-54](WP-54-openzaak-integration-harness.md) | Docker OpenZaak integration-test harness (opt-in, live round-trip) | 9 · OpenZaak/ZGW | done | -| [WP-55](WP-55-openzaak-secrets-tls.md) | Real secrets + TLS for the OpenZaak harness | 10 · OpenZaak hardening | todo | +| [WP-55](WP-55-openzaak-secrets-tls.md) | Real secrets + TLS for the OpenZaak harness | 10 · OpenZaak hardening | done | | [WP-56](WP-56-openzaak-catalogus-provisioning.md) | Idempotent catalogus provisioning | 10 · OpenZaak hardening | todo | | [WP-57](WP-57-openzaak-least-privilege-scopes.md) | Least-privilege client scopes | 10 · OpenZaak hardening | todo | | [WP-58](WP-58-openzaak-notifications.md) | Real notifications (celery + scripted abonnement) | 10 · OpenZaak hardening | todo | -| [WP-59](WP-59-document-confidentialiteit-config.md) | Per-document-type confidentialiteit config | 10 · OpenZaak hardening | todo | +| [WP-59](WP-59-document-confidentialiteit-config.md) | Per-document-type confidentialiteit config | 10 · OpenZaak hardening | todo | | [WP-60](WP-60-write-divergence-resilience.md) | Write-divergence resilience (local + ZGW writes) | 10 · OpenZaak hardening | todo | | [WP-61](WP-61-behandelportal-bootstrap.md) | Bootstrap the behandelportal app | 11 · Behandelportal | todo | -| [WP-62](WP-62-medewerker-identity-authz.md) | Backend: medewerker caller identity + authz seam | 11 · Behandelportal | todo | -| [WP-63](WP-63-aanvraag-status-lifecycle.md) | Backend: aanvraag status lifecycle as a published DTO | 11 · Behandelportal | todo | +| [WP-62](WP-62-medewerker-identity-authz.md) | Backend: medewerker caller identity + authz seam | 11 · Behandelportal | todo | +| [WP-63](WP-63-aanvraag-status-lifecycle.md) | Backend: aanvraag status lifecycle as a published DTO | 11 · Behandelportal | todo | | [WP-64](WP-64-behandelportal-werkvoorraad.md) | Behandelportal: werkvoorraad (queue) screen | 11 · Behandelportal | todo | | [WP-65](WP-65-behandelportal-beoordeling.md) | Behandelportal: zaak detail + beoordeling (decision) screen | 11 · Behandelportal | todo | | [WP-66](WP-66-behandelportal-openzaak-write.md) | Wire the decision into OpenZaak | 11 · Behandelportal | todo | diff --git a/docs/project/backlog/WP-55-openzaak-secrets-tls.md b/docs/project/backlog/WP-55-openzaak-secrets-tls.md index 0ab3f07..ec2c0e3 100644 --- a/docs/project/backlog/WP-55-openzaak-secrets-tls.md +++ b/docs/project/backlog/WP-55-openzaak-secrets-tls.md @@ -1,6 +1,6 @@ # WP-55 — Real secrets + TLS for the OpenZaak harness -Status: todo +Status: done Phase: 10 — OpenZaak production hardening ## Why @@ -45,10 +45,35 @@ change, not application code. ## Acceptance criteria -- [ ] No secret value is hardcoded in any committed compose/config file. -- [ ] The production compose fails fast (or docs state clearly) when secrets aren't +- [x] No secret value is hardcoded in any committed compose/config file. +- [x] The production compose fails fast (or docs state clearly) when secrets aren't supplied — no silent fallback to a real-looking default. -- [ ] README documents exactly which env vars must be set and how TLS is terminated. +- [x] README documents exactly which env vars must be set and how TLS is terminated. + +## Deviation from the original plan + +The WP's own "Files" section expected the secret to be parameterized directly inside +`docker-compose.openzaak.yml`'s environment or a straight env-var override. That covers +`SECRET_KEY`/DB password/`IS_HTTPS` fine (compose does key-based environment merging across +`-f` files even though the base file writes some blocks as YAML mappings and others as +anchors), but the ZGW client secret lives inside `setup_configuration/data.yaml`, a file +OpenZaak's own `setup_configuration` management command reads — compose has no mechanism to +interpolate env vars _inside_ a mounted file's contents. Solved by templating that one file +(`data.prod.yaml.template`, no secret) + a tiny host-side `render-prod-secrets.sh` +(`envsubst`, fail-fast via `${VAR:?...}`) that produces a gitignored `data.prod.yaml`, which +`docker-compose.openzaak.prod.yml` mounts over the container's `data.yaml` (bind-mounting a +single file inside an already bind-mounted read-only directory works fine in Docker/Podman — +verified via `docker compose config` with the override applied). No new dependency: `envsubst` +is part of `gettext`, already present on this machine. + +Verified for real: `docker compose -f docker-compose.openzaak.yml -f +docker-compose.openzaak.prod.yml config` succeeds with all required env vars set and both +environment overrides (SECRET_KEY, DB password/auth method) present in the merged output; +fails with a clear `${VAR:?...}` error when any is missing. `render-prod-secrets.sh` itself +fails fast (tested) when `OPENZAAK_CLIENT_SECRET` etc. are unset, and its rendered +`data.prod.yaml` was inspected and matched the template with real values substituted. +`cd backend && dotnet test` (WP-54 harness untouched): 159/159 green. The dev harness +(`docker-compose.openzaak.yml` alone, `setup_configuration/data.yaml`) is untouched. ## Verification diff --git a/docs/project/backlog/WP-63-aanvraag-status-lifecycle.md b/docs/project/backlog/WP-63-aanvraag-status-lifecycle.md index 0123f29..fb8dba8 100644 --- a/docs/project/backlog/WP-63-aanvraag-status-lifecycle.md +++ b/docs/project/backlog/WP-63-aanvraag-status-lifecycle.md @@ -26,7 +26,7 @@ read (this WP), the behandelportal needs it as the thing it advances (WP-65). - The DTO change is additive: the SSP's `pendingHerregistratie` boolean can be derived from the new status field (or kept as a computed convenience) so this ships with zero required FE behavior change — a pure backend + contract widening. -- Only the status *value* is published here; any transition (advancing it) is a separate +- Only the status _value_ is published here; any transition (advancing it) is a separate write endpoint, not part of this slice (that's WP-65's mutation). ## Files @@ -60,7 +60,7 @@ dashboard still shows the same pending/approved states it does today. ## Out of scope -Any endpoint that *advances* the status (WP-65); the behandelportal consuming it (WP-64). +Any endpoint that _advances_ the status (WP-65); the behandelportal consuming it (WP-64). ## Risks diff --git a/docs/project/backlog/WP-65-behandelportal-beoordeling.md b/docs/project/backlog/WP-65-behandelportal-beoordeling.md index f6ca38d..101900b 100644 --- a/docs/project/backlog/WP-65-behandelportal-beoordeling.md +++ b/docs/project/backlog/WP-65-behandelportal-beoordeling.md @@ -7,7 +7,7 @@ Phase: 11 — Behandelportal The core case-treatment write path — a medewerker opens one aanvraag's detail (including its documents) and records a decision (goedkeuren/afwijzen/meer info opvragen), advancing -the status lifecycle WP-63 published. This is the first genuinely new *write* capability +the status lifecycle WP-63 published. This is the first genuinely new _write_ capability in the system beyond what the citizen SSP already does to itself. ## Read first