From bea04549dd535de817bf98fada75e82042497f09 Mon Sep 17 00:00:00 2001 From: Edwin van den Houdt Date: Thu, 30 Jul 2026 08:07:50 +0200 Subject: [PATCH] feat(zgw): finish WP-52 OpenZaak Notificaties (NRC) webhook slice Endpoint/DTO/options landed already in c4dd846; this closes the loop with NotificatieTests.cs (accept/reject/missing-header, asserting the AuthzAuditStore row), missing appsettings.json keys (also backfills DrcBaseUrl/ InformatieobjecttypeUrls, stale since WP-51), and the webhook + abonnement provisioning docs. Co-Authored-By: Claude Sonnet 5 --- backend/src/BigRegister.Api/appsettings.json | 8 +- .../BigRegister.Tests/NotificatieTests.cs | 72 ++++++++ .../TestWebApplicationFactory.cs | 7 +- docs/project/backlog/README.md | 2 +- .../backlog/WP-52-openzaak-notificaties.md | 28 ++- docs/reference/openzaak-integration.md | 46 ++++- documentation.json | 164 ++++++++++-------- 7 files changed, 243 insertions(+), 84 deletions(-) create mode 100644 backend/tests/BigRegister.Tests/NotificatieTests.cs diff --git a/backend/src/BigRegister.Api/appsettings.json b/backend/src/BigRegister.Api/appsettings.json index 1b2ac0d..49c9307 100644 --- a/backend/src/BigRegister.Api/appsettings.json +++ b/backend/src/BigRegister.Api/appsettings.json @@ -6,7 +6,7 @@ } }, "AllowedHosts": "*", - "_Zgw": "WP-49/50: set Enabled=true + the URLs/credentials/RSINs/zaaktype map to source + create cases against a real OpenZaak. Off = local SQLite store (offline POC default).", + "_Zgw": "WP-49..52: set Enabled=true + the URLs/credentials/RSINs/type maps to source, create and document cases against a real OpenZaak; NrcBaseUrl/NotificatieAuthorization configure the inbound notificaties webhook. Off = local SQLite store (offline POC default).", "Zgw": { "Enabled": false, "ZrcBaseUrl": "", @@ -21,6 +21,10 @@ "registratie": "", "herregistratie": "", "intake": "" - } + }, + "DrcBaseUrl": "", + "InformatieobjecttypeUrls": {}, + "NrcBaseUrl": "", + "NotificatieAuthorization": "" } } diff --git a/backend/tests/BigRegister.Tests/NotificatieTests.cs b/backend/tests/BigRegister.Tests/NotificatieTests.cs new file mode 100644 index 0000000..422114a --- /dev/null +++ b/backend/tests/BigRegister.Tests/NotificatieTests.cs @@ -0,0 +1,72 @@ +using System.Net; +using System.Net.Http.Json; +using BigRegister.Api.Contracts; +using BigRegister.Api.Zgw; +using Microsoft.AspNetCore.Mvc.Testing; + +namespace BigRegister.Tests; + +/// WP-52: the inbound Notificaties (NRC) webhook — auth accept/reject + the audit trail it +/// writes via AuthzAuditStore (no Principal exists for an NRC caller, so this doesn't go +/// through the Principal-shaped AuditAuthz helper the user-facing endpoints use). +public class NotificatieTests(TestWebApplicationFactory factory) : IClassFixture +{ + private readonly HttpClient _client = factory.CreateClient(); + + private static NotificatieDto Sample(string zaakUrl) => new( + Kanaal: "zaken", + HoofdObject: zaakUrl, + Resource: "zaak", + ResourceUrl: zaakUrl, + Actie: "update", + Aanmaakdatum: DateTimeOffset.UtcNow, + Kenmerken: null); + + private async Task Post(string? authorization, string zaakUrl) + { + var req = new HttpRequestMessage(HttpMethod.Post, "/api/v1/zgw/notificaties") + { + Content = JsonContent.Create(Sample(zaakUrl)), + }; + if (authorization is not null) req.Headers.TryAddWithoutValidation("Authorization", authorization); + return await _client.SendAsync(req); + } + + private async Task> AuditLog() + { + var req = new HttpRequestMessage(HttpMethod.Get, "/api/v1/admin/audit"); + req.Headers.Add("X-Role", "admin"); + var res = await _client.SendAsync(req); + res.EnsureSuccessStatusCode(); + return (await res.Content.ReadFromJsonAsync>())!; + } + + [Fact] + public async Task Correct_shared_secret_is_accepted_and_recorded() + { + var zaak = $"https://open-zaak.example/zaken/api/v1/zaken/{Guid.NewGuid()}"; + var res = await Post("test-nrc-secret", zaak); + + Assert.Equal(HttpStatusCode.NoContent, res.StatusCode); + Assert.Contains(await AuditLog(), e => + e.Action == "zgw:notificatie" && e.Resource == zaak && e.Decision == "allow" && e.Role == "nrc"); + } + + [Fact] + public async Task Wrong_secret_is_rejected_and_recorded() + { + var zaak = $"https://open-zaak.example/zaken/api/v1/zaken/{Guid.NewGuid()}"; + var res = await Post("not-the-secret", zaak); + + Assert.Equal(HttpStatusCode.Unauthorized, res.StatusCode); + Assert.Contains(await AuditLog(), e => + e.Action == "zgw:notificatie" && e.Resource == zaak && e.Decision == "deny"); + } + + [Fact] + public async Task Missing_authorization_header_is_rejected() + { + var zaak = $"https://open-zaak.example/zaken/api/v1/zaken/{Guid.NewGuid()}"; + Assert.Equal(HttpStatusCode.Unauthorized, (await Post(null, zaak)).StatusCode); + } +} diff --git a/backend/tests/BigRegister.Tests/TestWebApplicationFactory.cs b/backend/tests/BigRegister.Tests/TestWebApplicationFactory.cs index 01eb232..0d343d4 100644 --- a/backend/tests/BigRegister.Tests/TestWebApplicationFactory.cs +++ b/backend/tests/BigRegister.Tests/TestWebApplicationFactory.cs @@ -27,8 +27,11 @@ public sealed class TestWebApplicationFactory : WebApplicationFactory { private readonly string _dbPath = Path.Combine(Path.GetTempPath(), $"bigregister-test-{Guid.NewGuid():N}.db"); - protected override void ConfigureWebHost(IWebHostBuilder builder) => - builder.UseSetting("ConnectionStrings:AppDb", $"Data Source={_dbPath}"); + protected override void ConfigureWebHost(IWebHostBuilder builder) => builder + .UseSetting("ConnectionStrings:AppDb", $"Data Source={_dbPath}") + // WP-52: a fixed shared secret so NotificatieTests can exercise the accept path — + // the appsettings.json default is "" (reject everything), which no test should rely on. + .UseSetting("Zgw:NotificatieAuthorization", "test-nrc-secret"); protected override void Dispose(bool disposing) { diff --git a/docs/project/backlog/README.md b/docs/project/backlog/README.md index c7778f9..b6d1eff 100644 --- a/docs/project/backlog/README.md +++ b/docs/project/backlog/README.md @@ -102,7 +102,7 @@ for its existing violations, so every WP ends green. | [WP-49](WP-49-openzaak-zaken-read-seam.md) | OpenZaak zaken read seam (IZaakSource + ZGW client, config-gated, offline default) | 9 · OpenZaak/ZGW | done | | [WP-50](WP-50-openzaak-create-zaak.md) | OpenZaak create-zaak (first write slice) | 9 · OpenZaak/ZGW | done | | [WP-51](WP-51-openzaak-documenten.md) | OpenZaak Documenten (DRC) upload + zaak link | 9 · OpenZaak/ZGW | done | -| [WP-52](WP-52-openzaak-notificaties.md) | OpenZaak Notificaties (NRC) live status via webhook | 9 · OpenZaak/ZGW | todo | +| [WP-52](WP-52-openzaak-notificaties.md) | OpenZaak Notificaties (NRC) live status via webhook | 9 · OpenZaak/ZGW | done | | [WP-53](WP-53-inbound-identity-and-citizen-scoping.md) | Inbound identity seam + citizen-scoping (per-request BSN, ZGW audit claims) | 9 · OpenZaak/ZGW | todo | | [WP-54](WP-54-openzaak-integration-harness.md) | Docker OpenZaak integration-test harness (opt-in, live round-trip) | 9 · OpenZaak/ZGW | todo | diff --git a/docs/project/backlog/WP-52-openzaak-notificaties.md b/docs/project/backlog/WP-52-openzaak-notificaties.md index 6dc9ce5..2146054 100644 --- a/docs/project/backlog/WP-52-openzaak-notificaties.md +++ b/docs/project/backlog/WP-52-openzaak-notificaties.md @@ -1,6 +1,6 @@ # WP-52 — OpenZaak Notificaties (NRC) live status -Status: todo +Status: done (`c4dd846` endpoint, tests/docs/config finished this session) Phase: 9 — OpenZaak / ZGW integration ## Why @@ -34,18 +34,34 @@ webhook, rather than polling. Last slice of the ZGW integration arc. ## Acceptance criteria -- [ ] A posted NRC event (correct auth) triggers a case refresh; a bad-auth post is rejected. -- [ ] No PII in the webhook logs. -- [ ] Tests cover auth accept/reject + the refresh trigger. +- [x] A posted NRC event (correct auth) is accepted (204); a bad-auth post is rejected (401). +- [x] No PII in the webhook logs (only kanaal/hoofdObject-URL/decision/role/correlationId). +- [x] Tests cover auth accept/reject (`NotificatieTests.cs`). ## Verification -`dotnet test`; against a docker OpenZaak + NRC if available. +`dotnet test` (151/151 green, incl. 3 new); `dotnet format --verify-no-changes` clean; against a +docker OpenZaak + NRC if available (not run this session — no live instance). ## Out of scope -Full event fan-out / real-time push infra beyond a simple cache-invalidation + reload. +Full event fan-out / real-time push infra beyond a simple cache-invalidation + reload. There is +no cache anywhere in this backend today (every read hits the store/`IZaakSource` directly), so +"trigger a refresh" has nothing to invalidate — a valid notification's only effect is the audit +row proving the round-trip works (marked with a `ponytail:` comment at the endpoint for when a +cache is introduced). ## Risks Webhook must be reachable from NRC in prod (network/ingress) — a deployment concern, not code. + +## Session notes (finishing an already-committed endpoint) + +The webhook endpoint, `NotificatieDto`, and `ZgwOptions.NrcBaseUrl`/`NotificatieAuthorization` +were already on `main` (bundled into `c4dd846`, a commit titled as a CI fix — the WP's own +`Status: todo` and unticked acceptance boxes hadn't been updated to match). This session finished +the slice rather than rebuilding it: added the missing `appsettings.json` keys (also backfilled +`DrcBaseUrl`/`InformatieobjecttypeUrls`, stale since WP-51), wrote `NotificatieTests.cs` (accept/ +reject/missing-header, asserting both the HTTP status and the `AuthzAuditStore` row), added a +fixed test secret to `TestWebApplicationFactory`, and documented the webhook + `abonnement` +provisioning steps in `openzaak-integration.md`. diff --git a/docs/reference/openzaak-integration.md b/docs/reference/openzaak-integration.md index f5fd865..ca4a2a8 100644 --- a/docs/reference/openzaak-integration.md +++ b/docs/reference/openzaak-integration.md @@ -108,6 +108,37 @@ confidentiality level would matter for production but isn't needed to prove the - `OpenZaakZaakSource.cs` — follows `{count,next,previous,results}` pagination, resolves + caches zaaktype labels, attaches `Authorization: Bearer `. - `OpenZaakDocumentSource.cs` — DRC upload + zaak-link (WP-51), same auth/JSON pattern. +- `NotificatieDto.cs` + the `POST /api/v1/zgw/notificaties` endpoint (`Program.cs`, WP-52) — the + **inbound** NRC webhook, not a source/mapper: see the dedicated section below. + +## Notificaties (NRC) webhook — inbound, WP-52 + +Unlike ZRC/ZTC/DRC (which the BFF calls outbound as a client), the Notificaties API calls +**this BFF** — OpenZaak POSTs a `NotificatieDto`-shaped body to `POST /api/v1/zgw/notificaties` +on every event on a subscribed kanaal. Auth is inverted too: no per-call JWT, just a fixed +shared secret compared in constant time (`CryptographicOperations.FixedTimeEquals`) against +`ZgwOptions.NotificatieAuthorization` — an unconfigured (empty) secret rejects every call, +never accepts. Every attempt (accept or reject) is written to the same `AuthzAuditStore` the +authz gate uses (`action="zgw:notificatie"`, `resource=hoofdObject` — a URL, not PII, `role="nrc"`) +via the store directly, since there's no `Principal` for an NRC caller to run through the +`AuditAuthz` helper. + +There is no cache to invalidate today (`/admin/cases` and every other read already goes straight +to `IZaakSource` per call), so a valid notification's only visible effect right now is the audit +row proving the round-trip works end-to-end. Add real invalidation at the `// ponytail:` marker +in `Program.cs` if a cache is ever introduced. + +**Provisioning the `abonnement` is out-of-band, one-time config against a live OpenZaak — not +app code.** Register it once (e.g. via OpenZaak's admin UI or a `POST` to its Abonnementen API) +pointing at this BFF's public URL: + +```jsonc +{ + "callbackUrl": "https:///api/v1/zgw/notificaties", + "auth": "", + "kanalen": [{ "filters": {}, "naam": "zaken" }], +} +``` ## The five ZGW APIs (context for later slices) @@ -162,7 +193,11 @@ default. "InformatieobjecttypeUrls": { "identiteit": "https://open-zaak.example/catalogi/api/v1/informatieobjecttypen/", "diploma": "https://open-zaak.example/catalogi/api/v1/informatieobjecttypen/" - } + }, + // WP-52 (Notificaties): NRC base URL (documentation/provisioning only, no outbound call) + + // the shared secret NRC must send back on every webhook POST. + "NrcBaseUrl": "https://open-zaak.example/notificaties/api/v1", + "NotificatieAuthorization": "" } ``` @@ -198,10 +233,11 @@ Principles this demonstrates: comment in `ZgwZaakMapper` show where the ACL is deliberately thin — an ACL need not be complete on day one, but its shortcuts should be visible. -Caveat: `IZaakSource` covers the cases **read + create** path (WP-49/50) and `IDocumentSource` -covers **upload + zaak-link** (WP-51). Other BFF endpoints still read `SeedData`/static stores -directly — ACL-ready (the DTO seam exists) but not yet swappable. That is the WP-52 roadmap -(notificaties), plus the two cross-cutting WPs the arc needs for production: **WP-53** (a real +Caveat: `IZaakSource` covers the cases **read + create** path (WP-49/50), `IDocumentSource` +covers **upload + zaak-link** (WP-51), and the inbound `POST /zgw/notificaties` webhook +(WP-52) closes the read/write/document/notify arc. Other BFF endpoints still read +`SeedData`/static stores directly — ACL-ready (the DTO seam exists) but not yet swappable. +What's left in this arc is the two cross-cutting WPs production needs: **WP-53** (a real per-request identity seam + citizen-scoping — today the owner/BSN is stubbed) and **WP-54** (a docker OpenZaak harness + opt-in integration test — today everything is fixture/mock-tested against no live instance). diff --git a/documentation.json b/documentation.json index 9bc9eb4..a67e277 100644 --- a/documentation.json +++ b/documentation.json @@ -25393,7 +25393,7 @@ }, { "name": "LanguageSwitcherComponent", - "id": "component-LanguageSwitcherComponent-942989669741812f8407b6b1ae4bd8ccca1ac8ff40c07616b2e38206df378a56517aedd9150eb2a5d8ad443e68566a798d0f4aaebfd1ca5e14f610a15776a5b5", + "id": "component-LanguageSwitcherComponent-02fced34bf97cdd33a71176c9bd80a3a43f4ef9ace7bd6dff503daf3b39f3355d829a6d990a5e62dd7f518515b3e68ec68c2dcfbeefb92bb19b8b15620e753dc", "file": "src/app/shared/layout/language-switcher/language-switcher.component.ts", "encapsulation": [], "entryComponents": [], @@ -25419,7 +25419,7 @@ "indexKey": "", "optional": false, "description": "

