Files
atomic-design-poc/docs/project/backlog/WP-57-openzaak-least-privilege-scopes.md
T
ehoandClaude Sonnet 5 3e983bd2cc feat(openzaak): real notification delivery to the BFF webhook (WP-58)
OpenZaak doesn't serve the Notificaties API itself (it's a separate app,
open-notificaties) — standing one up for a real abonnement would triple
this harness for a benefit it doesn't need (exactly one subscriber, this
repo's own BFF). Instead, an opt-in compose overlay adds a celery worker
and points OpenZaak's NotificationsConfig straight at the BFF's webhook
via a zgw_consumers Service; bootstrap-notificaties.sh configures it
idempotently and verify-notificatie.sh proves a real write delivers to
the BFF's audit trail end-to-end.

Verified live: preflight proves the webhook's shared-secret gate both
ways (204/401), a zaak PATCH triggers real celery delivery, and rerunning
both scripts against an already-configured harness stays idempotent.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-30 15:33:16 +02:00

5.0 KiB

WP-57 — Least-privilege client scopes

Status: done Phase: 10 — OpenZaak production hardening

Why

The harness's OpenZaak client is granted heeft_alle_autorisaties: true in setup_configuration/data.yaml — acceptable for a disposable test rig, wrong for anything closer to production, where the BFF's client should hold only the Autorisaties it actually exercises.

Read first

  • backend/openzaak/setup_configuration/data.yaml
  • backend/src/BigRegister.Api/Zgw/OpenZaakZaakSource.cs and OpenZaakDocumentSource.cs (the actual ZGW endpoints/verbs called)

Decisions (pre-made, don't relitigate)

  • Scope precisely to what the BFF calls today: zaken (aanmaken, bijwerken, lezen), statussen (aanmaken), rollen (aanmaken), documenten/zaakinformatieobjecten (aanmaken, lezen) — enumerate exactly at kickoff from the client code, don't guess broader.
  • No wildcard/all-scope grant in any environment beyond the pre-WP-56 disposable dev rig.

Files

  • backend/openzaak/setup_configuration/data.yaml (Autorisaties block)
  • backend/openzaak/README.md

Steps

  1. Grep OpenZaakZaakSource.cs and OpenZaakDocumentSource.cs for every ZGW endpoint/verb called.
  2. Replace heeft_alle_autorisaties: true with an explicit autorisaties list matching exactly that set.
  3. Re-run the full integration suite against the narrowed client; add any scope a 403 surfaces.

Acceptance criteria

  • Client config has no wildcard/all-scopes grant.
  • OpenZaakIntegrationTests (WP-54) pass unchanged against the narrowed client.

What actually happened

vng_api_common's ApplicatieConfigurationModel (the class backing setup_configuration's vng_api_common_applicaties step, read from the installed package inside the openzaak/open-zaak:1.29.1 image) only has fields for uuid/client_ids/label/heeft_alle_autorisaties — there is no YAML field for granular autorisaties at all. So data.yaml now sets heeft_alle_autorisaties: false (both the dev harness and the prod template), which leaves bigregister-test with zero Autorisaties until something else grants them.

That "something else" can't be the JWT-authenticated Autorisaties REST API — a zero-scope client can't grant itself scope over an API gated by scope (confirmed from ApplicatieViewSet.required_scopes: update/partial_update need autorisaties.bijwerken). bootstrap-catalogus.sh grants the scopes directly via the ORM instead (docker compose exec web python manage.py shell, workdir /app/src) — no JWT/REST layer involved, so no circularity. Two grants, both idempotent (delete-then-create):

  • ztc: catalogi.lezen + catalogi.schrijven — granted up front (no zaaktype dependency). Only catalogi.schrijven is provisioning-only; the BFF itself only ever reads Catalogi.
  • zrc: zaken.aanmaken + zaken.bijwerken + zaken.lezen, scoped to the one zaaktype (zaaktype=<ZT-HERREG url>, max_vertrouwelijkheidaanduiding=openbaar — both fields are required by OpenZaak's AutorisatieValidator for any zaken.* scope) — granted once zaaktype_url is known, right after the zaaktype is created/resolved.

Reading the actual RolViewSet/StatusViewSet/ZaakInformatieObjectViewSet required_scopes (not just the scope docstrings, which are aspirational/descriptive) showed the decision text's "statussen (aanmaken), rollen (aanmaken)" don't map to separate OpenZaak scopes — zaken.aanmaken alone (OR'd against alternatives) already covers the first status and the initiator rol; there is no rollen.aanmaken scope. documenten/zaakinformatieobjecten scope was not granted: Zgw:InformatieobjecttypeUrls is empty in appsettings.json, so OpenZaakDocumentSource.Upload can't function in this harness regardless of scope (throws before any HTTP call) — nothing to scope precisely to yet. Left as a documented follow-up (the script would also need to seed an informatieobjecttype to have something concrete to scope documenten.aanmaken to).

Verified for real: down -v fresh volume → up -d → bootstrap-catalogus.sh (all "created", scopes granted, heeft_alle_autorisaties: False confirmed via manage.py shell) → dotnet test --filter Category=Integration green → reran bootstrap-catalogus.sh again under the now-narrowed client (all "exists", scopes re-granted idempotently, no 403s) → confirmed the narrowing is real, not just untested, by DELETEing the seeded zaak with a hand-rolled JWT for this client: 403 permission_denied (zaak deletion needs zaken.verwijderen/zaken.geforceerd-bijwerken, neither granted).

Verification

cd backend && dotnet test --filter Category=Integration against the harness with the narrowed client.

Out of scope

Rotating/expiring the client credential itself — defer until multi-tenant/production ops actually need it.

Risks

An overlooked scope only surfaces as a runtime 403 against a real instance — mitigated by running the full integration suite, which already exercises every current call path (WP-54).

Depends on: WP-56 (provisioning mechanism this scopes down).