Compare commits
5
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a5fd47e546 | ||
|
|
d76abf2df2 | ||
|
|
448896206f | ||
|
|
dc9ca2ceb2 | ||
|
|
0cd70ae8c3 |
@@ -219,6 +219,9 @@ jobs:
|
|||||||
- name: OpenZaak → NRC → Event Subscriber → projection-api
|
- name: OpenZaak → NRC → Event Subscriber → projection-api
|
||||||
id: projection
|
id: projection
|
||||||
run: make verify-projection
|
run: make verify-projection
|
||||||
|
- name: Objecten → NRC notification delivery
|
||||||
|
id: objecten_nrc
|
||||||
|
run: make verify-objecten-notifications
|
||||||
- name: Domain → Flowable → ACL → OpenZaak
|
- name: Domain → Flowable → ACL → OpenZaak
|
||||||
id: domain
|
id: domain
|
||||||
run: make verify-domain
|
run: make verify-domain
|
||||||
@@ -245,6 +248,7 @@ jobs:
|
|||||||
OBJECTTYPEN: ${{ steps.objecttypen.outcome }}
|
OBJECTTYPEN: ${{ steps.objecttypen.outcome }}
|
||||||
OBJECTEN: ${{ steps.objecten.outcome }}
|
OBJECTEN: ${{ steps.objecten.outcome }}
|
||||||
REGISTERRECORD: ${{ steps.registerrecord.outcome }}
|
REGISTERRECORD: ${{ steps.registerrecord.outcome }}
|
||||||
|
OBJECTEN_NOTIFICATIONS: ${{ steps.objecten_nrc.outcome }}
|
||||||
ACL: ${{ steps.acl.outcome }}
|
ACL: ${{ steps.acl.outcome }}
|
||||||
NRC: ${{ steps.nrc.outcome }}
|
NRC: ${{ steps.nrc.outcome }}
|
||||||
PROJECTION: ${{ steps.projection.outcome }}
|
PROJECTION: ${{ steps.projection.outcome }}
|
||||||
@@ -266,6 +270,7 @@ jobs:
|
|||||||
echo "| Objecttypen API + token | $(icon "$OBJECTTYPEN") |"
|
echo "| Objecttypen API + token | $(icon "$OBJECTTYPEN") |"
|
||||||
echo "| Objecten API + token | $(icon "$OBJECTEN") |"
|
echo "| Objecten API + token | $(icon "$OBJECTEN") |"
|
||||||
echo "| RegisterRecord objecttype | $(icon "$REGISTERRECORD") |"
|
echo "| RegisterRecord objecttype | $(icon "$REGISTERRECORD") |"
|
||||||
|
echo "| Objecten → NRC | $(icon "$OBJECTEN_NOTIFICATIONS") |"
|
||||||
echo "| ACL ↔ OpenZaak | $(icon "$ACL") |"
|
echo "| ACL ↔ OpenZaak | $(icon "$ACL") |"
|
||||||
echo "| OpenZaak → NRC | $(icon "$NRC") |"
|
echo "| OpenZaak → NRC | $(icon "$NRC") |"
|
||||||
echo "| NRC → Event Subscriber → projection | $(icon "$PROJECTION") |"
|
echo "| NRC → Event Subscriber → projection | $(icon "$PROJECTION") |"
|
||||||
@@ -285,7 +290,7 @@ jobs:
|
|||||||
# Log dump must precede teardown (which removes the containers).
|
# Log dump must precede teardown (which removes the containers).
|
||||||
- name: Dump container logs on failure
|
- name: Dump container logs on failure
|
||||||
if: failure()
|
if: failure()
|
||||||
run: docker compose -f infra/docker-compose.yml logs --no-color --tail=100 oz-init openzaak nrc-init nrc-web nrc-celery nrc-beat flowable-db flowable-rest flowable-init keycloak acl bff domain projection-db event-subscriber projection-api self-service openbaar behandel beheer objecttypen-db objecttypen-redis objecttypen-init objecttypen objecten-db objecten-redis objecten-init objecten registerrecord-init tempo prometheus grafana 2>&1 || true
|
run: docker compose -f infra/docker-compose.yml logs --no-color --tail=100 oz-init openzaak nrc-init nrc-web nrc-celery nrc-beat flowable-db flowable-rest flowable-init keycloak acl bff domain projection-db event-subscriber projection-api self-service openbaar behandel beheer objecttypen-db objecttypen-redis objecttypen-init objecttypen objecten-db objecten-redis objecten-init objecten objecten-celery registerrecord-init tempo prometheus grafana 2>&1 || true
|
||||||
- name: Tear down
|
- name: Tear down
|
||||||
if: always()
|
if: always()
|
||||||
run: make down
|
run: make down
|
||||||
|
|||||||
+3
-1
@@ -296,7 +296,9 @@ Split into independently deployable sub-slices (CLAUDE.md §13):
|
|||||||
Split into independently deployable sub-slices (CLAUDE.md §13):
|
Split into independently deployable sub-slices (CLAUDE.md §13):
|
||||||
|
|
||||||
- **S-19a** (#149, ✅) · ACL writes the `RegisterRecord` to Objecten on approval, idempotently, alongside the ZGW eindstatus. Carries the ADR (ADR-0028).
|
- **S-19a** (#149, ✅) · ACL writes the `RegisterRecord` to Objecten on approval, idempotently, alongside the ZGW eindstatus. Carries the ADR (ADR-0028).
|
||||||
- **S-19b** (#150) · Read projection sourced from Objecten instead of NRC zaak events. Depends on S-19a.
|
- **S-19b** (#150) · Read projection sourced from Objecten instead of NRC zaak events. *(split — #150 closed)*
|
||||||
|
- **S-19b-1** (#152) · Objecten publishes to NRC — broker, celery worker, `objecten` kanaal, notifications config. Turns back on what ADR-0028 deliberately disabled.
|
||||||
|
- **S-19b-2** (#153) · Projection derived from `RegisterRecord` objects, rebuildable from the Objecten-derived log. Depends on S-19b-1.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -43,7 +43,7 @@ export DOCKER_HOST := unix://$(PODMAN_SOCK)
|
|||||||
endif
|
endif
|
||||||
endif
|
endif
|
||||||
|
|
||||||
.PHONY: ci lint build unit mutation frontend integration verify verify-up verify-acl verify-nrc verify-projection verify-bff verify-domain verify-observability verify-tracing verify-metrics verify-objecttypen verify-objecten verify-registerrecord verify-notifications smoke up down local verify-local local-down changelog openzaak-up openzaak-smoke openzaak-seed openzaak-down stack-up stack-smoke stack-down keycloak-up keycloak-smoke keycloak-down flowable-up flowable-smoke flowable-down help
|
.PHONY: ci lint build unit mutation frontend integration verify verify-up verify-acl verify-nrc verify-projection verify-bff verify-domain verify-observability verify-tracing verify-metrics verify-objecttypen verify-objecten verify-registerrecord verify-objecten-notifications verify-notifications smoke up down local verify-local local-down changelog openzaak-up openzaak-smoke openzaak-seed openzaak-down stack-up stack-smoke stack-down keycloak-up keycloak-smoke keycloak-down flowable-up flowable-smoke flowable-down help
|
||||||
|
|
||||||
## ci: run the full pipeline — lint, build, unit, mutation, frontend, verify (mirrors Gitea Actions)
|
## ci: run the full pipeline — lint, build, unit, mutation, frontend, verify (mirrors Gitea Actions)
|
||||||
## `verify` is the live-stack stage (full stack up once → ACL + notification checks).
|
## `verify` is the live-stack stage (full stack up once → ACL + notification checks).
|
||||||
@@ -201,6 +201,11 @@ verify-objecten:
|
|||||||
verify-registerrecord:
|
verify-registerrecord:
|
||||||
bash infra/run-registerrecord-check.sh
|
bash infra/run-registerrecord-check.sh
|
||||||
|
|
||||||
|
## verify-objecten-notifications: assert a RegisterRecord write in Objecten is DELIVERED as an
|
||||||
|
## `objecten` notification via NRC (S-19b-1), against the already-running stack.
|
||||||
|
verify-objecten-notifications:
|
||||||
|
bash infra/run-objecten-notifications-check.sh
|
||||||
|
|
||||||
## verify: local mirror of the CI verify-stack job — full stack up once, all checks,
|
## verify: local mirror of the CI verify-stack job — full stack up once, all checks,
|
||||||
## tear down (always). For fast single-concern local iteration use `integration`
|
## tear down (always). For fast single-concern local iteration use `integration`
|
||||||
## (oz-only) or `verify-notifications` (oz+nrc) instead.
|
## (oz-only) or `verify-notifications` (oz+nrc) instead.
|
||||||
@@ -212,6 +217,7 @@ verify:
|
|||||||
&& bash infra/run-acl-integration.sh \
|
&& bash infra/run-acl-integration.sh \
|
||||||
&& bash infra/run-notification-check.sh \
|
&& bash infra/run-notification-check.sh \
|
||||||
&& bash infra/run-projection-check.sh \
|
&& bash infra/run-projection-check.sh \
|
||||||
|
&& bash infra/run-objecten-notifications-check.sh \
|
||||||
&& bash infra/run-domain-check.sh \
|
&& bash infra/run-domain-check.sh \
|
||||||
&& bash infra/run-bff-check.sh \
|
&& bash infra/run-bff-check.sh \
|
||||||
&& bash infra/run-e2e-check.sh || rc=$$?; \
|
&& bash infra/run-e2e-check.sh || rc=$$?; \
|
||||||
|
|||||||
@@ -119,8 +119,8 @@ every message was dropped on the floor — a delivery path that looks wired and
|
|||||||
|
|
||||||
- ponytail ceiling: Objecten emits no notifications, so nothing downstream can react to a
|
- ponytail ceiling: Objecten emits no notifications, so nothing downstream can react to a
|
||||||
register write yet.
|
register write yet.
|
||||||
- Upgrade path: S-19b (#150) needs those notifications to source the projection from
|
- **Lifted by ADR-0029** (S-19b-1, #152): broker, worker, `objecten` kanaal and
|
||||||
Objecten, and turns them on together with the broker, worker, kanaal and abonnement.
|
notifications config now exist, and `NOTIFICATIONS_DISABLED` is `false`.
|
||||||
|
|
||||||
## Consequences
|
## Consequences
|
||||||
|
|
||||||
@@ -153,10 +153,16 @@ and no service reaches Objecten's database.
|
|||||||
|
|
||||||
## Verification
|
## Verification
|
||||||
|
|
||||||
`verify-domain` (`infra/run-domain-check.sh`) drives a real approval end-to-end and then
|
The end-to-end assertion lives in the Playwright happy path
|
||||||
asserts, via `infra/register-record-check.py`, that Objecten holds exactly one
|
(`tests/e2e/registration.spec.ts`, run by `verify-e2e`): after the behandelaar approves and
|
||||||
`RegisterRecord` for that registration, with status `INGESCHREVEN` and no field outside
|
the openbaar register shows `INGESCHREVEN`, it asserts Objecten holds exactly one
|
||||||
the public-safe schema.
|
`RegisterRecord` for *that* reference, with status `INGESCHREVEN` and no field outside the
|
||||||
|
public-safe schema.
|
||||||
|
|
||||||
|
It belongs there and not in `verify-domain`, which looks like the obvious home: that check
|
||||||
|
completes the Beoordelen task straight through Flowable REST (deliberately — it exists to
|
||||||
|
exercise the Workflow Client's REST contract), which bypasses the domain `decide` path that
|
||||||
|
calls the ACL. The e2e is the only check that drives a real approval.
|
||||||
|
|
||||||
`ObjectenGatewayIntegrationTests` (`Category=Integration`, so it runs under `verify-acl`
|
`ObjectenGatewayIntegrationTests` (`Category=Integration`, so it runs under `verify-acl`
|
||||||
inside the compose network) drives the real gateway against a live Objecten + Objecttypen
|
inside the compose network) drives the real gateway against a live Objecten + Objecttypen
|
||||||
|
|||||||
@@ -0,0 +1,122 @@
|
|||||||
|
# ADR-0029: Objecten publishes register events to NRC
|
||||||
|
|
||||||
|
- **Status:** Accepted
|
||||||
|
- **Date:** 2026-08-14
|
||||||
|
- **Deciders:** Respellion engineering
|
||||||
|
- **Slice:** S-19b-1 (#152), first of the S-19b (#150) split
|
||||||
|
- **Supersedes in part:** ADR-0028's "Objecten's notifications are off for this slice"
|
||||||
|
|
||||||
|
## Context
|
||||||
|
|
||||||
|
ADR-0028 put the authoritative register record in the Objecten API and had the ACL write
|
||||||
|
it on approval. It also switched Objecten's notifications **off** — deliberately, with a
|
||||||
|
stated ceiling: there was no broker, no worker, no `objecten` kanaal and no abonnement, so
|
||||||
|
turning the client side on alone would have produced a delivery path that looks wired and
|
||||||
|
drops every message.
|
||||||
|
|
||||||
|
S-19b-2 (#153) wants the read projection sourced from register writes rather than
|
||||||
|
re-derived from ZGW zaak events. That needs the notifications to actually arrive. This ADR
|
||||||
|
builds the four missing pieces and lifts the ceiling.
|
||||||
|
|
||||||
|
## Decision
|
||||||
|
|
||||||
|
**Objecten publishes to the same NRC OpenZaak already publishes to, on the `objecten`
|
||||||
|
kanaal, delivered by its own Celery worker — provisioned declaratively on both sides,
|
||||||
|
exactly as ADR-0007 did for OpenZaak.**
|
||||||
|
|
||||||
|
- **Objecten** (`infra/objecten/setup_configuration/data.yaml`): a `zgw_consumers` service
|
||||||
|
`nrc` (api_type `nrc`) plus a `notifications_config` step naming it, and
|
||||||
|
`NOTIFICATIONS_DISABLED: "false"` in both compose files.
|
||||||
|
- **NRC** (`infra/opennotificaties/setup_configuration/data.yaml`): an `objecten` kanaal
|
||||||
|
alongside `zaken`.
|
||||||
|
- **`objecten-celery`**: a worker container on the Objecten image (`/celery_worker.sh`),
|
||||||
|
mirroring `oz-celery`, with `CELERY_BROKER_URL`/`CELERY_RESULT_BACKEND` on
|
||||||
|
`objecten-redis` db 1 (db 0 is already the cache).
|
||||||
|
|
||||||
|
### One NRC, one credential, one kanaal per publisher
|
||||||
|
|
||||||
|
Objecten reuses the `big-reference-seed` client OpenZaak publishes with. NRC verifies its
|
||||||
|
JWT and authorizes it against OpenZaak's Autorisaties API (ADR-0007), which grants that
|
||||||
|
client `heeft_alle_autorisaties` — so no second credential and no publisher-specific
|
||||||
|
authorization is needed. A second NRC, or a second credential, would buy isolation this
|
||||||
|
reference application has no use for.
|
||||||
|
|
||||||
|
The kanaal name is **not ours to choose**: the Objects API sends
|
||||||
|
`NOTIFICATIONS_KANAAL = "objecten"`. NRC rejects a publish to an unregistered kanaal
|
||||||
|
(`"Kanaal met deze naam bestaat niet"`), which is precisely what the failing check for this
|
||||||
|
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.
|
||||||
|
|
||||||
|
### Writers address Objecten as `objecten.local` — NRC rejects single-label hosts
|
||||||
|
|
||||||
|
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
|
||||||
|
|
||||||
|
```
|
||||||
|
{"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
|
||||||
|
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
|
||||||
|
|
||||||
|
`notifications_api_common` only schedules the send on transaction commit. Without a worker
|
||||||
|
the task sits in redis forever and every register write is silently undelivered — the exact
|
||||||
|
half-wired state ADR-0028 refused to ship. No `beat` for Objecten: it is a publisher, not a
|
||||||
|
subscriber, and `nrc-beat` already drains NRC's delivery queue.
|
||||||
|
|
||||||
|
## Verification
|
||||||
|
|
||||||
|
`make verify-objecten-notifications` (`infra/run-objecten-notifications-check.sh`, in the
|
||||||
|
CI `verify-stack` job) registers an abonnement on the `objecten` kanaal pointing at a
|
||||||
|
throwaway webhook sink, writes a `RegisterRecord` exactly as the ACL does on approval, and
|
||||||
|
asserts the notification reaches the sink. That is the whole chain in one assertion:
|
||||||
|
Objecten → `objecten-celery` → NRC → `nrc-beat` → the callback. Any missing piece — broker,
|
||||||
|
worker, kanaal, notifications config — shows up as a non-delivery rather than as a green
|
||||||
|
config.
|
||||||
|
|
||||||
|
## Consequences
|
||||||
|
|
||||||
|
**Positive**
|
||||||
|
|
||||||
|
- A register write is now observable by anything that subscribes, which is what S-19b-2
|
||||||
|
(#153) needs to make the projection a cache of Objecten rather than a re-derivation of ZGW.
|
||||||
|
- ADR-0028's ceiling is lifted: the delivery path is proven end to end, not merely configured.
|
||||||
|
|
||||||
|
**Negative / costs**
|
||||||
|
|
||||||
|
- 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.
|
||||||
|
- 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
|
||||||
|
read NRC's own delivery admin rather than building a retry layer here.
|
||||||
|
|
||||||
|
## Coupling rules touched (CLAUDE.md §8)
|
||||||
|
|
||||||
|
None bent. This is infrastructure between two upstream modules, over their documented
|
||||||
|
APIs; no service reaches another's database. §8.6 (idempotency at every event boundary)
|
||||||
|
applies to whatever consumes the new kanaal — S-19b-2's problem, not this slice's.
|
||||||
+7
-4
@@ -16,11 +16,14 @@ approval updates the existing object instead of creating a second one.
|
|||||||
# 1. Bring the stack up (Objecten, Objecttypen and the RegisterRecord objecttype come with it).
|
# 1. Bring the stack up (Objecten, Objecttypen and the RegisterRecord objecttype come with it).
|
||||||
make up
|
make up
|
||||||
#
|
#
|
||||||
# 2. End-to-end: the domain check submits a registration, walks it to Beoordelen, approves it, and
|
# 2. End-to-end: the walking-skeleton e2e submits, approves via the behandel portal, and then
|
||||||
# then asserts Objecten holds exactly one RegisterRecord for *that* registration:
|
# asserts Objecten holds exactly one RegisterRecord for *that* registration:
|
||||||
make verify-domain # → "OK — approval wrote the register record to Objecten: id=… status=INGESCHREVEN reference=…"
|
make verify-e2e # → "DigiD submit → … → behandelaar goedkeurt → public INGESCHREVEN"
|
||||||
#
|
#
|
||||||
# 3. See it for yourself — every register record currently in Objecten:
|
# 3. The ACL integration test proves the same writes against a live Objecten (upsert stays one object):
|
||||||
|
make verify-acl # → "Writes a register record and updates it in place on a second write"
|
||||||
|
#
|
||||||
|
# 4. See it for yourself — every register record currently in Objecten:
|
||||||
curl -s -H 'Authorization: Token 1234567890abcdef1234567890abcdef12345678' \
|
curl -s -H 'Authorization: Token 1234567890abcdef1234567890abcdef12345678' \
|
||||||
-H 'Accept-Crs: EPSG:4326' \
|
-H 'Accept-Crs: EPSG:4326' \
|
||||||
'http://localhost:8021/api/v2/objects' | python3 -m json.tool
|
'http://localhost:8021/api/v2/objects' | python3 -m json.tool
|
||||||
|
|||||||
@@ -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,12 +692,14 @@ 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"
|
||||||
# S-19a: Objecten refuses every write while its Notificaties config is absent
|
CELERY_BROKER_URL: redis://objecten-redis:6379/1
|
||||||
# (notifications_api_common raises rather than skipping, so POST /objects 500s). Objecten →
|
CELERY_RESULT_BACKEND: redis://objecten-redis:6379/1
|
||||||
# NRC is not wired yet — there is no broker, worker, kanaal or abonnement for it — so turn
|
# Publish register-record events to NRC on the `objecten` kanaal (S-19b-1, ADR-0029). The NRC
|
||||||
# notifications off rather than fake a delivery path that silently drops every message.
|
# service + notifications_config are provisioned by setup_configuration
|
||||||
# S-19b (#150) sources the projection from Objecten and turns this back on for real.
|
# (infra/objecten/setup_configuration/data.yaml), and objecten-celery below actually sends
|
||||||
NOTIFICATIONS_DISABLED: "true"
|
# them — notifications_api_common only queues the task. See ADR-0028 for why S-19a left this
|
||||||
|
# off until all four pieces existed.
|
||||||
|
NOTIFICATIONS_DISABLED: "false"
|
||||||
RUN_SETUP_CONFIG: "true"
|
RUN_SETUP_CONFIG: "true"
|
||||||
command: /setup_configuration.sh
|
command: /setup_configuration.sh
|
||||||
volumes:
|
volumes:
|
||||||
@@ -721,6 +724,28 @@ services:
|
|||||||
start_period: 30s
|
start_period: 30s
|
||||||
ports:
|
ports:
|
||||||
- "8021:8000"
|
- "8021:8000"
|
||||||
|
depends_on:
|
||||||
|
objecten-init:
|
||||||
|
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
|
||||||
|
|
||||||
|
# The celery worker that actually delivers Objecten's notifications to NRC (S-19b-1, ADR-0029).
|
||||||
|
# notifications_api_common only schedules the send on transaction commit; without a worker the
|
||||||
|
# task sits in redis forever and every register write is silently undelivered. Mirrors oz-celery.
|
||||||
|
# No beat: Objecten is a publisher, not a subscriber — nrc-beat drains the delivery queue.
|
||||||
|
objecten-celery:
|
||||||
|
image: docker.io/maykinmedia/objects-api:${OBJECTS_TAG:-3.4.0}
|
||||||
|
environment: *objecten-env-local
|
||||||
|
command: /celery_worker.sh
|
||||||
depends_on:
|
depends_on:
|
||||||
objecten-init:
|
objecten-init:
|
||||||
condition: service_completed_successfully
|
condition: service_completed_successfully
|
||||||
|
|||||||
@@ -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,12 +718,14 @@ 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"
|
||||||
# S-19a: Objecten refuses every write while its Notificaties config is absent
|
CELERY_BROKER_URL: redis://objecten-redis:6379/1
|
||||||
# (notifications_api_common raises rather than skipping, so POST /objects 500s). Objecten →
|
CELERY_RESULT_BACKEND: redis://objecten-redis:6379/1
|
||||||
# NRC is not wired yet — there is no broker, worker, kanaal or abonnement for it — so turn
|
# Publish register-record events to NRC on the `objecten` kanaal (S-19b-1, ADR-0029). The NRC
|
||||||
# notifications off rather than fake a delivery path that silently drops every message.
|
# service + notifications_config are provisioned by setup_configuration
|
||||||
# S-19b (#150) sources the projection from Objecten and turns this back on for real.
|
# (infra/objecten/setup_configuration/data.yaml), and objecten-celery below actually sends
|
||||||
NOTIFICATIONS_DISABLED: "true"
|
# them — notifications_api_common only queues the task. See ADR-0028 for why S-19a left this
|
||||||
|
# off until all four pieces existed.
|
||||||
|
NOTIFICATIONS_DISABLED: "false"
|
||||||
RUN_SETUP_CONFIG: "true"
|
RUN_SETUP_CONFIG: "true"
|
||||||
command: /setup_configuration.sh
|
command: /setup_configuration.sh
|
||||||
# data.yaml is streamed into this external volume by infra/seed-config.sh before start.
|
# data.yaml is streamed into this external volume by infra/seed-config.sh before start.
|
||||||
@@ -750,6 +753,28 @@ services:
|
|||||||
start_period: 30s
|
start_period: 30s
|
||||||
ports:
|
ports:
|
||||||
- "8021:8000"
|
- "8021:8000"
|
||||||
|
depends_on:
|
||||||
|
objecten-init:
|
||||||
|
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
|
||||||
|
|
||||||
|
# The celery worker that actually delivers Objecten's notifications to NRC (S-19b-1, ADR-0029).
|
||||||
|
# notifications_api_common only schedules the send on transaction commit; without a worker the
|
||||||
|
# task sits in redis forever and every register write is silently undelivered. Mirrors oz-celery.
|
||||||
|
# No beat: Objecten is a publisher, not a subscriber — nrc-beat drains the delivery queue.
|
||||||
|
objecten-celery:
|
||||||
|
image: docker.io/maykinmedia/objects-api:${OBJECTS_TAG:-3.4.0}
|
||||||
|
environment: *objecten-env
|
||||||
|
command: /celery_worker.sh
|
||||||
depends_on:
|
depends_on:
|
||||||
objecten-init:
|
objecten-init:
|
||||||
condition: service_completed_successfully
|
condition: service_completed_successfully
|
||||||
|
|||||||
@@ -0,0 +1,121 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""S-19b-1 (#152): driver for the Objecten → NRC notification check.
|
||||||
|
|
||||||
|
Registers an abonnement on the `objecten` kanaal pointing at the webhook sink, then writes a
|
||||||
|
RegisterRecord object exactly as the ACL's ObjectenGateway does (S-19a). The caller
|
||||||
|
(run-objecten-notifications-check.sh) watches the sink for the delivery — this only sets it up,
|
||||||
|
and prints `OBJECT_URL <url>` for the caller to grep on.
|
||||||
|
|
||||||
|
Delivery exercises the whole chain: Objecten → its celery worker → NRC → nrc-beat → the callback.
|
||||||
|
Anything missing (broker, worker, kanaal, notifications config) shows up as a non-delivery.
|
||||||
|
|
||||||
|
Stdlib only so it runs in a bare python:3-slim container on the compose network.
|
||||||
|
"""
|
||||||
|
import base64
|
||||||
|
import hashlib
|
||||||
|
import hmac
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
import time
|
||||||
|
import urllib.error
|
||||||
|
import urllib.request
|
||||||
|
|
||||||
|
OBJECTEN = os.environ["OBJECTEN"] # http://objecten:8000
|
||||||
|
OBJECTEN_TOKEN = os.environ["OBJECTEN_TOKEN"]
|
||||||
|
OBJECTTYPEN = os.environ["OBJECTTYPEN"] # http://objecttypen:8000
|
||||||
|
OBJECTTYPEN_TOKEN = os.environ["OBJECTTYPEN_TOKEN"]
|
||||||
|
NRC_BASE = os.environ["NRC_BASE"] # http://<nrc-ip>:8000
|
||||||
|
SINK_CALLBACK = os.environ["SINK_CALLBACK"] # http://<sink-ip>:9000/
|
||||||
|
SINK_AUTH = os.environ["SINK_AUTH"]
|
||||||
|
CLIENT_ID = os.environ.get("NRC_CLIENT_ID", "big-reference-seed")
|
||||||
|
SECRET = os.environ.get("NRC_SECRET", "insecure-dev-secret-change-me")
|
||||||
|
KANAAL = "objecten"
|
||||||
|
|
||||||
|
|
||||||
|
def mint():
|
||||||
|
"""The HS256 JWT NRC expects (same shape as infra/local/register-abonnement.py)."""
|
||||||
|
def seg(d):
|
||||||
|
return base64.urlsafe_b64encode(json.dumps(d).encode()).rstrip(b"=")
|
||||||
|
|
||||||
|
payload = seg({
|
||||||
|
"iss": CLIENT_ID, "iat": int(time.time()), "client_id": CLIENT_ID,
|
||||||
|
"user_id": CLIENT_ID, "user_representation": CLIENT_ID,
|
||||||
|
})
|
||||||
|
signing_input = seg({"typ": "JWT", "alg": "HS256"}) + b"." + payload
|
||||||
|
signature = base64.urlsafe_b64encode(
|
||||||
|
hmac.new(SECRET.encode(), signing_input, hashlib.sha256).digest()).rstrip(b"=")
|
||||||
|
return (signing_input + b"." + signature).decode()
|
||||||
|
|
||||||
|
|
||||||
|
def nrc(method, url, body=None):
|
||||||
|
"""Call NRC. `url` may be a path or an absolute URL (the list returns absolute ones)."""
|
||||||
|
data = json.dumps(body).encode() if body is not None else None
|
||||||
|
req = urllib.request.Request(
|
||||||
|
url if url.startswith("http") else f"{NRC_BASE}{url}", data=data, method=method,
|
||||||
|
headers={"Authorization": f"Bearer {mint()}", "Content-Type": "application/json"})
|
||||||
|
try:
|
||||||
|
with urllib.request.urlopen(req, timeout=15) as r:
|
||||||
|
return json.load(r) if r.length != 0 else {}
|
||||||
|
except urllib.error.HTTPError as e:
|
||||||
|
# The body carries the reason (e.g. an unregistered kanaal); the status alone does not.
|
||||||
|
raise SystemExit(f"FAIL — NRC {method} {url} → {e.code}: {e.read().decode(errors='replace')[:400]}")
|
||||||
|
|
||||||
|
|
||||||
|
def token_api(base, token, method, path, body=None, crs=False):
|
||||||
|
data = json.dumps(body).encode() if body is not None else None
|
||||||
|
headers = {"Authorization": f"Token {token}"}
|
||||||
|
if body is not None:
|
||||||
|
headers["Content-Type"] = "application/json"
|
||||||
|
if crs:
|
||||||
|
headers["Accept-Crs"] = "EPSG:4326"
|
||||||
|
if body is not None:
|
||||||
|
headers["Content-Crs"] = "EPSG:4326"
|
||||||
|
req = urllib.request.Request(f"{base}{path}", data=data, method=method, headers=headers)
|
||||||
|
with urllib.request.urlopen(req, timeout=15) as r:
|
||||||
|
return json.load(r) if r.length != 0 else {}
|
||||||
|
|
||||||
|
|
||||||
|
def subscribe():
|
||||||
|
"""Register an abonnement on the objecten kanaal, replacing a stale one for the same callback."""
|
||||||
|
# NRC returns a bare list here, not a paginated envelope.
|
||||||
|
for existing in nrc("GET", "/api/v1/abonnement") or []:
|
||||||
|
if existing.get("callbackUrl") == SINK_CALLBACK:
|
||||||
|
nrc("DELETE", existing["url"])
|
||||||
|
nrc("POST", "/api/v1/abonnement", {
|
||||||
|
"callbackUrl": SINK_CALLBACK,
|
||||||
|
"auth": SINK_AUTH,
|
||||||
|
"kanalen": [{"naam": KANAAL, "filters": {}}],
|
||||||
|
})
|
||||||
|
print(f">> abonnement on '{KANAAL}' -> {SINK_CALLBACK}")
|
||||||
|
|
||||||
|
|
||||||
|
def objecttype_url():
|
||||||
|
results = token_api(OBJECTTYPEN, OBJECTTYPEN_TOKEN, "GET", "/api/v2/objecttypes").get("results", [])
|
||||||
|
match = next((o for o in results if o.get("name") == "RegisterRecord"), None)
|
||||||
|
if not match:
|
||||||
|
print("FAIL — no RegisterRecord objecttype in Objecttypen", file=sys.stderr)
|
||||||
|
raise SystemExit(1)
|
||||||
|
return match["url"]
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
subscribe()
|
||||||
|
reference = f"NOTIF-{int(time.time())}"
|
||||||
|
created = token_api(OBJECTEN, OBJECTEN_TOKEN, "POST", "/api/v2/objects", {
|
||||||
|
"type": objecttype_url(),
|
||||||
|
"record": {
|
||||||
|
"typeVersion": 1,
|
||||||
|
"data": {"id": f"zaak-{reference}", "status": "INGESCHREVEN", "reference": reference},
|
||||||
|
"startAt": time.strftime("%Y-%m-%d"),
|
||||||
|
},
|
||||||
|
}, crs=True)
|
||||||
|
print(f">> wrote RegisterRecord {created['url']}")
|
||||||
|
# An NRC notification carries no record data — only hoofdObject/resourceUrl — so the object
|
||||||
|
# URL, not the reference in its data, is what the caller can correlate the delivery on.
|
||||||
|
print(f"OBJECT_URL {created['url']}")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
sys.exit(main())
|
||||||
@@ -18,6 +18,16 @@ zgw_consumers:
|
|||||||
auth_type: api_key
|
auth_type: api_key
|
||||||
header_key: Authorization
|
header_key: Authorization
|
||||||
header_value: Token 0123456789abcdef0123456789abcdef01234567
|
header_value: Token 0123456789abcdef0123456789abcdef01234567
|
||||||
|
# (1b) The NRC Objecten publishes register-record events to (S-19b-1, ADR-0029). Same shape and
|
||||||
|
# same big-reference-seed credential OpenZaak publishes with — NRC verifies the JWT and
|
||||||
|
# authorizes it via OpenZaak's AC, which grants that client heeft_alle_autorisaties.
|
||||||
|
- identifier: nrc
|
||||||
|
label: Open Notificaties
|
||||||
|
api_type: nrc
|
||||||
|
api_root: http://nrc-web:8000/api/v1/
|
||||||
|
auth_type: zgw
|
||||||
|
client_id: big-reference-seed
|
||||||
|
secret: insecure-dev-secret-change-me
|
||||||
|
|
||||||
# (2) Permit the RegisterRecord objecttype (S-19a). Objecten refuses to store an object whose
|
# (2) Permit the RegisterRecord objecttype (S-19a). Objecten refuses to store an object whose
|
||||||
# objecttype it has not been configured with ("ObjectType with url=… is not configured"), and it
|
# objecttype it has not been configured with ("ObjectType with url=… is not configured"), and it
|
||||||
@@ -40,3 +50,10 @@ tokenauth:
|
|||||||
email: admin@localhost
|
email: admin@localhost
|
||||||
organization: Respellion
|
organization: Respellion
|
||||||
is_superuser: true
|
is_superuser: true
|
||||||
|
|
||||||
|
# (4) Point Objecten's notifications at that NRC service (S-19b-1, ADR-0029). Requires
|
||||||
|
# NOTIFICATIONS_DISABLED=false plus a celery broker + worker — without the worker the message is
|
||||||
|
# queued and never sent, which is exactly the half-wired state S-19a refused to ship (ADR-0028).
|
||||||
|
notifications_config_enable: true
|
||||||
|
notifications_config:
|
||||||
|
notifications_api_service_identifier: nrc
|
||||||
|
|||||||
@@ -29,7 +29,9 @@ autorisaties_api_config_enable: true
|
|||||||
autorisaties_api:
|
autorisaties_api:
|
||||||
authorizations_api_service_identifier: openzaak-ac
|
authorizations_api_service_identifier: openzaak-ac
|
||||||
|
|
||||||
# 4. The kanaal OpenZaak publishes zaak events on.
|
# 4. The kanalen publishers announce on: `zaken` (OpenZaak) and `objecten` (Objecten, S-19b-1).
|
||||||
|
# Both authenticate with the big-reference-seed credential above, which OpenZaak's AC grants
|
||||||
|
# heeft_alle_autorisaties — so no separate publisher authorization is needed for Objecten.
|
||||||
notifications_kanalen_config_enable: true
|
notifications_kanalen_config_enable: true
|
||||||
notifications_kanalen_config:
|
notifications_kanalen_config:
|
||||||
items:
|
items:
|
||||||
@@ -39,3 +41,11 @@ notifications_kanalen_config:
|
|||||||
- bronorganisatie
|
- bronorganisatie
|
||||||
- zaaktype
|
- zaaktype
|
||||||
- vertrouwelijkheidaanduiding
|
- vertrouwelijkheidaanduiding
|
||||||
|
# 5. The kanaal Objecten publishes register-record events on (S-19b-1, ADR-0029). Its name is
|
||||||
|
# fixed by the Objects API itself (NOTIFICATIONS_KANAAL = "objecten"), not chosen here. The
|
||||||
|
# filter set matches what the Objects API sends as kenmerken, so an abonnement can narrow by
|
||||||
|
# objecttype rather than receiving every object write in the register.
|
||||||
|
- naam: objecten
|
||||||
|
documentatie_link: https://objects-and-objecttypes-api.readthedocs.io/
|
||||||
|
filters:
|
||||||
|
- object_type
|
||||||
|
|||||||
@@ -1,88 +0,0 @@
|
|||||||
#!/usr/bin/env python3
|
|
||||||
"""S-19a (#149): prove the approval path wrote the register record to Objecten.
|
|
||||||
|
|
||||||
Given the registration whose Beoordelen task the caller just completed with `goedkeuren`, assert
|
|
||||||
that Objecten holds exactly one RegisterRecord object for it, with status INGESCHREVEN and the
|
|
||||||
registration's reference — i.e. the ACL's Objecten hop ran, the record validates against the
|
|
||||||
objecttype schema (Objecten rejects a mismatch), and it carries no personal data (ADR-0027/0028).
|
|
||||||
|
|
||||||
Stdlib only so it runs in a bare python:3-slim container on the compose network.
|
|
||||||
"""
|
|
||||||
import json
|
|
||||||
import os
|
|
||||||
import sys
|
|
||||||
import time
|
|
||||||
import urllib.error
|
|
||||||
import urllib.parse
|
|
||||||
import urllib.request
|
|
||||||
|
|
||||||
OBJECTTYPEN = os.environ["OBJECTTYPEN"] # http://<ip>:8000
|
|
||||||
OBJECTTYPEN_TOKEN = os.environ["OBJECTTYPEN_TOKEN"]
|
|
||||||
OBJECTEN = os.environ["OBJECTEN"] # http://<ip>:8000
|
|
||||||
OBJECTEN_TOKEN = os.environ["OBJECTEN_TOKEN"]
|
|
||||||
REFERENCE = os.environ["REGISTRATION_REFERENCE"]
|
|
||||||
TIMEOUT = int(os.environ.get("REGISTER_RECORD_TIMEOUT", "60"))
|
|
||||||
NAME = "RegisterRecord"
|
|
||||||
# The register is world-readable: a record must never carry anything identifying (ADR-0027).
|
|
||||||
ALLOWED_FIELDS = {"id", "status", "reference"}
|
|
||||||
|
|
||||||
|
|
||||||
def get(base, token, path, crs=False):
|
|
||||||
headers = {"Authorization": f"Token {token}"}
|
|
||||||
if crs:
|
|
||||||
headers["Accept-Crs"] = "EPSG:4326"
|
|
||||||
req = urllib.request.Request(f"{base}{path}", headers=headers)
|
|
||||||
with urllib.request.urlopen(req, timeout=10) as r:
|
|
||||||
return json.load(r)
|
|
||||||
|
|
||||||
|
|
||||||
def objecttype_url():
|
|
||||||
"""The RegisterRecord objecttype URL, or None while registerrecord-init has yet to run."""
|
|
||||||
ots = get(OBJECTTYPEN, OBJECTTYPEN_TOKEN, "/api/v2/objecttypes").get("results", [])
|
|
||||||
match = next((o for o in ots if o.get("name") == NAME), None)
|
|
||||||
return match["url"] if match else None
|
|
||||||
|
|
||||||
|
|
||||||
def check():
|
|
||||||
"""Return (ok, detail). Raises on transport errors so the caller can retry."""
|
|
||||||
type_url = objecttype_url()
|
|
||||||
if not type_url:
|
|
||||||
return False, f"no objecttype named {NAME!r} in Objecttypen yet"
|
|
||||||
|
|
||||||
query = urllib.parse.urlencode({"type": type_url, "data_attrs": f"reference__exact__{REFERENCE}"})
|
|
||||||
results = get(OBJECTEN, OBJECTEN_TOKEN, f"/api/v2/objects?{query}", crs=True).get("results", [])
|
|
||||||
if not results:
|
|
||||||
return False, f"no RegisterRecord object with reference {REFERENCE}"
|
|
||||||
if len(results) > 1:
|
|
||||||
# The ACL upserts, so a replayed approval must update rather than duplicate (§8.6).
|
|
||||||
return False, f"{len(results)} RegisterRecord objects for reference {REFERENCE} — the write is not idempotent"
|
|
||||||
|
|
||||||
data = (results[0].get("record") or {}).get("data") or {}
|
|
||||||
if data.get("status") != "INGESCHREVEN":
|
|
||||||
return False, f"record status is {data.get('status')!r}, expected 'INGESCHREVEN'"
|
|
||||||
if not data.get("id"):
|
|
||||||
return False, "record carries no id (the zaak the projection keys on)"
|
|
||||||
extra = set(data) - ALLOWED_FIELDS
|
|
||||||
if extra:
|
|
||||||
return False, f"record leaks non-public fields: {sorted(extra)}"
|
|
||||||
return True, f"id={data['id']} status={data['status']} reference={data['reference']}"
|
|
||||||
|
|
||||||
|
|
||||||
def main():
|
|
||||||
deadline = time.time() + TIMEOUT
|
|
||||||
detail = "no attempt"
|
|
||||||
while time.time() < deadline:
|
|
||||||
try:
|
|
||||||
ok, detail = check()
|
|
||||||
if ok:
|
|
||||||
print(f"OK — approval wrote the register record to Objecten: {detail}")
|
|
||||||
return 0
|
|
||||||
except (urllib.error.URLError, ConnectionError, TimeoutError) as e:
|
|
||||||
detail = f"transport: {e}"
|
|
||||||
time.sleep(3)
|
|
||||||
print(f"FAIL — {detail}", file=sys.stderr)
|
|
||||||
return 1
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
sys.exit(main())
|
|
||||||
@@ -142,30 +142,6 @@ still="$(printf '%s' "$resp" | task_for_reg "$reg_id")"
|
|||||||
[ -z "$still" ] || { echo "FAIL — Beoordelen task $still still active after completion" >&2; exit 1; }
|
[ -z "$still" ] || { echo "FAIL — Beoordelen task $still still active after completion" >&2; exit 1; }
|
||||||
echo "OK — behandelaar claimed and completed the Beoordelen task; the registratie process finished"
|
echo "OK — behandelaar claimed and completed the Beoordelen task; the registratie process finished"
|
||||||
|
|
||||||
# ── S-19a: the same approval also wrote the canonical register record to Objecten (ADR-0028).
|
|
||||||
# Assert it for THIS registration (matched on its reference) rather than "some INGESCHREVEN record":
|
|
||||||
# the shared verify stack carries records from earlier runs. The container-name filters are anchored
|
|
||||||
# on the compose replica suffix so they don't also match objecten-db / objecttypen-db.
|
|
||||||
echo ">> asserting the approval wrote the register record to Objecten (S-19a)"
|
|
||||||
obj="$(docker ps -q --filter 'name=objecten[-_][0-9]+$' | head -1)"
|
|
||||||
objt="$(docker ps -q --filter 'name=objecttypen[-_][0-9]+$' | head -1)"
|
|
||||||
[ -n "$obj" ] || { echo "FAIL — no running objecten container" >&2; exit 1; }
|
|
||||||
[ -n "$objt" ] || { echo "FAIL — no running objecttypen container" >&2; exit 1; }
|
|
||||||
rr="$(docker create --network "$net" \
|
|
||||||
-e "OBJECTEN=http://$(ip "$obj"):8000" \
|
|
||||||
-e "OBJECTEN_TOKEN=${OBJECTEN_TOKEN:-1234567890abcdef1234567890abcdef12345678}" \
|
|
||||||
-e "OBJECTTYPEN=http://$(ip "$objt"):8000" \
|
|
||||||
-e "OBJECTTYPEN_TOKEN=${OBJECTTYPEN_TOKEN:-0123456789abcdef0123456789abcdef01234567}" \
|
|
||||||
-e "REGISTRATION_REFERENCE=$reg_id" \
|
|
||||||
python:3-slim python /register-record-check.py)"
|
|
||||||
docker cp "$here/register-record-check.py" "$rr:/register-record-check.py" >/dev/null
|
|
||||||
rr_rc=0; docker start -a "$rr" || rr_rc=$?
|
|
||||||
docker rm -f "$rr" >/dev/null
|
|
||||||
if [ "$rr_rc" -ne 0 ]; then
|
|
||||||
acl="$(docker ps -q --filter 'name=[-_]acl[-_]' | head -1)"
|
|
||||||
[ -n "$acl" ] && { echo "--- acl log ---" >&2; docker logs "$acl" 2>&1 | tail -20 >&2; }
|
|
||||||
exit "$rr_rc"
|
|
||||||
fi
|
|
||||||
|
|
||||||
# ── S-11: withdrawal. A second registration parks at Beoordelen; the citizen withdraws it via the
|
# ── S-11: withdrawal. A second registration parks at Beoordelen; the citizen withdraws it via the
|
||||||
# domain, which delivers the RegistratieIngetrokken message to the task's execution, tripping the
|
# domain, which delivers the RegistratieIngetrokken message to the task's execution, tripping the
|
||||||
|
|||||||
Executable
+83
@@ -0,0 +1,83 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
#
|
||||||
|
# S-19b-1 (#152): verify the Objecten → NRC notification path against an ALREADY-RUNNING full
|
||||||
|
# stack. Registers an abonnement on the `objecten` kanaal pointing at a throwaway webhook sink,
|
||||||
|
# writes a RegisterRecord object (exactly as the ACL does on approval, S-19a), and asserts the sink
|
||||||
|
# receives the notification.
|
||||||
|
#
|
||||||
|
# This is the whole publish chain in one assertion: Objecten → its celery worker → NRC → nrc-beat →
|
||||||
|
# the subscriber callback. S-19a deliberately left it disconnected (ADR-0028); this proves it is
|
||||||
|
# connected for real, rather than merely configured.
|
||||||
|
#
|
||||||
|
# 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 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
|
||||||
|
|
||||||
|
here="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
|
SINK_AUTH="Bearer objecten-notification-sink-token"
|
||||||
|
|
||||||
|
cleanup() { docker rm -f rr-osink rr-overify >/dev/null 2>&1 || true; }
|
||||||
|
trap cleanup EXIT
|
||||||
|
|
||||||
|
ip() { docker inspect -f '{{range .NetworkSettings.Networks}}{{.IPAddress}}{{end}}' "$1"; }
|
||||||
|
|
||||||
|
# Anchored on the compose replica suffix so they don't also match objecten-db / objecten-redis.
|
||||||
|
obj="$(docker ps -q --filter 'name=objecten[-_][0-9]+$' | head -1)"
|
||||||
|
nrc="$(docker ps -q --filter 'name=nrc-web' | head -1)"
|
||||||
|
[ -n "$obj" ] || { echo "ERROR: no running objecten container — bring the stack up first" >&2; exit 1; }
|
||||||
|
[ -n "$nrc" ] || { echo "ERROR: no running nrc-web container — bring the stack up first" >&2; exit 1; }
|
||||||
|
net="$(docker inspect -f '{{range $k,$_ := .NetworkSettings.Networks}}{{$k}}{{"\n"}}{{end}}' "$obj" | head -1)"
|
||||||
|
nrc_ip="$(ip "$nrc")"
|
||||||
|
echo ">> network=$net nrc=$nrc_ip"
|
||||||
|
|
||||||
|
echo ">> starting the webhook sink"
|
||||||
|
docker rm -f rr-osink >/dev/null 2>&1 || true
|
||||||
|
sink="$(docker create --network "$net" --name rr-osink -e "EXPECTED_AUTH=$SINK_AUTH" \
|
||||||
|
python:3-slim python /sink.py)"
|
||||||
|
docker cp "$here/notification-sink.py" "$sink:/sink.py" >/dev/null
|
||||||
|
docker start "$sink" >/dev/null
|
||||||
|
sleep 1
|
||||||
|
sink_ip="$(ip rr-osink)"
|
||||||
|
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.local:8000" \
|
||||||
|
-e "OBJECTEN_TOKEN=${OBJECTEN_TOKEN:-1234567890abcdef1234567890abcdef12345678}" \
|
||||||
|
-e "OBJECTTYPEN=http://objecttypen:8000" \
|
||||||
|
-e "OBJECTTYPEN_TOKEN=${OBJECTTYPEN_TOKEN:-0123456789abcdef0123456789abcdef01234567}" \
|
||||||
|
-e "NRC_BASE=http://$nrc_ip:8000" \
|
||||||
|
-e "SINK_CALLBACK=http://$sink_ip:9000/" -e "SINK_AUTH=$SINK_AUTH" \
|
||||||
|
python:3-slim python /driver.py)"
|
||||||
|
docker cp "$here/objecten-notifications-check.py" "$drv:/driver.py" >/dev/null
|
||||||
|
docker start -a "$drv"
|
||||||
|
object_url="$(docker logs rr-overify 2>/dev/null | sed -n 's/^OBJECT_URL //p' | head -1)"
|
||||||
|
docker rm -f rr-overify >/dev/null
|
||||||
|
[ -n "$object_url" ] || { echo "FAIL — the driver did not write a RegisterRecord" >&2; exit 1; }
|
||||||
|
echo ">> wrote $object_url"
|
||||||
|
|
||||||
|
# Correlate on the object URL: a notification carries hoofdObject/resourceUrl, never the record
|
||||||
|
# data, so the reference inside the record is not in the delivered message.
|
||||||
|
echo ">> waiting for the notification to reach the sink"
|
||||||
|
for _ in $(seq 1 "${NOTIFICATION_TRIES:-40}"); do
|
||||||
|
if docker logs rr-osink 2>&1 | grep -qF "$object_url"; then
|
||||||
|
echo "OK — Objecten published to NRC and the abonnement delivered it:"
|
||||||
|
docker logs rr-osink 2>&1 | grep -F "$object_url" | tail -1 | cut -c1-500
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
sleep 2
|
||||||
|
done
|
||||||
|
|
||||||
|
echo "FAIL — no 'objecten' notification for $object_url reached the sink." >&2
|
||||||
|
echo " Objecten accepted the write, so the gap is downstream: the celery broker/worker," >&2
|
||||||
|
echo " the kanaal registration, or Objecten's notifications_config." >&2
|
||||||
|
echo "--- sink log ---" >&2; docker logs rr-osink 2>&1 | tail -8 >&2
|
||||||
|
echo "--- objecten log ---" >&2; docker logs "$obj" 2>&1 | tail -15 >&2
|
||||||
|
exit 1
|
||||||
@@ -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));
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { expect, test } from '@playwright/test';
|
import { expect, request, test } from '@playwright/test';
|
||||||
|
|
||||||
// Walking-skeleton happy path (S-08d + S-09 + S-09b + S-12 + S-10a): a zorgprofessional logs in via
|
// Walking-skeleton happy path (S-08d + S-09 + S-09b + S-12 + S-10a): a zorgprofessional logs in via
|
||||||
// mock DigiD and submits through the self-service portal → BFF → domain; the entry appears in the
|
// mock DigiD and submits through the self-service portal → BFF → domain; the entry appears in the
|
||||||
@@ -109,4 +109,53 @@ test('DigiD submit → public INGEDIEND → documenten → behandelaar goedkeurt
|
|||||||
return staff.getByRole('row', { name: reference }).getByRole('cell', { name: 'INGESCHREVEN' }).count();
|
return staff.getByRole('row', { name: reference }).getByRole('cell', { name: 'INGESCHREVEN' }).count();
|
||||||
}, { timeout: 30_000, intervals: [1_000, 2_000, 3_000, 5_000] })
|
}, { timeout: 30_000, intervals: [1_000, 2_000, 3_000, 5_000] })
|
||||||
.toBeGreaterThan(0);
|
.toBeGreaterThan(0);
|
||||||
|
|
||||||
|
// S-19a: the same approval also wrote the canonical register record to Objecten (ADR-0028).
|
||||||
|
// Asserted here rather than in verify-domain because this is the only check that drives a *real*
|
||||||
|
// approval — verify-domain completes the Beoordelen task straight through Flowable REST, which
|
||||||
|
// bypasses the domain `decide` path that calls the ACL.
|
||||||
|
const records = await registerRecordsFor(reference);
|
||||||
|
// Matched on OUR reference: the verify stack is shared and holds records from earlier checks.
|
||||||
|
expect(records, `expected exactly one RegisterRecord for ${reference}`).toHaveLength(1);
|
||||||
|
expect(records[0].status).toBe('INGESCHREVEN');
|
||||||
|
// The register is world-readable, so the record must carry nothing but the public-safe fields
|
||||||
|
// (ADR-0027) — Objecten's own schema validation enforces this, and this proves it end to end.
|
||||||
|
expect(Object.keys(records[0]).sort()).toEqual(['id', 'reference', 'status']);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const OBJECTEN = process.env.OBJECTEN_URL ?? 'http://objecten:8000';
|
||||||
|
const OBJECTTYPEN = process.env.OBJECTTYPEN_URL ?? 'http://objecttypen:8000';
|
||||||
|
const OBJECTEN_TOKEN = process.env.OBJECTEN_TOKEN ?? '1234567890abcdef1234567890abcdef12345678';
|
||||||
|
const OBJECTTYPEN_TOKEN = process.env.OBJECTTYPEN_TOKEN ?? '0123456789abcdef0123456789abcdef01234567';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The RegisterRecord objects Objecten holds for a registration reference.
|
||||||
|
*
|
||||||
|
* The objecttype is resolved by name rather than pinned: Objecttypen echoes the request Host into
|
||||||
|
* the objecttype `url`, and Objecten only accepts the one matching its configured api_root — so
|
||||||
|
* both must be reached by service name, exactly as the ACL reaches them (ADR-0028).
|
||||||
|
*/
|
||||||
|
async function registerRecordsFor(reference: string): Promise<Record<string, string>[]> {
|
||||||
|
const api = await request.newContext();
|
||||||
|
try {
|
||||||
|
const types = await api.get(`${OBJECTTYPEN}/api/v2/objecttypes`, {
|
||||||
|
headers: { Authorization: `Token ${OBJECTTYPEN_TOKEN}` },
|
||||||
|
});
|
||||||
|
expect(types.ok(), `Objecttypen returned ${types.status()}`).toBeTruthy();
|
||||||
|
const objecttype = ((await types.json()).results as { url: string; name: string }[]).find(
|
||||||
|
(o) => o.name === 'RegisterRecord',
|
||||||
|
);
|
||||||
|
if (!objecttype) throw new Error('the RegisterRecord objecttype is not registered in Objecttypen');
|
||||||
|
|
||||||
|
const objects = await api.get(`${OBJECTEN}/api/v2/objects`, {
|
||||||
|
headers: { Authorization: `Token ${OBJECTEN_TOKEN}`, 'Accept-Crs': 'EPSG:4326' },
|
||||||
|
params: { type: objecttype.url, data_attrs: `reference__exact__${reference}` },
|
||||||
|
});
|
||||||
|
expect(objects.ok(), `Objecten returned ${objects.status()}: ${await objects.text()}`).toBeTruthy();
|
||||||
|
return ((await objects.json()).results as { record: { data: Record<string, string> } }[]).map(
|
||||||
|
(o) => o.record.data,
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
await api.dispose();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user