feat(openzaak): least-privilege client scopes (WP-57)

setup_configuration has no YAML field for granular autorisaties, so
bigregister-test now starts at heeft_alle_autorisaties: false (dev + prod
template) and bootstrap-catalogus.sh grants exactly the ztc/zrc scopes the
harness needs via the Django ORM, sidestepping the zero-scope
chicken-and-egg with the JWT-authenticated Autorisaties REST API.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
eho
2026-07-30 14:05:54 +02:00
co-authored by Claude Sonnet 5
parent 89ad3490b0
commit 1e87997ea0
6 changed files with 112 additions and 13 deletions
+12 -2
View File
@@ -107,14 +107,24 @@ app change.
**rolls back the whole create**) on any notified resource — see the compose file's comment. **rolls back the whole create**) on any notified resource — see the compose file's comment.
- `setup_configuration/data.yaml` — the declarative, scripted alternative to clicking through - `setup_configuration/data.yaml` — the declarative, scripted alternative to clicking through
the Django admin (upstream's own documented `setup_configuration` CLI mechanism): creates the the Django admin (upstream's own documented `setup_configuration` CLI mechanism): creates the
one `bigregister-test` client (`heeft_alle_autorisaties: true` — this instance never exists one `bigregister-test` client with `heeft_alle_autorisaties: false` — this YAML mechanism
for anything but this harness, so there's no least-privilege boundary worth modeling). (`vng_api_common`'s `ApplicatieConfigurationModel`) has no field for granular scopes at all,
so the client starts with zero Autorisaties; `bootstrap-catalogus.sh` grants the exact ones
it needs (WP-57).
- `bootstrap-catalogus.sh` — the business content (catalogus/zaaktype/zaak/…) `setup_configuration` - `bootstrap-catalogus.sh` — the business content (catalogus/zaaktype/zaak/…) `setup_configuration`
has no YAML for; every field value here was checked against OpenZaak's own OpenAPI spec and a has no YAML for; every field value here was checked against OpenZaak's own OpenAPI spec and a
live run of this exact script, not guessed (two OpenZaak quirks it works around: a zaaktype live run of this exact script, not guessed (two OpenZaak quirks it works around: a zaaktype
needs ≥1 resultaattype and 2 statustypen before it can be published, and its needs ≥1 resultaattype and 2 statustypen before it can be published, and its
`selectielijstklasse` and the zaaktype's `selectielijstProcestype` must reference the same `selectielijstklasse` and the zaaktype's `selectielijstProcestype` must reference the same
`procesType` on the public VNG selectielijst API). Idempotent (WP-56) — see "Bring it up" above. `procesType` on the public VNG selectielijst API). Idempotent (WP-56) — see "Bring it up" above.
Also grants `bigregister-test`'s Autorisaties via `manage.py shell` (WP-57, see the script's
top comment): `ztc` scopes (`catalogi.lezen`/`catalogi.schrijven`, this script's own
content-creation needs) up front, `zrc` scopes (`zaken.aanmaken`/`zaken.bijwerken`/
`zaken.lezen`, scoped to the one zaaktype the BFF and this script both use) once that
zaaktype exists. No `documenten`/DRC grant — `Zgw:InformatieobjecttypeUrls` is empty in this
harness's `appsettings.json`, so `OpenZaakDocumentSource` isn't reachable here yet; add the
grant (scoped to a real `informatieobjecttype`, which this script would also need to seed)
when a later WP wires DRC content into this harness.
- **Not here**: Documenten (DRC) / Notificaties (NRC) content — add if a later WP needs to prove - **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). 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.prod.yml` (WP-55) — production overrides layered on top of
+39
View File
@@ -16,6 +16,16 @@
# repeatedly against a long-lived instance, not just once per fresh volume. Prints the seeded # repeatedly against a long-lived instance, not just once per fresh volume. Prints the seeded
# zaak's `identificatie` + `url` on success; also writes them to seeded.env (repo-ignored) for # zaak's `identificatie` + `url` on success; also writes them to seeded.env (repo-ignored) for
# OpenZaakIntegrationTests.cs to assert against. # OpenZaakIntegrationTests.cs to assert against.
#
# WP-57: `bigregister-test` starts with ZERO Autorisaties (data.yaml sets
# heeft_alle_autorisaties: false) — the setup_configuration YAML has no field for granular
# scopes at all (confirmed from vng_api_common's own ApplicatieConfigurationModel), so this
# script grants them itself via `manage.py shell` (Django ORM, inside the `web` container) at
# the two points they become grantable: ztc scopes up front (no zaaktype dependency), zrc
# scopes once `zaaktype_url` exists below. Going through the ORM instead of the
# JWT-authenticated Autorisaties REST API sidesteps a real chicken-and-egg: a client with zero
# scopes cannot grant itself any scope over that API. Re-running this script re-grants the same
# scopes (idempotent, like everything else here).
set -euo pipefail set -euo pipefail
cd "$(dirname "${BASH_SOURCE[0]}")" cd "$(dirname "${BASH_SOURCE[0]}")"
@@ -61,6 +71,27 @@ oz() {
echo "$json" echo "$json"
} }
# Grant (replace) an Autorisatie for $CLIENT_ID directly via the ORM (see the WP-57 note up
# top for why this bypasses the REST Autorisaties API). $1 = component, $2 = python list
# literal of scopes, $3.. = extra `Autorisatie(...)` kwargs as `name=value` (value already a
# valid Python literal, e.g. a quoted URL).
grant_scopes() {
local component="$1" scopes="$2"
shift 2
local extra="" kv
for kv in "$@"; do extra+=" $kv,"$'\n'; done
docker compose -f docker-compose.openzaak.yml exec -T --workdir /app/src web python manage.py shell <<PY
from vng_api_common.authorizations.models import Applicatie
app = Applicatie.objects.get(client_ids__contains=["$CLIENT_ID"])
app.autorisaties.filter(component="$component").delete()
app.autorisaties.create(
component="$component",
scopes=$scopes,
$extra)
PY
}
# $1 = list path+query (server-side-filtered to the natural key). Prints the first result's # $1 = list path+query (server-side-filtered to the natural key). Prints the first result's
# `url`, or nothing if the list is empty — the GET-before-POST idempotency check. # `url`, or nothing if the list is empty — the GET-before-POST idempotency check.
existing_url() { existing_url() {
@@ -86,6 +117,9 @@ until curl -sS -o /dev/null -w '%{http_code}' "$BASE/catalogi/api/v1/catalogusse
sleep 2 sleep 2
done done
echo "Granting ztc scopes (catalogi.lezen, catalogi.schrijven — this script's own content-creation needs; the BFF only ever reads Catalogi)..."
grant_scopes ztc '["catalogi.lezen", "catalogi.schrijven"]'
echo "Catalogus..." echo "Catalogus..."
catalogus_url=$(existing_url "/catalogi/api/v1/catalogussen?domein=BIGR&rsin=$RSIN") catalogus_url=$(existing_url "/catalogi/api/v1/catalogussen?domein=BIGR&rsin=$RSIN")
if [ -n "$catalogus_url" ]; then if [ -n "$catalogus_url" ]; then
@@ -134,6 +168,11 @@ print(json.dumps({
echo " created: $zaaktype_url" echo " created: $zaaktype_url"
fi fi
echo "Granting zrc scopes (zaken.aanmaken, zaken.bijwerken, zaken.lezen), scoped to $zaaktype_url — the one zaaktype this harness (and the BFF's Zgw:ZaaktypeUrls config) ever uses..."
grant_scopes zrc '["zaken.aanmaken", "zaken.bijwerken", "zaken.lezen"]' \
"zaaktype=\"$zaaktype_url\"" \
'max_vertrouwelijkheidaanduiding="openbaar"'
echo "Statustypen (publish needs a begin AND an end status)..." echo "Statustypen (publish needs a begin AND an end status)..."
statustype_url=$(existing_statustype_url "$zaaktype_url" 1) statustype_url=$(existing_statustype_url "$zaaktype_url" 1)
if [ -n "$statustype_url" ]; then if [ -n "$statustype_url" ]; then
@@ -4,8 +4,12 @@
# into it to produce the gitignored data.prod.yaml that docker-compose.openzaak.prod.yml # into it to produce the gitignored data.prod.yaml that docker-compose.openzaak.prod.yml
# mounts over the container's data.yaml. # mounts over the container's data.yaml.
# #
# Least-privilege client scopes (heeft_alle_autorisaties: true below) are WP-57's job, not # Least-privilege client scopes (WP-57): heeft_alle_autorisaties is false, matching the dev
# this WP's — left matching the dev harness on purpose. # harness (setup_configuration has no YAML field for granular `autorisaties` — see
# data.yaml's comment). This template only covers infra config; a real deploy must grant this
# client's Autorisaties the same way bootstrap-catalogus.sh does for the dev harness — via
# `manage.py shell` (or the Autorisaties REST API from an already-privileged caller) against
# the production catalogus/zaaktype URLs, once, as part of standing up that environment.
sites_config_enable: true sites_config_enable: true
sites_config: sites_config:
items: items:
@@ -25,4 +29,4 @@ vng_api_common_applicaties:
client_ids: client_ids:
- ${OPENZAAK_CLIENT_ID} - ${OPENZAAK_CLIENT_ID}
label: BIG-register BFF (production) label: BIG-register BFF (production)
heeft_alle_autorisaties: true heeft_alle_autorisaties: false
@@ -2,9 +2,12 @@
# documented CLI config mechanism — see docker-compose.openzaak.yml) instead of the Django # documented CLI config mechanism — see docker-compose.openzaak.yml) instead of the Django
# admin. Creates the ONE application the bootstrap script + integration test authenticate as. # admin. Creates the ONE application the bootstrap script + integration test authenticate as.
# #
# ponytail: heeft_alle_autorisaties (all scopes) rather than a granular per-component/scope # heeft_alle_autorisaties is false (WP-57, least privilege) — but
# list — this instance only ever exists for this harness/test, never a shared or prod # `ApplicatieConfigurationModel` (vng_api_common's setup_configuration step) has no field for
# OpenZaak, so there's no least-privilege boundary worth modeling here. # granular `autorisaties` at all, only this boolean. So this client starts with ZERO scopes;
# bootstrap-catalogus.sh grants the exact ones it needs via `manage.py shell` (Django ORM,
# not the JWT-authenticated Autorisaties REST API — a zero-scope client can't grant itself
# anything over REST, so this sidesteps that bootstrap chicken-and-egg entirely).
sites_config_enable: true sites_config_enable: true
sites_config: sites_config:
items: items:
@@ -24,4 +27,4 @@ vng_api_common_applicaties:
client_ids: client_ids:
- bigregister-test - bigregister-test
label: BIG-register BFF (WP-54 test harness) label: BIG-register BFF (WP-54 test harness)
heeft_alle_autorisaties: true heeft_alle_autorisaties: false
+1 -1
View File
@@ -107,7 +107,7 @@ for its existing violations, so every WP ends green.
| [WP-54](WP-54-openzaak-integration-harness.md) | Docker OpenZaak integration-test harness (opt-in, live round-trip) | 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 | done | | [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 | done | | [WP-56](WP-56-openzaak-catalogus-provisioning.md) | Idempotent catalogus provisioning | 10 · OpenZaak hardening | done |
| [WP-57](WP-57-openzaak-least-privilege-scopes.md) | Least-privilege client scopes | 10 · OpenZaak hardening | todo | | [WP-57](WP-57-openzaak-least-privilege-scopes.md) | Least-privilege client scopes | 10 · OpenZaak hardening | done |
| [WP-58](WP-58-openzaak-notifications.md) | Real notifications (celery + scripted abonnement) | 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-60](WP-60-write-divergence-resilience.md) | Write-divergence resilience (local + ZGW writes) | 10 · OpenZaak hardening | todo |
@@ -1,6 +1,6 @@
# WP-57 — Least-privilege client scopes # WP-57 — Least-privilege client scopes
Status: todo Status: done
Phase: 10 — OpenZaak production hardening Phase: 10 — OpenZaak production hardening
## Why ## Why
@@ -39,8 +39,51 @@ exercises.
## Acceptance criteria ## Acceptance criteria
- [ ] Client config has no wildcard/all-scopes grant. - [x] Client config has no wildcard/all-scopes grant.
- [ ] `OpenZaakIntegrationTests` (WP-54) pass unchanged against the narrowed client. - [x] `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 ## Verification