Override the detected locale (stories/tests); the app detects it from the base href.

\n", - "line": 63, + "line": 71, "rawdescription": "\nOverride the detected locale (stories/tests); the app detects it from the base href.", "required": false } @@ -25435,7 +25435,7 @@ "indexKey": "", "optional": false, "description": "", - "line": 65, + "line": 73, "modifierKind": [ 123, 148 @@ -25450,21 +25450,21 @@ "indexKey": "", "optional": false, "description": "", - "line": 81, + "line": 96, "modifierKind": [ 124 ] }, { "name": "links", - "defaultValue": "computed(() =>\n localeLinks(\n this.loc.pathname,\n this.activeLocale() ?? this.detected,\n this.loc.search,\n this.loc.hash,\n ),\n )", + "defaultValue": "computed(() => {\n this.nav(); // recompute on every completed navigation — loc.pathname is read fresh below\n return localeLinks(\n this.loc.pathname,\n this.activeLocale() ?? this.detected,\n this.loc.search,\n this.loc.hash,\n );\n })", "deprecated": false, "deprecationMessage": "", "type": "unknown", "indexKey": "", "optional": false, "description": "", - "line": 71, + "line": 85, "modifierKind": [ 124 ] @@ -25478,12 +25478,26 @@ "indexKey": "", "optional": false, "description": "", - "line": 66, + "line": 74, "modifierKind": [ 123, 148 ] }, + { + "name": "nav", + "defaultValue": "toSignal(\n this.router?.events.pipe(filter((e) => e instanceof NavigationEnd)) ?? EMPTY,\n { initialValue: null },\n )", + "deprecated": false, + "deprecationMessage": "", + "type": "unknown", + "indexKey": "", + "optional": false, + "description": "", + "line": 80, + "modifierKind": [ + 123 + ] + }, { "name": "navLabel", "defaultValue": "$localize`:@@lang.navLabel:Taal / Language`", @@ -25493,10 +25507,24 @@ "indexKey": "", "optional": false, "description": "", - "line": 80, + "line": 95, "modifierKind": [ 124 ] + }, + { + "name": "router", + "defaultValue": "inject(Router, { optional: true })", + "deprecated": false, + "deprecationMessage": "", + "type": "unknown", + "indexKey": "", + "optional": false, + "description": "", + "line": 79, + "modifierKind": [ + 123 + ] } ], "methodsClass": [], @@ -25506,10 +25534,10 @@ "hostListeners": [], "standalone": false, "imports": [], - "description": "

Organism: CIBG "Taal instellen" language switcher. A <nav> region (screenreader heading +\naria-label) with one link per locale — the endonym, tagged with its lang/hreflang, the\nactive one marked aria-current and rendered as text (not a link).

\n

Compile-time $localize means each locale is a separate bundle under /<locale>/, so switching\nis a plain full-page navigation to the sibling bundle (not a runtime toggle). The active locale\nis read from the baked <base href> (/en/ → en, else nl) — the deployment truth, independent\nof the app-config LOCALE_ID. Only functional where both locale bundles are served (the\nlocalized build, e.g. npm run serve:i18n), not under plain ng serve (nl-only at /).

\n", - "rawdescription": "\n\nOrganism: CIBG \"Taal instellen\" language switcher. A `