Files
atomic-design-poc/docs/project/archive/backlog/WP-57-openzaak-least-privilege-scopes.md
ehoandClaude Opus 5 12f17d9d73 docs: archive the finished backlogs (RD-30)
Two backlog trees are complete: `docs/project/backlog/` (75 files, every
WP done) and `docs/project/refactor-backlog-setup/` (the arc before it).
Move both under `docs/project/archive/` with `git mv`, so history stays
intact through `git log --follow`. `SHOWCASE-ROADMAP.md` moves with them,
because it points at the now-archived backlog README.

Add `docs/project/archive/README.md`. It states that these trees are
historical and names the two directories that are still live.

Repoint every inbound reference named in RD-30's Files table: CLAUDE.md,
the root README, both backend READMEs, `LetterHtml.cs`, `a11y.mdx`, the
`document-feature` and `new-ssp` skills, and the readable-codebase PLAN,
README, and RD-19 ticket. Fix two upward-relative links inside the moved
WP files (WP-68, WP-69) that gained a directory level and would otherwise
break. Repoint `.prettierignore`'s two agent-prompt exclusions to their
new path, so prettier keeps leaving those files' exact wording alone.

Mark RD-30 done and check off its acceptance criteria; flip its README
row to done.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-08 23:00:38 +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 -dbootstrap-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).