diff --git a/infra/register-record-check.py b/infra/register-record-check.py new file mode 100644 index 0000000..e8e16fd --- /dev/null +++ b/infra/register-record-check.py @@ -0,0 +1,88 @@ +#!/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://:8000 +OBJECTTYPEN_TOKEN = os.environ["OBJECTTYPEN_TOKEN"] +OBJECTEN = os.environ["OBJECTEN"] # http://: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()) diff --git a/infra/run-domain-check.sh b/infra/run-domain-check.sh index 833032a..9f12b75 100755 --- a/infra/run-domain-check.sh +++ b/infra/run-domain-check.sh @@ -142,6 +142,31 @@ still="$(printf '%s' "$resp" | task_for_reg "$reg_id")" [ -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" +# ── 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 # domain, which delivers the RegistratieIngetrokken message to the task's execution, tripping the # BPMN boundary event so the process ends and the Beoordelen task disappears (ADR-0014). ────────────