From d76abf2df2c9fde28d4e8b18faf49f2f11effe62 Mon Sep 17 00:00:00 2001 From: Niek Otten Date: Fri, 28 Aug 2026 11:31:31 +0200 Subject: [PATCH] fix(infra): address Objecten by a dotted host so NRC accepts its notifications (refs #152) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The worker published and NRC answered 400 on every message: {"hoofdObject":["Voer een geldige URL in."],"resourceUrl":["Voer een geldige URL in."]} NRC types both as DRF `URLField`, and Django's URLValidator refuses a single-label host. Objecten fills them from the object `url` DRF built with `request.build_absolute_uri` — the Host the *caller* used — so `SITE_DOMAIN` never entered into it. Dropped that env pair; it was a wrong guess at the mechanism. The fix is on the caller side: keep the `objecten.local` network alias and point every writer whose writes must be notified at it — the ACL, the gateway integration tests, and this slice's verify driver. Readers keep the plain service name. ADR-0029 updated with the real mechanism and the ceiling it leaves: a new writer using `objecten:8000` gets a 201 and silently no notification. --- .../adr-0029-objecten-publishes-to-nrc.md | 39 +++++++++++++------ infra/docker-compose.local.yml | 16 ++++---- infra/docker-compose.yml | 16 ++++---- infra/run-objecten-notifications-check.sh | 9 +++-- .../ObjectenGatewayIntegrationTests.cs | 4 +- 5 files changed, 50 insertions(+), 34 deletions(-) diff --git a/docs/architecture/adr-0029-objecten-publishes-to-nrc.md b/docs/architecture/adr-0029-objecten-publishes-to-nrc.md index 7320eca..22a4e5f 100644 --- a/docs/architecture/adr-0029-objecten-publishes-to-nrc.md +++ b/docs/architecture/adr-0029-objecten-publishes-to-nrc.md @@ -47,21 +47,36 @@ The kanaal name is **not ours to choose**: the Objects API sends slice reported first. Its filter set (`object_type`) matches the kenmerken the Objects API sends, so an abonnement can narrow to one objecttype instead of receiving every write. -### `SITE_DOMAIN: objecten.local:8000` — NRC rejects single-label hosts +### Writers address Objecten as `objecten.local` — NRC rejects single-label hosts -NRC validates a notification's `hoofdObject`/`resourceUrl` with Django's `URLValidator`, -which refuses a **single-label** host. Objecten builds those URLs from `objects.utils.get_domain` -(i.e. `SITE_DOMAIN`), so with the compose service name they read `http://objecten:8000/...` -and every publish is refused with `"Voer een geldige URL in."` +NRC types a notification's `hoofdObject` and `resourceUrl` as DRF `URLField`s, so Django's +`URLValidator` runs on them — and it refuses a **single-label** host. Objecten fills both +from the object `url` that DRF built with `request.build_absolute_uri`, i.e. **the Host the +caller used**. Write to `http://objecten:8000` and NRC answers every publish with -`SITE_DOMAIN` is therefore set to a dotted host, and the `objecten` service carries an -`objecten.local` network alias so that host still **resolves in-network** — a subscriber -that follows `resourceUrl` reaches the record it points at (which S-19b-2 will do). A -dotted name that didn't resolve would trade one broken link for a quieter one. +``` +{"hoofdObject":["Voer een geldige URL in."],"resourceUrl":["Voer een geldige URL in."]} +``` + +which `objecten-celery` then retries with exponential backoff, forever, in the background — +the write itself having returned 201. + +`SITE_DOMAIN` does **not** fix this; it is not what builds those URLs. The fix is on the +caller side: the `objecten` service carries an `objecten.local` network alias, and every +component whose writes must be notified — the ACL (`Acl__Objecten__BaseUrl`), the gateway +integration tests, this slice's verify check — addresses it there. An alias rather than a +plain dotted `SITE_DOMAIN` so the host still **resolves in-network**: a subscriber that +follows `resourceUrl` reaches the record it points at, which S-19b-2 will do. Readers are +unaffected and keep using the plain service name. This is the same class of constraint as ADR-0028's "the ACL's Objecttypen base URL must match Objecten's configured `api_root`": these modules put request-derived hosts into data -that another module then validates or dereferences. +another module then validates or dereferences. + +- ponytail ceiling: nothing *enforces* that a new writer uses the alias — it would get a 201 + and silently no notification. +- Upgrade path: if a second writer ever appears, rename the compose service to `objecten.local` + so the plain name stops working, rather than adding a lint. ### A worker, not a synchronous send @@ -93,8 +108,8 @@ config. - One more long-running container (`objecten-celery`) on an already memory-tight CI runner. - A second publisher on the shared `big-reference-seed` credential — a credential rotation now touches two modules. -- One more hand-kept host constant: `SITE_DOMAIN` and the `objecten.local` alias must stay - in step, in both compose files. +- Objecten now has two in-network names, and which one a caller uses silently decides + whether its writes are notified (ceiling above). - ponytail ceiling: notification delivery has no dead-letter or alerting — a failed publish is visible only in the worker log. - Upgrade path: if undelivered register events start mattering, subscribe an audit sink or diff --git a/infra/docker-compose.local.yml b/infra/docker-compose.local.yml index 8d65cfa..b4c83cf 100644 --- a/infra/docker-compose.local.yml +++ b/infra/docker-compose.local.yml @@ -341,7 +341,8 @@ services: # Objecten holds the register, OpenZaak holds the process (S-19a, ADR-0028). Both APIs take a # static token, not a ZGW JWT. The objecttype URL is assigned at seed time, so the ACL resolves # it by name — lazily, on the first approval, so no depends_on is needed here. - Acl__Objecten__BaseUrl: http://objecten:8000/ + # Dotted host on purpose — see the `objecten.local` alias below (ADR-0029). + Acl__Objecten__BaseUrl: http://objecten.local:8000/ Acl__Objecten__Token: ${OBJECTEN_TOKEN:-1234567890abcdef1234567890abcdef12345678} Acl__Objecten__ObjecttypenBaseUrl: http://objecttypen:8000/ Acl__Objecten__ObjecttypenToken: ${OBJECTTYPEN_TOKEN:-0123456789abcdef0123456789abcdef01234567} @@ -691,13 +692,6 @@ services: CACHE_AXES: objecten-redis:6379/0 DISABLE_2FA: "true" OTEL_SDK_DISABLED: "true" - # NRC validates hoofdObject/resourceUrl with Django's URLValidator, which rejects a - # single-label host — so notifications built from `objecten:8000` are refused with - # "Voer een geldige URL in." SITE_DOMAIN fixes the host Objecten puts in its notifications - # (objects.utils.get_domain), and the `objecten.local` network alias below keeps that host - # resolvable in-network so a subscriber can actually fetch the record it points at (S-19b-2). - SITE_DOMAIN: objecten.local:8000 - IS_HTTPS: "no" CELERY_BROKER_URL: redis://objecten-redis:6379/1 CELERY_RESULT_BACKEND: redis://objecten-redis:6379/1 # Publish register-record events to NRC on the `objecten` kanaal (S-19b-1, ADR-0029). The NRC @@ -735,6 +729,12 @@ services: condition: service_completed_successfully networks: cg: + # Objecten reflects the *request* Host into the `url` it returns, and + # notifications_api_common publishes that url as the notification's hoofdObject / + # resourceUrl — which NRC types as a URLField, and Django's URLValidator rejects a + # single-label host ("Voer een geldige URL in."). So every caller whose writes must be + # notified addresses Objecten by this dotted alias instead of `objecten` (ADR-0029). + # Reads are unaffected and still use the plain service name. aliases: - objecten.local diff --git a/infra/docker-compose.yml b/infra/docker-compose.yml index b703ad2..12836c9 100644 --- a/infra/docker-compose.yml +++ b/infra/docker-compose.yml @@ -326,7 +326,8 @@ services: # Objecten holds the register, OpenZaak holds the process (S-19a, ADR-0028). Both APIs take a # static token, not a ZGW JWT. The objecttype URL is assigned at seed time, so the ACL resolves # it by name — lazily, on the first approval, so no depends_on is needed here. - Acl__Objecten__BaseUrl: http://objecten:8000/ + # Dotted host on purpose — see the `objecten.local` alias below (ADR-0029). + Acl__Objecten__BaseUrl: http://objecten.local:8000/ Acl__Objecten__Token: ${OBJECTEN_TOKEN:-1234567890abcdef1234567890abcdef12345678} Acl__Objecten__ObjecttypenBaseUrl: http://objecttypen:8000/ Acl__Objecten__ObjecttypenToken: ${OBJECTTYPEN_TOKEN:-0123456789abcdef0123456789abcdef01234567} @@ -717,13 +718,6 @@ services: CACHE_AXES: objecten-redis:6379/0 DISABLE_2FA: "true" OTEL_SDK_DISABLED: "true" - # NRC validates hoofdObject/resourceUrl with Django's URLValidator, which rejects a - # single-label host — so notifications built from `objecten:8000` are refused with - # "Voer een geldige URL in." SITE_DOMAIN fixes the host Objecten puts in its notifications - # (objects.utils.get_domain), and the `objecten.local` network alias below keeps that host - # resolvable in-network so a subscriber can actually fetch the record it points at (S-19b-2). - SITE_DOMAIN: objecten.local:8000 - IS_HTTPS: "no" CELERY_BROKER_URL: redis://objecten-redis:6379/1 CELERY_RESULT_BACKEND: redis://objecten-redis:6379/1 # Publish register-record events to NRC on the `objecten` kanaal (S-19b-1, ADR-0029). The NRC @@ -764,6 +758,12 @@ services: condition: service_completed_successfully networks: cg: + # Objecten reflects the *request* Host into the `url` it returns, and + # notifications_api_common publishes that url as the notification's hoofdObject / + # resourceUrl — which NRC types as a URLField, and Django's URLValidator rejects a + # single-label host ("Voer een geldige URL in."). So every caller whose writes must be + # notified addresses Objecten by this dotted alias instead of `objecten` (ADR-0029). + # Reads are unaffected and still use the plain service name. aliases: - objecten.local diff --git a/infra/run-objecten-notifications-check.sh b/infra/run-objecten-notifications-check.sh index 3b77b50..1b3cff1 100755 --- a/infra/run-objecten-notifications-check.sh +++ b/infra/run-objecten-notifications-check.sh @@ -11,9 +11,10 @@ # # All in-network, reaching services by container IP (a single-label host isn't URL-valid for NRC's # callbackUrl validator; the runner can't reach published ports — gitea-actions-gotchas.md §5/§6). -# EXCEPT Objecten/Objecttypen, which must be reached by SERVICE NAME: Objecttypen echoes the request -# Host into the objecttype `url` and Objecten only accepts the one matching its configured api_root -# (ADR-0028). +# EXCEPT Objecttypen, which must be reached by SERVICE NAME: it echoes the request Host into the +# objecttype `url` and Objecten only accepts the one matching its configured api_root (ADR-0028); +# and Objecten, reached by its `objecten.local` alias because it reflects the request Host into the +# notification's hoofdObject/resourceUrl, which NRC validates as a URL (ADR-0029). # # Does NOT manage the stack lifecycle, but cleans up the sink/driver it creates. set -euo pipefail @@ -48,7 +49,7 @@ echo ">> sink at $sink_ip:9000" echo ">> registering the abonnement + writing a RegisterRecord" docker rm -f rr-overify >/dev/null 2>&1 || true drv="$(docker create --network "$net" --name rr-overify \ - -e "OBJECTEN=http://objecten:8000" \ + -e "OBJECTEN=http://objecten.local:8000" \ -e "OBJECTEN_TOKEN=${OBJECTEN_TOKEN:-1234567890abcdef1234567890abcdef12345678}" \ -e "OBJECTTYPEN=http://objecttypen:8000" \ -e "OBJECTTYPEN_TOKEN=${OBJECTTYPEN_TOKEN:-0123456789abcdef0123456789abcdef01234567}" \ diff --git a/services/acl/Acl.IntegrationTests/ObjectenGatewayIntegrationTests.cs b/services/acl/Acl.IntegrationTests/ObjectenGatewayIntegrationTests.cs index 6ded7c1..14eff91 100644 --- a/services/acl/Acl.IntegrationTests/ObjectenGatewayIntegrationTests.cs +++ b/services/acl/Acl.IntegrationTests/ObjectenGatewayIntegrationTests.cs @@ -20,7 +20,7 @@ public sealed class ObjectenGatewayIntegrationTests new HttpClient(), new ObjectenOptions { - BaseUrl = new(Env("OBJECTEN_BASE", "http://objecten:8000")), + BaseUrl = new(Env("OBJECTEN_BASE", "http://objecten.local:8000")), Token = Env("OBJECTEN_TOKEN", "1234567890abcdef1234567890abcdef12345678"), ObjecttypenBaseUrl = new(Env("OBJECTTYPEN_BASE", "http://objecttypen:8000")), ObjecttypenToken = Env("OBJECTTYPEN_TOKEN", "0123456789abcdef0123456789abcdef01234567"), @@ -65,7 +65,7 @@ public sealed class ObjectenGatewayIntegrationTests { using var http = new HttpClient(); var objecttype = await ResolveObjecttypeUrlAsync(http); - var query = new Uri(new Uri(Env("OBJECTEN_BASE", "http://objecten:8000")), + var query = new Uri(new Uri(Env("OBJECTEN_BASE", "http://objecten.local:8000")), "/api/v2/objects?type=" + Uri.EscapeDataString(objecttype) + "&data_attrs=id__exact__" + Uri.EscapeDataString(id));