fix(infra): address Objecten by a dotted host so NRC accepts its notifications (refs #152)
CI / build (pull_request) Successful in 1m9s
CI / lint (pull_request) Successful in 1m24s
CI / unit (pull_request) Successful in 1m32s
CI / frontend (pull_request) Successful in 3m13s
CI / mutation (pull_request) Successful in 6m25s
CI / verify-stack (pull_request) Failing after 8m11s

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.
This commit is contained in:
not
2026-08-28 11:31:31 +02:00
parent 448896206f
commit d76abf2df2
5 changed files with 50 additions and 34 deletions
@@ -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 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. 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`, NRC types a notification's `hoofdObject` and `resourceUrl` as DRF `URLField`s, so Django's
which refuses a **single-label** host. Objecten builds those URLs from `objects.utils.get_domain` `URLValidator` runs on them — and it refuses a **single-label** host. Objecten fills both
(i.e. `SITE_DOMAIN`), so with the compose service name they read `http://objecten:8000/...` from the object `url` that DRF built with `request.build_absolute_uri`, i.e. **the Host the
and every publish is refused with `"Voer een geldige URL in."` 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 {"hoofdObject":["Voer een geldige URL in."],"resourceUrl":["Voer een geldige URL in."]}
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.
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 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 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 ### 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. - 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 - A second publisher on the shared `big-reference-seed` credential — a credential rotation
now touches two modules. now touches two modules.
- One more hand-kept host constant: `SITE_DOMAIN` and the `objecten.local` alias must stay - Objecten now has two in-network names, and which one a caller uses silently decides
in step, in both compose files. whether its writes are notified (ceiling above).
- ponytail ceiling: notification delivery has no dead-letter or alerting — a failed publish - ponytail ceiling: notification delivery has no dead-letter or alerting — a failed publish
is visible only in the worker log. is visible only in the worker log.
- Upgrade path: if undelivered register events start mattering, subscribe an audit sink or - Upgrade path: if undelivered register events start mattering, subscribe an audit sink or
+8 -8
View File
@@ -341,7 +341,8 @@ services:
# Objecten holds the register, OpenZaak holds the process (S-19a, ADR-0028). Both APIs take a # 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 # 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. # 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__Token: ${OBJECTEN_TOKEN:-1234567890abcdef1234567890abcdef12345678}
Acl__Objecten__ObjecttypenBaseUrl: http://objecttypen:8000/ Acl__Objecten__ObjecttypenBaseUrl: http://objecttypen:8000/
Acl__Objecten__ObjecttypenToken: ${OBJECTTYPEN_TOKEN:-0123456789abcdef0123456789abcdef01234567} Acl__Objecten__ObjecttypenToken: ${OBJECTTYPEN_TOKEN:-0123456789abcdef0123456789abcdef01234567}
@@ -691,13 +692,6 @@ services:
CACHE_AXES: objecten-redis:6379/0 CACHE_AXES: objecten-redis:6379/0
DISABLE_2FA: "true" DISABLE_2FA: "true"
OTEL_SDK_DISABLED: "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_BROKER_URL: redis://objecten-redis:6379/1
CELERY_RESULT_BACKEND: 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 # 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 condition: service_completed_successfully
networks: networks:
cg: 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: aliases:
- objecten.local - objecten.local
+8 -8
View File
@@ -326,7 +326,8 @@ services:
# Objecten holds the register, OpenZaak holds the process (S-19a, ADR-0028). Both APIs take a # 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 # 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. # 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__Token: ${OBJECTEN_TOKEN:-1234567890abcdef1234567890abcdef12345678}
Acl__Objecten__ObjecttypenBaseUrl: http://objecttypen:8000/ Acl__Objecten__ObjecttypenBaseUrl: http://objecttypen:8000/
Acl__Objecten__ObjecttypenToken: ${OBJECTTYPEN_TOKEN:-0123456789abcdef0123456789abcdef01234567} Acl__Objecten__ObjecttypenToken: ${OBJECTTYPEN_TOKEN:-0123456789abcdef0123456789abcdef01234567}
@@ -717,13 +718,6 @@ services:
CACHE_AXES: objecten-redis:6379/0 CACHE_AXES: objecten-redis:6379/0
DISABLE_2FA: "true" DISABLE_2FA: "true"
OTEL_SDK_DISABLED: "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_BROKER_URL: redis://objecten-redis:6379/1
CELERY_RESULT_BACKEND: 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 # 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 condition: service_completed_successfully
networks: networks:
cg: 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: aliases:
- objecten.local - objecten.local
+5 -4
View File
@@ -11,9 +11,10 @@
# #
# All in-network, reaching services by container IP (a single-label host isn't URL-valid for NRC's # 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). # 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 # EXCEPT Objecttypen, which must be reached by SERVICE NAME: it echoes the request Host into the
# Host into the objecttype `url` and Objecten only accepts the one matching its configured api_root # objecttype `url` and Objecten only accepts the one matching its configured api_root (ADR-0028);
# (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. # Does NOT manage the stack lifecycle, but cleans up the sink/driver it creates.
set -euo pipefail set -euo pipefail
@@ -48,7 +49,7 @@ echo ">> sink at $sink_ip:9000"
echo ">> registering the abonnement + writing a RegisterRecord" echo ">> registering the abonnement + writing a RegisterRecord"
docker rm -f rr-overify >/dev/null 2>&1 || true docker rm -f rr-overify >/dev/null 2>&1 || true
drv="$(docker create --network "$net" --name rr-overify \ 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 "OBJECTEN_TOKEN=${OBJECTEN_TOKEN:-1234567890abcdef1234567890abcdef12345678}" \
-e "OBJECTTYPEN=http://objecttypen:8000" \ -e "OBJECTTYPEN=http://objecttypen:8000" \
-e "OBJECTTYPEN_TOKEN=${OBJECTTYPEN_TOKEN:-0123456789abcdef0123456789abcdef01234567}" \ -e "OBJECTTYPEN_TOKEN=${OBJECTTYPEN_TOKEN:-0123456789abcdef0123456789abcdef01234567}" \
@@ -20,7 +20,7 @@ public sealed class ObjectenGatewayIntegrationTests
new HttpClient(), new HttpClient(),
new ObjectenOptions new ObjectenOptions
{ {
BaseUrl = new(Env("OBJECTEN_BASE", "http://objecten:8000")), BaseUrl = new(Env("OBJECTEN_BASE", "http://objecten.local:8000")),
Token = Env("OBJECTEN_TOKEN", "1234567890abcdef1234567890abcdef12345678"), Token = Env("OBJECTEN_TOKEN", "1234567890abcdef1234567890abcdef12345678"),
ObjecttypenBaseUrl = new(Env("OBJECTTYPEN_BASE", "http://objecttypen:8000")), ObjecttypenBaseUrl = new(Env("OBJECTTYPEN_BASE", "http://objecttypen:8000")),
ObjecttypenToken = Env("OBJECTTYPEN_TOKEN", "0123456789abcdef0123456789abcdef01234567"), ObjecttypenToken = Env("OBJECTTYPEN_TOKEN", "0123456789abcdef0123456789abcdef01234567"),
@@ -65,7 +65,7 @@ public sealed class ObjectenGatewayIntegrationTests
{ {
using var http = new HttpClient(); using var http = new HttpClient();
var objecttype = await ResolveObjecttypeUrlAsync(http); 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) + "/api/v2/objects?type=" + Uri.EscapeDataString(objecttype) +
"&data_attrs=id__exact__" + Uri.EscapeDataString(id)); "&data_attrs=id__exact__" + Uri.EscapeDataString(id));