diff --git a/backend/src/BigRegister.Api/Data/IZaakSource.cs b/backend/src/BigRegister.Api/Data/IZaakSource.cs new file mode 100644 index 0000000..623388f --- /dev/null +++ b/backend/src/BigRegister.Api/Data/IZaakSource.cs @@ -0,0 +1,21 @@ +using BigRegister.Api.Contracts; + +namespace BigRegister.Api.Data; + +/// +/// The cases (zaken) READ seam (WP-49). A "zaak" in ZGW terms is an +/// here; this interface is the one injection point that lets a real ZGW backend (OpenZaak) +/// replace the local SQLite store behind the same +/// contract — so the frontend never changes (BFF-lite anti-corruption, ADR-0001). +/// +/// Default binding is (offline). Setting Zgw:Enabled=true +/// swaps in OpenZaakZaakSource. Slice 1 is read-only; create/update stay on the +/// local write path until WP-50. The interface returns the wire DTO (not the domain +/// ) precisely so each source owns its own mapping — the OpenZaak +/// source maps a ZGW Zaak into this shape, the local source maps the stored aanvraag. +/// +public interface IZaakSource +{ + /// Every case, newest-first (the admin cross-owner list, WP-36). + IReadOnlyList ListCases(DateTimeOffset now); +} diff --git a/backend/src/BigRegister.Api/Data/LocalZaakSource.cs b/backend/src/BigRegister.Api/Data/LocalZaakSource.cs new file mode 100644 index 0000000..3d24f0f --- /dev/null +++ b/backend/src/BigRegister.Api/Data/LocalZaakSource.cs @@ -0,0 +1,15 @@ +using BigRegister.Api.Contracts; + +namespace BigRegister.Api.Data; + +/// +/// The default — the cases come from the local SQLite +/// , exactly as before the seam existed (WP-49). Zero +/// behaviour change: this is the same ListAll().ToAdminSummaryDto(now) the +/// /admin/cases endpoint used to call inline. +/// +public sealed class LocalZaakSource : IZaakSource +{ + public IReadOnlyList ListCases(DateTimeOffset now) => + ApplicationStore.ListAll().Select(a => a.ToAdminSummaryDto(now)).ToList(); +} diff --git a/backend/src/BigRegister.Api/Program.cs b/backend/src/BigRegister.Api/Program.cs index be2545a..57c1b4c 100644 --- a/backend/src/BigRegister.Api/Program.cs +++ b/backend/src/BigRegister.Api/Program.cs @@ -10,6 +10,7 @@ using BigRegister.Domain.Intake; using BigRegister.Domain.Letters; using BigRegister.Domain.Registrations; using BigRegister.Domain.Submissions; +using BigRegister.Api.Zgw; using BigRegister.Stamdata; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Logging.Console; @@ -40,6 +41,22 @@ builder.Services.AddCors(o => o.AddPolicy(SpaCors, p => // override it (ConnectionStrings:AppDb) without touching this file. Db.ConnectionString = builder.Configuration.GetConnectionString("AppDb") ?? Db.ConnectionString; +// WP-49: the cases (zaken) READ path goes through IZaakSource so a real ZGW backend +// (OpenZaak) can replace the local SQLite store behind the same DTO contract — the FE never +// changes (ADR-0001). Default = LocalZaakSource (offline). Zgw:Enabled=true swaps in the +// OpenZaak client (needs the base URLs + credentials in the Zgw config section). +var zgw = builder.Configuration.GetSection("Zgw").Get() ?? new ZgwOptions(); +if (zgw.Enabled) +{ + builder.Services.AddSingleton(zgw); + builder.Services.AddSingleton(); + builder.Services.AddHttpClient(); +} +else +{ + builder.Services.AddSingleton(); +} + var app = builder.Build(); // Migrate on every startup, seed nothing (WP-22): unlike SeedData's read-only @@ -314,11 +331,8 @@ api.MapPost("/applications/{id}/submit", (string id, SubmitApplicationRequest re .Produces(StatusCodes.Status404NotFound); // --- Admin cases (WP-36): cross-owner list + admin delete, gated by `cases:manage`. --- -api.MapGet("/admin/cases", (HttpContext ctx) => CasesAdmin(ctx, () => -{ - var now = DateTimeOffset.UtcNow; - return Results.Ok(ApplicationStore.ListAll().Select(a => a.ToAdminSummaryDto(now)).ToList()); -})) +api.MapGet("/admin/cases", (HttpContext ctx, IZaakSource zaken) => CasesAdmin(ctx, () => + Results.Ok(zaken.ListCases(DateTimeOffset.UtcNow)))) .Produces>() .ProducesProblem(StatusCodes.Status403Forbidden); diff --git a/backend/src/BigRegister.Api/Stamdata/Beroep.cs b/backend/src/BigRegister.Api/Stamdata/Beroep.cs new file mode 100644 index 0000000..9231558 --- /dev/null +++ b/backend/src/BigRegister.Api/Stamdata/Beroep.cs @@ -0,0 +1,13 @@ +namespace BigRegister.Stamdata; + +/// +/// One row of the beroepen (BIG professions) stamdata (config-as-code, ADR-0004): the +/// master list of registered professions. The first property () is the +/// table key by convention (see StamdataTable) and the target of the FK-like +/// references from opleidingen and specialismen (see +/// StamdataValidationTests). Non-temporal — a profession is either registrable or it +/// isn't; the mapping's validity window lives on opleidingen. +/// +/// This is the typed shape beroepen.json deserializes into. +/// +public sealed record Beroep(string Code, string Naam); diff --git a/backend/src/BigRegister.Api/Stamdata/Opleiding.cs b/backend/src/BigRegister.Api/Stamdata/Opleiding.cs new file mode 100644 index 0000000..90b0fd2 --- /dev/null +++ b/backend/src/BigRegister.Api/Stamdata/Opleiding.cs @@ -0,0 +1,14 @@ +namespace BigRegister.Stamdata; + +/// +/// One row of the opleidingen (study programs) stamdata (config-as-code, ADR-0004): a study +/// program and the BIG profession it leads to, valid for a period. The first property +/// () is the table key; holds a beroepen.Code +/// — a stamdata → stamdata reference the build gate enforces (StamdataValidationTests), +/// so orphaning a beroep fails CI, never prod. Temporal (/ +/// , half-open [van, tot)): a null +/// means "still valid"; a future pre-schedules a program. +/// +/// This is the typed shape opleidingen.json deserializes into. +/// +public sealed record Opleiding(string Code, string Naam, string Beroep, DateOnly GeldigVan, DateOnly? GeldigTot); diff --git a/backend/src/BigRegister.Api/Stamdata/Specialisme.cs b/backend/src/BigRegister.Api/Stamdata/Specialisme.cs new file mode 100644 index 0000000..d3a2ba6 --- /dev/null +++ b/backend/src/BigRegister.Api/Stamdata/Specialisme.cs @@ -0,0 +1,13 @@ +namespace BigRegister.Stamdata; + +/// +/// One row of the specialismen (professional specialisms) stamdata (config-as-code, +/// ADR-0004): a registered specialism and the base BIG profession it belongs to. The first +/// property () is the table key; holds a +/// beroepen.Code — the second stamdata → stamdata reference the build gate enforces +/// (StamdataValidationTests), demonstrating one table referenced by two others. +/// Non-temporal. +/// +/// This is the typed shape specialismen.json deserializes into. +/// +public sealed record Specialisme(string Code, string Naam, string Beroep); diff --git a/backend/src/BigRegister.Api/Stamdata/StamdataCatalog.cs b/backend/src/BigRegister.Api/Stamdata/StamdataCatalog.cs index 52dee56..7b73de0 100644 --- a/backend/src/BigRegister.Api/Stamdata/StamdataCatalog.cs +++ b/backend/src/BigRegister.Api/Stamdata/StamdataCatalog.cs @@ -11,6 +11,9 @@ public static class StamdataCatalog public static readonly IReadOnlyList All = new[] { StamdataTable.Of("professions", "Opleiding → beroep"), + StamdataTable.Of("beroepen", "Beroepen (BIG)"), + StamdataTable.Of("opleidingen", "Opleidingen → beroep"), + StamdataTable.Of("specialismen", "Specialismen → beroep"), // PolicyQuestions and future tables migrate here, same one-liner each. }; diff --git a/backend/src/BigRegister.Api/Stamdata/beroepen.json b/backend/src/BigRegister.Api/Stamdata/beroepen.json new file mode 100644 index 0000000..a79ddc8 --- /dev/null +++ b/backend/src/BigRegister.Api/Stamdata/beroepen.json @@ -0,0 +1,10 @@ +[ + { "code": "arts", "naam": "Arts" }, + { "code": "verpleegkundige", "naam": "Verpleegkundige" }, + { "code": "fysiotherapeut", "naam": "Fysiotherapeut" }, + { "code": "apotheker", "naam": "Apotheker" }, + { "code": "tandarts", "naam": "Tandarts" }, + { "code": "verloskundige", "naam": "Verloskundige" }, + { "code": "gz-psycholoog", "naam": "Gezondheidszorgpsycholoog" }, + { "code": "psychotherapeut", "naam": "Psychotherapeut" } +] diff --git a/backend/src/BigRegister.Api/Stamdata/opleidingen.json b/backend/src/BigRegister.Api/Stamdata/opleidingen.json new file mode 100644 index 0000000..549e391 --- /dev/null +++ b/backend/src/BigRegister.Api/Stamdata/opleidingen.json @@ -0,0 +1,10 @@ +[ + { "code": "geneeskunde", "naam": "Geneeskunde", "beroep": "arts", "geldigVan": "2000-01-01", "geldigTot": null }, + { "code": "verpleegkunde", "naam": "Verpleegkunde (hbo-v)", "beroep": "verpleegkundige", "geldigVan": "2000-01-01", "geldigTot": null }, + { "code": "fysiotherapie", "naam": "Fysiotherapie", "beroep": "fysiotherapeut", "geldigVan": "2000-01-01", "geldigTot": null }, + { "code": "farmacie", "naam": "Farmacie", "beroep": "apotheker", "geldigVan": "2000-01-01", "geldigTot": null }, + { "code": "tandheelkunde", "naam": "Tandheelkunde", "beroep": "tandarts", "geldigVan": "2000-01-01", "geldigTot": null }, + { "code": "verloskunde", "naam": "Verloskunde", "beroep": "verloskundige", "geldigVan": "2000-01-01", "geldigTot": null }, + { "code": "gz-psychologie", "naam": "Gezondheidszorgpsychologie", "beroep": "gz-psycholoog", "geldigVan": "2005-01-01", "geldigTot": null }, + { "code": "inservice-a", "naam": "Inservice A-opleiding (verpleegkunde, oud)", "beroep": "verpleegkundige", "geldigVan": "2000-01-01", "geldigTot": "2012-01-01" } +] diff --git a/backend/src/BigRegister.Api/Stamdata/specialismen.json b/backend/src/BigRegister.Api/Stamdata/specialismen.json new file mode 100644 index 0000000..989e435 --- /dev/null +++ b/backend/src/BigRegister.Api/Stamdata/specialismen.json @@ -0,0 +1,8 @@ +[ + { "code": "huisartsgeneeskunde", "naam": "Huisartsgeneeskunde", "beroep": "arts" }, + { "code": "cardiologie", "naam": "Cardiologie", "beroep": "arts" }, + { "code": "kindergeneeskunde", "naam": "Kindergeneeskunde", "beroep": "arts" }, + { "code": "ziekenhuisfarmacie", "naam": "Ziekenhuisfarmacie", "beroep": "apotheker" }, + { "code": "orthodontie", "naam": "Orthodontie", "beroep": "tandarts" }, + { "code": "geriatriefysiotherapie", "naam": "Geriatriefysiotherapie", "beroep": "fysiotherapeut" } +] diff --git a/backend/src/BigRegister.Api/Zgw/OpenZaakZaakSource.cs b/backend/src/BigRegister.Api/Zgw/OpenZaakZaakSource.cs new file mode 100644 index 0000000..b32761a --- /dev/null +++ b/backend/src/BigRegister.Api/Zgw/OpenZaakZaakSource.cs @@ -0,0 +1,82 @@ +using System.Net.Http.Headers; +using System.Net.Http.Json; +using System.Text.Json; +using System.Text.Json.Serialization; +using BigRegister.Api.Contracts; +using BigRegister.Api.Data; + +namespace BigRegister.Api.Zgw; + +/// One page of a ZGW list response — the uniform {count,next,previous,results} +/// envelope every ZGW collection uses. next is a full URL (or null) to follow. +public sealed record ZgwPage( + [property: JsonPropertyName("count")] int Count, + [property: JsonPropertyName("next")] string? Next, + [property: JsonPropertyName("results")] IReadOnlyList Results); + +/// +/// The backed by a real OpenZaak / ZGW Zaken API (WP-49). Reads +/// zaken (following pagination), resolves each zaaktype's human label from the Catalogi API +/// (cached), and maps into via . +/// Selected only when Zgw:Enabled=true; the default stays . +/// +/// Auth: a fresh HS256 JWT per request () on the Authorization +/// header. Reading a zaak needs read scope on BOTH Zaken and Catalogi (zaaktype resolution). +/// +public sealed class OpenZaakZaakSource(HttpClient http, ZgwTokenProvider tokens, ZgwOptions options) : IZaakSource +{ + // ponytail: sync-over-async — IZaakSource is sync to match the local store + the existing + // sync /admin/cases endpoint, and ASP.NET Core has no sync-context to deadlock on. Make the + // whole cases read path async (endpoint + CasesAdmin + interface) if OpenZaak becomes the + // default and this blocking call shows up under load. + public IReadOnlyList ListCases(DateTimeOffset now) => + ListCasesAsync().GetAwaiter().GetResult(); + + private async Task> ListCasesAsync() + { + var zaken = await GetAllAsync($"{options.ZrcBaseUrl}/zaken"); + var labels = new Dictionary(); + var result = new List(zaken.Count); + foreach (var z in zaken) + { + if (!labels.TryGetValue(z.Zaaktype, out var label)) + labels[z.Zaaktype] = label = await ZaaktypeLabelAsync(z.Zaaktype); + result.Add(ZgwZaakMapper.ToSummaryDto(z, label)); + } + return result; + } + + /// Follow the next links, accumulating every page's results. + private async Task> GetAllAsync(string url) + { + var all = new List(); + string? next = url; + while (next is not null) + { + var page = await GetAsync>(next); + all.AddRange(page.Results); + next = page.Next; + } + return all; + } + + /// A zaaktype's human label (omschrijving) from the Catalogi API. + private async Task ZaaktypeLabelAsync(string zaaktypeUrl) + { + var zt = await GetAsync(zaaktypeUrl); + return zt.Omschrijving; + } + + private async Task GetAsync(string url) + { + using var req = new HttpRequestMessage(HttpMethod.Get, url); + req.Headers.Authorization = new AuthenticationHeaderValue("Bearer", tokens.Mint()); + req.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json")); + using var res = await http.SendAsync(req); + res.EnsureSuccessStatusCode(); + return (await res.Content.ReadFromJsonAsync()) + ?? throw new InvalidOperationException($"ZGW GET {url} returned null body."); + } + + private sealed record Zaaktype([property: JsonPropertyName("omschrijving")] string Omschrijving); +} diff --git a/backend/src/BigRegister.Api/Zgw/ZgwOptions.cs b/backend/src/BigRegister.Api/Zgw/ZgwOptions.cs new file mode 100644 index 0000000..2a51c6b --- /dev/null +++ b/backend/src/BigRegister.Api/Zgw/ZgwOptions.cs @@ -0,0 +1,34 @@ +namespace BigRegister.Api.Zgw; + +/// +/// Config for connecting to OpenZaak / the ZGW APIs (WP-49), bound from the Zgw +/// section of appsettings. Disabled by default so the POC runs fully offline on the local +/// SQLite store; set Zgw:Enabled=true (plus the URLs + credentials) to source cases +/// from a real OpenZaak. +/// +/// The ZGW standard is FIVE separate services, each its own base URL — Slice 1 only needs +/// the Zaken API (ZRC) and, to resolve human labels for a zaaktype, the Catalogi API (ZTC). +/// The others (DRC/BRC/NRC) arrive with later slices (WP-51/52). +/// +public sealed class ZgwOptions +{ + public bool Enabled { get; init; } + + /// Zaken API (ZRC) base URL, e.g. https://open-zaak.example/zaken/api/v1. + public string ZrcBaseUrl { get; init; } = ""; + + /// Catalogi API (ZTC) base URL — used to resolve a zaaktype URL to its label. + public string ZtcBaseUrl { get; init; } = ""; + + /// Client ID registered in the Autorisaties API (goes into the JWT iss/client_id). + public string ClientId { get; init; } = ""; + + /// Client secret — the HS256 signing key for the JWT. Held only by the BFF, never the browser. + public string Secret { get; init; } = ""; + + /// End-user identity for the ZGW audit trail (JWT user_id). Wire from the session in prod. + public string UserId { get; init; } = "big-register-bff"; + + /// Human-readable end-user name for the audit trail (JWT user_representation). + public string UserRepresentation { get; init; } = "BIG-register BFF"; +} diff --git a/backend/src/BigRegister.Api/Zgw/ZgwTokenProvider.cs b/backend/src/BigRegister.Api/Zgw/ZgwTokenProvider.cs new file mode 100644 index 0000000..b408581 --- /dev/null +++ b/backend/src/BigRegister.Api/Zgw/ZgwTokenProvider.cs @@ -0,0 +1,43 @@ +using System.Security.Cryptography; +using System.Text; +using System.Text.Json; + +namespace BigRegister.Api.Zgw; + +/// +/// Mints the JWT that authenticates the BFF to the ZGW APIs (WP-49). OpenZaak expects a +/// short-lived HS256 assertion signed with the client secret, carrying iss/ +/// client_id (identity), iat (issued-at), and user_id/ +/// user_representation (for the ZGW audit trail). There is no OAuth refresh dance — +/// OpenZaak rejects tokens more than an hour past iat, so we mint a fresh token +/// per call (cheap, keeps iat current and the audit user correct). +/// +/// ponytail: hand-rolled base64url JWT with — a ZGW token is a +/// plain HS256 JWS, so this ~15 lines beats adding Microsoft.IdentityModel.* just to sign +/// three claims. Swap in a library if we ever need RS256 / JWKS. +/// +public sealed class ZgwTokenProvider(ZgwOptions options) +{ + public string Mint() + { + var header = new { alg = "HS256", typ = "JWT" }; + var payload = new + { + iss = options.ClientId, + iat = DateTimeOffset.UtcNow.ToUnixTimeSeconds(), + client_id = options.ClientId, + user_id = options.UserId, + user_representation = options.UserRepresentation, + }; + + var signingInput = $"{Encode(header)}.{Encode(payload)}"; + using var hmac = new HMACSHA256(Encoding.UTF8.GetBytes(options.Secret)); + var signature = Base64Url(hmac.ComputeHash(Encoding.UTF8.GetBytes(signingInput))); + return $"{signingInput}.{signature}"; + } + + private static string Encode(object o) => Base64Url(JsonSerializer.SerializeToUtf8Bytes(o)); + + private static string Base64Url(byte[] bytes) => + Convert.ToBase64String(bytes).TrimEnd('=').Replace('+', '-').Replace('/', '_'); +} diff --git a/backend/src/BigRegister.Api/Zgw/ZgwZaakMapper.cs b/backend/src/BigRegister.Api/Zgw/ZgwZaakMapper.cs new file mode 100644 index 0000000..327d521 --- /dev/null +++ b/backend/src/BigRegister.Api/Zgw/ZgwZaakMapper.cs @@ -0,0 +1,58 @@ +using System.Text.Json.Serialization; +using BigRegister.Api.Contracts; + +namespace BigRegister.Api.Zgw; + +/// +/// The subset of a ZGW Zaak (Zaken API / ZRC) the read slice needs. The full resource has +/// dozens of fields; we bind only what maps to . Note the +/// two ZGW traits that force an anti-corruption layer: is the resource's +/// identity (not a bare id), and is a URL into another service +/// (Catalogi/ZTC) that must be resolved to a human label. +/// +public sealed record ZgwZaak( + [property: JsonPropertyName("url")] string Url, + [property: JsonPropertyName("identificatie")] string Identificatie, + [property: JsonPropertyName("zaaktype")] string Zaaktype, + [property: JsonPropertyName("startdatum")] DateOnly Startdatum, + [property: JsonPropertyName("einddatum")] DateOnly? Einddatum, + [property: JsonPropertyName("registratiedatum")] DateOnly? Registratiedatum); + +/// +/// Anti-corruption map: ZGW Zaak → the existing the FE +/// already renders (WP-49). This is where "URL as identity" and the cross-service zaaktype +/// join get flattened away, so nothing downstream (the FE) sees ZGW shapes. +/// +public static class ZgwZaakMapper +{ + /// Last path segment of a ZGW resource URL — the uuid that identifies it. + public static string Uuid(string url) => url.TrimEnd('/').Split('/').Last(); + + public static ApplicationSummaryDto ToSummaryDto(ZgwZaak z, string zaaktypeLabel) + { + // ponytail: coarse status map — an open zaak (no einddatum) is In behandeling, a closed + // one is Goedgekeurd. Real fidelity (statustype/resultaat lookups) is a later slice; the + // Afgewezen path needs the resultaat resource. Enough to prove the seam end-to-end. + var status = z.Einddatum is null + ? new AanvraagStatusDto("InBehandeling", Referentie: z.Identificatie, Manual: true) + : new AanvraagStatusDto("Goedgekeurd", Referentie: z.Identificatie); + + var created = Iso(z.Registratiedatum ?? z.Startdatum); + var updated = Iso(z.Einddatum ?? z.Registratiedatum ?? z.Startdatum); + + return new ApplicationSummaryDto( + Id: Uuid(z.Url), + Type: zaaktypeLabel, + Status: status, + DocumentIds: Array.Empty(), // zaak↔document links arrive with WP-51 (DRC) + CreatedAt: created, + UpdatedAt: updated, + SubmittedAt: created, + Owner: z.Identificatie); // real initiator (rol/BSN) needs a rollen lookup — later slice + } + + // Keep the wire shape identical to the local source (round-trip datetime): a ZGW date + // becomes midnight UTC so the FE's date parsing sees the same format either backend. + private static string Iso(DateOnly d) => + d.ToDateTime(TimeOnly.MinValue, DateTimeKind.Utc).ToString("o"); +} diff --git a/backend/src/BigRegister.Api/appsettings.json b/backend/src/BigRegister.Api/appsettings.json index 10f68b8..96cc344 100644 --- a/backend/src/BigRegister.Api/appsettings.json +++ b/backend/src/BigRegister.Api/appsettings.json @@ -5,5 +5,15 @@ "Microsoft.AspNetCore": "Warning" } }, - "AllowedHosts": "*" + "AllowedHosts": "*", + "_Zgw": "WP-49: set Enabled=true + the URLs/credentials to source cases from a real OpenZaak. Off = local SQLite store (offline POC default).", + "Zgw": { + "Enabled": false, + "ZrcBaseUrl": "", + "ZtcBaseUrl": "", + "ClientId": "", + "Secret": "", + "UserId": "big-register-bff", + "UserRepresentation": "BIG-register BFF" + } } diff --git a/backend/tests/BigRegister.Tests/OpenZaakZaakSourceTests.cs b/backend/tests/BigRegister.Tests/OpenZaakZaakSourceTests.cs new file mode 100644 index 0000000..4a774f5 --- /dev/null +++ b/backend/tests/BigRegister.Tests/OpenZaakZaakSourceTests.cs @@ -0,0 +1,78 @@ +using System.Net; +using System.Text; +using BigRegister.Api.Zgw; + +namespace BigRegister.Tests; + +/// +/// Exercises the OpenZaak read source against a stub HttpMessageHandler (no live server, no +/// mocking library) — the guarantee that it follows ZGW pagination, resolves + caches +/// zaaktype labels, and always sends a Bearer token. +/// +public class OpenZaakZaakSourceTests +{ + private const string ZrcBase = "https://oz.example/zaken/api/v1"; + private const string ZtBase = "https://oz.example/catalogi/api/v1"; + + private static string Page1 => $$""" + { "count": 2, "next": "{{ZrcBase}}/zaken?page=2", "results": [ + { "url": "{{ZrcBase}}/zaken/uuid-1", "identificatie": "ZAAK-1", + "zaaktype": "{{ZtBase}}/zaaktypen/zt-1", "startdatum": "2026-03-01", + "einddatum": null, "registratiedatum": "2026-03-01" } ] } + """; + + private static string Page2 => $$""" + { "count": 2, "next": null, "results": [ + { "url": "{{ZrcBase}}/zaken/uuid-2", "identificatie": "ZAAK-2", + "zaaktype": "{{ZtBase}}/zaaktypen/zt-1", "startdatum": "2026-01-01", + "einddatum": "2026-02-01", "registratiedatum": "2026-01-01" } ] } + """; + + private const string Zaaktype = """{ "omschrijving": "Herregistratie arts" }"""; + + [Fact] + public void Follows_pagination_caches_zaaktype_and_sends_bearer_token() + { + var handler = new StubHandler(url => url switch + { + _ when url == $"{ZrcBase}/zaken" => Page1, + _ when url == $"{ZrcBase}/zaken?page=2" => Page2, + _ when url == $"{ZtBase}/zaaktypen/zt-1" => Zaaktype, + _ => throw new InvalidOperationException($"unexpected ZGW GET {url}"), + }); + + var options = new ZgwOptions { ZrcBaseUrl = ZrcBase, ZtcBaseUrl = ZtBase, ClientId = "c", Secret = "s" }; + var source = new OpenZaakZaakSource(new HttpClient(handler), new ZgwTokenProvider(options), options); + + var cases = source.ListCases(DateTimeOffset.UtcNow); + + // Both pages accumulated. + Assert.Equal(2, cases.Count); + Assert.Equal(new[] { "uuid-1", "uuid-2" }, cases.Select(c => c.Id)); + Assert.All(cases, c => Assert.Equal("Herregistratie arts", c.Type)); + Assert.Equal("InBehandeling", cases[0].Status.Tag); // open + Assert.Equal("Goedgekeurd", cases[1].Status.Tag); // closed + + // Zaaktype resolved once despite two zaken sharing it (cache). + Assert.Single(handler.Requests, r => r.Contains("zaaktypen")); + // Every outbound request carried a Bearer token. + Assert.All(handler.AuthSchemes, s => Assert.Equal("Bearer", s)); + } + + private sealed class StubHandler(Func respond) : HttpMessageHandler + { + public List Requests { get; } = new(); + public List AuthSchemes { get; } = new(); + + protected override Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) + { + var url = request.RequestUri!.ToString(); + Requests.Add(url); + AuthSchemes.Add(request.Headers.Authorization?.Scheme); + return Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new StringContent(respond(url), Encoding.UTF8, "application/json"), + }); + } + } +} diff --git a/backend/tests/BigRegister.Tests/StamdataValidationTests.cs b/backend/tests/BigRegister.Tests/StamdataValidationTests.cs index eadda22..9ffd4b2 100644 --- a/backend/tests/BigRegister.Tests/StamdataValidationTests.cs +++ b/backend/tests/BigRegister.Tests/StamdataValidationTests.cs @@ -18,12 +18,26 @@ public class StamdataValidationTests /// which steers the editor toward closing validity only once nothing current relies on it. private sealed record StamdataRef(string Description, IEnumerable Keys, Func Resolves); + // The beroepen master-list keys every other profession table points at. + private static readonly IReadOnlySet BeroepCodes = + StamdataFile.Load("beroepen").Select(b => b.Code).ToHashSet(StringComparer.Ordinal); + private static readonly IReadOnlyList References = new[] { new StamdataRef( "Diploma.Opleiding → professions.program (valid today)", SeedData.Diplomas.Select(d => d.Opleiding), key => Professions.ByProgram.ContainsKey(key)), + // Stamdata → stamdata references: two tables point at beroepen.code, so deleting or + // renaming a beroep that either still uses fails the build (WP-48 gate, generalized). + new StamdataRef( + "Opleiding.beroep → beroepen.code", + StamdataFile.Load("opleidingen").Select(o => o.Beroep), + key => BeroepCodes.Contains(key)), + new StamdataRef( + "Specialisme.beroep → beroepen.code", + StamdataFile.Load("specialismen").Select(s => s.Beroep), + key => BeroepCodes.Contains(key)), }; [Fact] diff --git a/backend/tests/BigRegister.Tests/ZgwTokenProviderTests.cs b/backend/tests/BigRegister.Tests/ZgwTokenProviderTests.cs new file mode 100644 index 0000000..2d1fe3e --- /dev/null +++ b/backend/tests/BigRegister.Tests/ZgwTokenProviderTests.cs @@ -0,0 +1,66 @@ +using System.Security.Cryptography; +using System.Text; +using System.Text.Json; +using BigRegister.Api.Zgw; + +namespace BigRegister.Tests; + +/// +/// The ZGW JWT is hand-signed (no library), so it needs a check that it's actually a valid +/// HS256 JWS with the claims OpenZaak requires. Decodes the minted token and re-verifies the +/// signature with the shared secret. +/// +public class ZgwTokenProviderTests +{ + private static readonly ZgwOptions Options = new() + { + ClientId = "big-register", + Secret = "super-secret-signing-key", + UserId = "u-123", + UserRepresentation = "Dr. Test", + }; + + [Fact] + public void Mints_a_three_part_jwt_with_the_required_claims() + { + var token = new ZgwTokenProvider(Options).Mint(); + + var parts = token.Split('.'); + Assert.Equal(3, parts.Length); + + var header = JsonSerializer.Deserialize(Decode(parts[0])); + Assert.Equal("HS256", header.GetProperty("alg").GetString()); + Assert.Equal("JWT", header.GetProperty("typ").GetString()); + + var payload = JsonSerializer.Deserialize(Decode(parts[1])); + Assert.Equal("big-register", payload.GetProperty("iss").GetString()); + Assert.Equal("big-register", payload.GetProperty("client_id").GetString()); + Assert.Equal("u-123", payload.GetProperty("user_id").GetString()); + Assert.Equal("Dr. Test", payload.GetProperty("user_representation").GetString()); + // iat is a recent unix second + var iat = payload.GetProperty("iat").GetInt64(); + Assert.InRange(iat, DateTimeOffset.UtcNow.ToUnixTimeSeconds() - 5, DateTimeOffset.UtcNow.ToUnixTimeSeconds() + 5); + } + + [Fact] + public void Signature_verifies_with_the_shared_secret() + { + var token = new ZgwTokenProvider(Options).Mint(); + var parts = token.Split('.'); + + using var hmac = new HMACSHA256(Encoding.UTF8.GetBytes(Options.Secret)); + var expected = Base64Url(hmac.ComputeHash(Encoding.UTF8.GetBytes($"{parts[0]}.{parts[1]}"))); + + Assert.Equal(expected, parts[2]); + } + + private static string Decode(string b64Url) + { + var s = b64Url.Replace('-', '+').Replace('_', '/'); + s = s.PadRight(s.Length + (4 - s.Length % 4) % 4, '='); + return Encoding.UTF8.GetString(Convert.FromBase64String(s)); + } + + private static string Base64Url(byte[] bytes) => + Convert.ToBase64String(bytes).TrimEnd('=').Replace('+', '-').Replace('/', '_'); +} diff --git a/backend/tests/BigRegister.Tests/ZgwZaakMapperTests.cs b/backend/tests/BigRegister.Tests/ZgwZaakMapperTests.cs new file mode 100644 index 0000000..7fb4bd7 --- /dev/null +++ b/backend/tests/BigRegister.Tests/ZgwZaakMapperTests.cs @@ -0,0 +1,71 @@ +using System.Text.Json; +using BigRegister.Api.Zgw; + +namespace BigRegister.Tests; + +/// +/// The anti-corruption map is where ZGW's URL-as-identity + cross-service zaaktype join get +/// flattened into the FE's existing DTO. Feeds a captured ZGW Zaak shape and asserts the +/// mapping — the guarantee that the FE never sees a ZGW shape. +/// +public class ZgwZaakMapperTests +{ + private const string OpenZaakJson = """ + { + "url": "https://open-zaak.example/zaken/api/v1/zaken/6f2c5f6e-1b1a-4b7e-9c3d-000000000001", + "identificatie": "ZAAK-2026-0000000001", + "zaaktype": "https://open-zaak.example/catalogi/api/v1/zaaktypen/aaaaaaaa-0000-0000-0000-000000000001", + "startdatum": "2026-03-01", + "einddatum": null, + "registratiedatum": "2026-03-02" + } + """; + + private const string ClosedZaakJson = """ + { + "url": "https://open-zaak.example/zaken/api/v1/zaken/6f2c5f6e-1b1a-4b7e-9c3d-000000000002", + "identificatie": "ZAAK-2026-0000000002", + "zaaktype": "https://open-zaak.example/catalogi/api/v1/zaaktypen/aaaaaaaa-0000-0000-0000-000000000001", + "startdatum": "2026-01-05", + "einddatum": "2026-02-10", + "registratiedatum": "2026-01-06" + } + """; + + [Fact] + public void Maps_url_identity_zaaktype_and_open_status() + { + var zaak = JsonSerializer.Deserialize(OpenZaakJson)!; + + var dto = ZgwZaakMapper.ToSummaryDto(zaak, "Herregistratie arts"); + + // URL as identity → the trailing uuid, not the whole URL. + Assert.Equal("6f2c5f6e-1b1a-4b7e-9c3d-000000000001", dto.Id); + // zaaktype URL resolved to its human label (the cross-service join). + Assert.Equal("Herregistratie arts", dto.Type); + Assert.Equal("ZAAK-2026-0000000001", dto.Status.Referentie); + Assert.Equal("InBehandeling", dto.Status.Tag); // no einddatum → open + Assert.True(dto.Status.Manual); + Assert.Empty(dto.DocumentIds); + // Wire shape matches the local source: round-trip datetime at midnight UTC. + Assert.Equal("2026-03-02T00:00:00.0000000Z", dto.CreatedAt); + } + + [Fact] + public void Closed_zaak_maps_to_goedgekeurd() + { + var zaak = JsonSerializer.Deserialize(ClosedZaakJson)!; + + var dto = ZgwZaakMapper.ToSummaryDto(zaak, "Herregistratie arts"); + + Assert.Equal("Goedgekeurd", dto.Status.Tag); + Assert.Equal("2026-02-10T00:00:00.0000000Z", dto.UpdatedAt); // einddatum drives UpdatedAt + } + + [Fact] + public void Uuid_extracts_trailing_segment_ignoring_trailing_slash() + { + Assert.Equal("abc", ZgwZaakMapper.Uuid("https://host/zaken/api/v1/zaken/abc")); + Assert.Equal("abc", ZgwZaakMapper.Uuid("https://host/zaken/api/v1/zaken/abc/")); + } +} diff --git a/docs/README.md b/docs/README.md index 276e854..9d74c84 100644 --- a/docs/README.md +++ b/docs/README.md @@ -23,6 +23,8 @@ condensed, cross-linked curriculum. | [architecture/0002-user-groups-and-bounded-contexts.md](reference/architecture/0002-user-groups-and-bounded-contexts.md) | ADR — user groups as actors; identity vs authorization. | | [architecture/0003-cibg-huisstijl.md](reference/architecture/0003-cibg-huisstijl.md) | ADR — adopt CIBG Huisstijl (vendored Bootstrap 5.2) + the token bridge. | | [architecture/0004-stamdata-as-code.md](reference/architecture/0004-stamdata-as-code.md) | ADR — business-tunable reference data as typed, compile-time-validated config (not a production DB). | +| [architecture/0005-openzaak-behind-bff.md](reference/architecture/0005-openzaak-behind-bff.md) | ADR — connect to OpenZaak (ZGW APIs) behind the BFF via a config-gated data-source seam; the FE never changes. | +| [openzaak-integration.md](reference/openzaak-integration.md) | How the BFF sources cases from OpenZaak (the IZaakSource seam + ZGW client), and how to add the next slice. | | [stamdata.md](reference/stamdata.md) | How stamdata (config-as-code reference data) is laid out, how to add a table with zero UI code, and why coupling stays low. | | [audit-log.md](reference/audit-log.md) | How the data-minimised authz/PII-reveal audit trail is built, how to audit a new action, and the one-producer-hub coupling. | | [feature-flags.md](reference/feature-flags.md) | How runtime feature flags work (catalog-as-code + runtime state), how to add one, and the hand-wired gating coupling to watch. | diff --git a/docs/project/backlog/README.md b/docs/project/backlog/README.md index c20bb92..ac874ce 100644 --- a/docs/project/backlog/README.md +++ b/docs/project/backlog/README.md @@ -93,6 +93,10 @@ for its existing violations, so every WP ends green. | [WP-46](WP-46-vitest-coverage.md) | Vitest coverage (report + report-only thresholds) | 8 · platform/DX/showcase | done | | [WP-47](WP-47-feature-flags.md) | Runtime feature flags (catalog-in-code, admin toggle, FE+backend) | 8 · platform/DX/showcase | done | | [WP-48](WP-48-stamdata-deletion-protection.md) | Stamdata deletion protection (CI referential gate + editor expire/warn) | 8 · platform/DX/showcase | done | +| [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 | todo | +| [WP-51](WP-51-openzaak-documenten.md) | OpenZaak Documenten (DRC) upload + zaak link | 9 · OpenZaak/ZGW | todo | +| [WP-52](WP-52-openzaak-notificaties.md) | OpenZaak Notificaties (NRC) live status via webhook | 9 · OpenZaak/ZGW | todo | Sequencing dependencies (stated in the WPs too): 01 before 10–15 (axe covers story churn); 03/04 before 05–09 (boundaries stop new violations during refactors); 06 before 07 (typed @@ -114,6 +118,10 @@ WP-40 (P2) → WP-43 (P3) → WP-41 → WP-42 → WP-44 → WP-45 (P4). Ordering make the generator simple) + 43; 45 (`create-ssp`) needs 43+44. 37/38/39/40/43/46 are otherwise independent. Two open tool forks, decided as step 1 of their WP: 38 dependency-cruiser vs Sheriff; 43 plop vs Angular schematics. +Phase 9 (OpenZaak/ZGW integration, WP-49..52) is strictly ordered 49 → 50 → 51 → 52: 49 lands +the source seam + ZGW client the rest reuse; 50 (create) needs a zaak to exist; 51 (documents) +links to 50's zaak; 52 (notificaties) reacts to changes on the zaken 49–51 manage. All slices +stay behind `Zgw:Enabled` (default off) so the POC keeps running offline. ## WP template diff --git a/docs/project/backlog/WP-49-openzaak-zaken-read-seam.md b/docs/project/backlog/WP-49-openzaak-zaken-read-seam.md new file mode 100644 index 0000000..36a00c0 --- /dev/null +++ b/docs/project/backlog/WP-49-openzaak-zaken-read-seam.md @@ -0,0 +1,59 @@ +# WP-49 — OpenZaak zaken read seam (slice 1) + +Status: done +Phase: 9 — OpenZaak / ZGW integration + +## Why + +The POC serves cases from local SQLite. To grow toward production it must be able to source +them from a real ZGW backend (OpenZaak) **without changing the frontend** (BFF-lite, ADR-0001). +The backend had no data-access abstraction to swap behind, no outbound HTTP, and no JWT. This is +the first thin vertical slice: read-only zaken. + +## Read first + +- [ADR-0005 — OpenZaak behind the BFF](../reference/architecture/0005-openzaak-behind-bff.md) +- [openzaak-integration.md](../reference/openzaak-integration.md) + +## Decisions (pre-made, don't relitigate) + +- OpenZaak's anti-corruption layer lives in the **BFF**, never the browser. +- Source selected by config (`Zgw:Enabled`, default false) → POC still runs offline. +- Each source maps into the **existing** `ApplicationSummaryDto` → no api-client drift, no FE change. +- Fresh HS256 JWT **per call** (no refresh flow). Hand-rolled (no new NuGet). +- `IZaakSource` is sync (matches the endpoint + local store); OpenZaak source does sync-over-async. + +## Files + +- `Data/IZaakSource.cs`, `Data/LocalZaakSource.cs` +- `Zgw/{ZgwOptions,ZgwTokenProvider,ZgwZaakMapper,OpenZaakZaakSource}.cs` +- `Program.cs` (DI + resolve `IZaakSource` in `/admin/cases`), `appsettings.json` (`Zgw` section) +- tests: `ZgwTokenProviderTests`, `ZgwZaakMapperTests`, `OpenZaakZaakSourceTests` + +## Steps + +1. Extract the cases read into `IZaakSource`; `LocalZaakSource` delegates to `ApplicationStore`. +2. Build the `Zgw/` client (options, JWT minter, ZGW→DTO mapper, paginating HTTP source). +3. Wire DI by config; refactor `/admin/cases` to resolve `IZaakSource`. +4. Unit-test the minter, mapper, and source (fixtures + stub `HttpMessageHandler`). + +## Acceptance criteria + +- [x] `/admin/cases` serves identical DTOs via `LocalZaakSource` (default, offline). +- [x] `OpenZaakZaakSource` follows pagination, resolves+caches zaaktype labels, sends a Bearer token. +- [x] JWT verifies (HS256) with the required claims. +- [x] `dotnet test` green (142); `npm run ci` green with **no api-client drift** (FE untouched). + +## Verification + +`cd backend && dotnet test`; `npm run ci`; manual: `/beheer/zaken` still lists cases with `Zgw:Enabled=false`. + +## Out of scope + +Create-zaak (WP-50), Documenten/DRC (WP-51), Notificaties (WP-52), real inbound OIDC/JWT, +OpenZaak in docker-compose. + +## Risks + +Sync-over-async blocks a thread under load if OpenZaak becomes the default → make the read path +async then (noted at the call site). diff --git a/docs/project/backlog/WP-50-openzaak-create-zaak.md b/docs/project/backlog/WP-50-openzaak-create-zaak.md new file mode 100644 index 0000000..715666e --- /dev/null +++ b/docs/project/backlog/WP-50-openzaak-create-zaak.md @@ -0,0 +1,52 @@ +# WP-50 — OpenZaak create-zaak (first write slice) + +Status: todo +Phase: 9 — OpenZaak / ZGW integration + +## Why + +WP-49 made the cases **read** path source-swappable. The next slice is the first **write**: +create a Zaak in OpenZaak when an aanvraag is submitted, still behind the config gate, still +without changing the FE contract. + +## Read first + +- [openzaak-integration.md](../reference/openzaak-integration.md) — "How to add the next slice" +- [ADR-0005](../reference/architecture/0005-openzaak-behind-bff.md), [ADR-0001](../reference/architecture/0001-bff-lite-decision-dtos.md) + +## Decisions (pre-made, don't relitigate) + +- Route the create through the existing submit/mutation seam; keep the FE response DTO identical. +- A create needs a `zaaktype` **URL** from Catalogi (OpenZaak validates it by fetching) — map + the aanvraag `type` → a configured zaaktype URL. +- Follow the create with a `status` + a `rol` (initiator/betrokkene by BSN) as ZGW expects. + +## Files + +- Extend `IZaakSource` (or add a write method) + `OpenZaakZaakSource`; `LocalZaakSource` keeps + the current local submit. +- `ZgwOptions`: a `type → zaaktype URL` map + `bronorganisatie`/`verantwoordelijkeOrganisatie` (RSIN). + +## Steps + +1. Add `CreateZaak` to the source seam; local impl = current submit, OpenZaak impl = POST to ZRC. +2. Map aanvraag `type` → zaaktype URL; POST zaak, then status + rol. +3. Map the created Zaak back into the existing submit response DTO. + +## Acceptance criteria + +- [ ] Submitting with `Zgw:Enabled=true` creates a Zaak (+ status + rol) in OpenZaak. +- [ ] FE submit response DTO unchanged; no api-client drift. +- [ ] Covered by tests (stub handler asserts the POST bodies + type→zaaktype mapping). + +## Verification + +`dotnet test`; against a docker OpenZaak if available. + +## Out of scope + +Documenten (WP-51), Notificaties (WP-52). + +## Risks + +Create needs read scope on Catalogi (type-URL validation) — provision AC scopes accordingly. diff --git a/docs/project/backlog/WP-51-openzaak-documenten.md b/docs/project/backlog/WP-51-openzaak-documenten.md new file mode 100644 index 0000000..bdcaa69 --- /dev/null +++ b/docs/project/backlog/WP-51-openzaak-documenten.md @@ -0,0 +1,52 @@ +# WP-51 — OpenZaak Documenten (DRC) upload + link + +Status: todo +Phase: 9 — OpenZaak / ZGW integration + +## Why + +Uploaded documents currently persist as bytes in local SQLite (`DocumentStore`). To be +production-ready they must live in OpenZaak's **Documenten API (DRC)** as +`enkelvoudiginformatieobjecten`, linked to a Zaak via `zaakinformatieobject` — behind the same +config gate, still without a FE contract change. + +## Read first + +- [openzaak-integration.md](../reference/openzaak-integration.md) +- WP-49 (the seam pattern), WP-50 (create-zaak, the zaak to link to) + +## Decisions (pre-made, don't relitigate) + +- Introduce an `IDocumentSource` sibling of `IZaakSource`; local impl = `DocumentStore`, + OpenZaak impl = DRC. FE upload DTOs unchanged. +- A document needs an `informatieobjecttype` **URL** from Catalogi (like zaaktype for a zaak). +- Upload → returns document URL → `zaakinformatieobject` links it to the zaak URL. + +## Files + +- `Data/IDocumentSource.cs`, `Data/LocalDocumentSource.cs`, `Zgw/OpenZaakDocumentSource.cs` +- `ZgwOptions`: `informatieobjecttype` URL(s) + `DrcBaseUrl`. + +## Steps + +1. Abstract the upload/read/link paths behind `IDocumentSource`. +2. OpenZaak impl: POST `enkelvoudiginformatieobjecten` (content), then POST `zaakinformatieobjecten`. +3. Map DRC document metadata back into the existing document DTOs. + +## Acceptance criteria + +- [ ] With `Zgw:Enabled=true`, an upload lands in DRC and is linked to its zaak. +- [ ] FE upload/list DTOs unchanged; no api-client drift. +- [ ] Tests cover the DRC POST bodies + the zaak-link step (stub handler). + +## Verification + +`dotnet test`; against a docker OpenZaak if available. + +## Out of scope + +Notificaties (WP-52), content virus-scanning / blob-storage tuning. + +## Risks + +Large file content over base64/multipart — mind memory; stream if needed. diff --git a/docs/project/backlog/WP-52-openzaak-notificaties.md b/docs/project/backlog/WP-52-openzaak-notificaties.md new file mode 100644 index 0000000..6dc9ce5 --- /dev/null +++ b/docs/project/backlog/WP-52-openzaak-notificaties.md @@ -0,0 +1,51 @@ +# WP-52 — OpenZaak Notificaties (NRC) live status + +Status: todo +Phase: 9 — OpenZaak / ZGW integration + +## Why + +With cases in OpenZaak, case status changes in the backoffice, not in this app. Production +"live" status needs the **Notificaties API (NRC)**: subscribe to zaak events and update on +webhook, rather than polling. Last slice of the ZGW integration arc. + +## Read first + +- [openzaak-integration.md](../reference/openzaak-integration.md) +- WP-49/50/51 (the read/write/document slices this builds on) + +## Decisions (pre-made, don't relitigate) + +- The BFF exposes a webhook endpoint that NRC calls; it validates an Authorization header the + BFF issued, then invalidates any cached case data / notifies the FE. +- Subscription (`abonnement` on the `zaken` kanaal) is provisioning/config, not runtime code. +- FE update mechanism reuses the existing RemoteData reload — no new FE contract. + +## Files + +- A new BFF webhook endpoint (`POST /zgw/notificaties`) + Authorization validation. +- `ZgwOptions`: `NrcBaseUrl` + the webhook shared secret. + +## Steps + +1. Add the webhook endpoint (auth-checked, no PII logged — reuse the audit seam). +2. On a zaak event, invalidate cache / push an update to the FE. +3. Document the `abonnement` provisioning (out-of-band, one-time). + +## 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. + +## Verification + +`dotnet test`; against a docker OpenZaak + NRC if available. + +## Out of scope + +Full event fan-out / real-time push infra beyond a simple cache-invalidation + reload. + +## Risks + +Webhook must be reachable from NRC in prod (network/ingress) — a deployment concern, not code. diff --git a/docs/reference/architecture/0005-openzaak-behind-bff.md b/docs/reference/architecture/0005-openzaak-behind-bff.md new file mode 100644 index 0000000..08ce418 --- /dev/null +++ b/docs/reference/architecture/0005-openzaak-behind-bff.md @@ -0,0 +1,69 @@ +# ADR-0005 — OpenZaak (ZGW APIs) behind the BFF + +Status: Accepted · Date: 2026-07-24 + +## Context + +The POC serves cases (aanvragen) from a local SQLite store. To grow toward production it must +be able to source cases from a real Dutch **Zaakgericht Werken (ZGW)** backend — **OpenZaak**, +the VNG reference implementation. ZGW is not one API but five separate services (Zaken/ZRC, +Documenten/DRC, Catalogi/ZTC, Besluiten/BRC, Notificaties/NRC), each on its own base URL, with +traits that make raw responses unfit to hand to a browser: + +- resources are identified by **full URLs**, not bare ids; +- references between resources are **URLs into other services** (a zaak's `zaaktype` lives in + Catalogi), so a single screen means joining across services; +- lists use a uniform `{count,next,previous,results}` pagination envelope; +- auth is a short-lived **HS256 JWT** signed with a client secret (no OAuth refresh), which + OpenZaak rejects an hour past `iat`. + +Two constraints shaped the decision: the **frontend must not change** (BFF-lite, ADR-0001 — +the FE renders decision DTOs and never recomputes rules), and the POC must **still run fully +offline** (no OpenZaak needed for local dev/CI). + +The backend, however, had **no data-access abstraction** — endpoints called concrete static +stores directly — and no outbound HTTP or JWT machinery. So there was no injection point to +swap a data source behind. + +## Options + +1. **FE talks to OpenZaak directly.** Rejected: leaks ZGW shapes + the client secret to the + browser, and contradicts BFF-lite. +2. **Rewrite the static stores in place to call OpenZaak.** Rejected: no seam, no offline mode, + all-or-nothing, untestable without a live server. +3. **Introduce a data-source interface behind the existing DTO contract, select the + implementation by config.** Chosen. + +## Decision + +Put the OpenZaak anti-corruption layer **in the .NET BFF**, never in the browser. Introduce a +per-domain source interface (starting with `IZaakSource` for the cases read path) whose default +implementation reads the local SQLite store and whose alternate implementation calls OpenZaak — +selected by a config flag (`Zgw:Enabled`, default false). Each implementation maps into the +**existing** wire DTO (`ApplicationSummaryDto`), so the `/api/v1` contract and the FE are +untouched. The BFF holds the client secret and **mints a fresh JWT per outbound call**. + +This is deliberately a **thin vertical slice** (read-only zaken, WP-49); create/documents/ +notifications follow the same seam in later slices (WP-50/51/52) rather than being scaffolded +up front — the migration stance ADR-0001 already prescribes. + +## Consequences + +- **+** The FE is production-ready as-is: swapping to OpenZaak is backend-only, behind one + config flag, with zero DTO/api-client drift. The POC still runs offline (default = local). +- **+** The seam is unit-testable without a live server: the JWT minter, the ZGW→DTO mapper, + and the paginating source are all covered with fixtures + a stub `HttpMessageHandler`. +- **+** URL-as-identity and cross-service joins are contained in one mapper; nothing downstream + sees a ZGW shape. +- **−** Only the cases **read** path has a source interface today; other endpoints still call + static stores directly. Each future slice introduces its own seam as needed (not a big-bang + repository refactor). +- **−** `IZaakSource` is synchronous (matching the existing sync endpoint + local store), so + `OpenZaakZaakSource` does sync-over-async; fine under ASP.NET Core (no sync-context), to be + made async if OpenZaak becomes the default. Marked with a `ponytail:` note at the call site. +- **Shipped with this ADR (WP-49):** `IZaakSource` + `LocalZaakSource` (default) + + `OpenZaakZaakSource` (config-gated), the `Zgw/` client (`ZgwOptions`, `ZgwTokenProvider`, + `ZgwZaakMapper`), and the reference guide [openzaak-integration.md](../openzaak-integration.md). +- **Deferred:** real inbound OIDC/JWT auth (still header-stubbed), create-zaak (WP-50), + Documenten/DRC upload + link (WP-51), Notificaties/NRC webhooks (WP-52), adding OpenZaak to + docker-compose. diff --git a/docs/reference/openzaak-integration.md b/docs/reference/openzaak-integration.md new file mode 100644 index 0000000..1750864 --- /dev/null +++ b/docs/reference/openzaak-integration.md @@ -0,0 +1,88 @@ +# OpenZaak / ZGW integration — how the BFF connects (& how to extend) + +How the BFF sources cases from a real **OpenZaak** (ZGW APIs) while the frontend stays +unchanged. For the _why_, see [ADR-0005](architecture/0005-openzaak-behind-bff.md); this page +is _how the seam is built and how to add the next slice_. Built in +[WP-49](../project/backlog/WP-49-openzaak-zaken-read-seam.md) (read-only zaken). + +## The one rule: OpenZaak sits behind the BFF, never in the browser + +The Angular app only ever sees the BFF's decision DTOs (BFF-lite, ADR-0001). All ZGW +awkwardness — URL-as-identity, cross-service joins, JWT auth, pagination — is absorbed by the +.NET BFF. Flipping the data source from local SQLite to OpenZaak is a **backend config change** +with **zero frontend change and no api-client drift**. + +## The seam (data source by config) + +- `Data/IZaakSource.cs` — the cases READ interface. Returns the existing + `ApplicationSummaryDto`, so each implementation owns its own mapping. +- `Data/LocalZaakSource.cs` — **default**; reads the local SQLite `ApplicationStore` + (offline, unchanged behaviour). +- `Zgw/OpenZaakZaakSource.cs` — the OpenZaak client; selected only when `Zgw:Enabled=true`. +- Wiring (`Program.cs`): `if (Zgw:Enabled) AddHttpClient() +else AddSingleton()`. The `/admin/cases` endpoint resolves + `IZaakSource` from DI — routes + DTOs untouched. + +## The ZGW client (`backend/src/BigRegister.Api/Zgw/`) + +- `ZgwOptions.cs` — bound from the `Zgw` appsettings section: `Enabled`, per-service base URLs + (`ZrcBaseUrl`, `ZtcBaseUrl`), `ClientId`, `Secret`, `UserId`, `UserRepresentation`. The five + ZGW APIs are separate base URLs; slice 1 needs only Zaken (ZRC) + Catalogi (ZTC). +- `ZgwTokenProvider.cs` — mints an **HS256 JWT per call** (`iss`/`client_id`/`iat`/`user_id`/ + `user_representation`). No refresh flow — OpenZaak expires tokens 1h past `iat`, so per-call + minting is the recommended pattern. Hand-rolled (no `Microsoft.IdentityModel.*` dependency). +- `ZgwZaakMapper.cs` — the anti-corruption map: ZGW Zaak → `ApplicationSummaryDto`. This is + where **URL identity** becomes the trailing uuid and the **zaaktype URL** is resolved to a + human label (the cross-service join). +- `OpenZaakZaakSource.cs` — follows `{count,next,previous,results}` pagination, resolves + + caches zaaktype labels, attaches `Authorization: Bearer `. + +## The five ZGW APIs (context for later slices) + +| API | Component | Used by | +| ------------ | --------- | --------------------------------------------------- | +| Zaken | ZRC | slice 1 (read), WP-50 (create) | +| Catalogi | ZTC | slice 1 (zaaktype label; also type URLs for create) | +| Documenten | DRC | WP-51 (upload + zaak↔document link) | +| Besluiten | BRC | later (formal decisions) | +| Notificaties | NRC | WP-52 (live status via webhooks, not polling) | + +## How to add the next slice + +1. **Read** — extend `IZaakSource` (or add a sibling interface, e.g. `IDocumentSource`) with + the new operation; implement it on both `LocalZaakSource` and the OpenZaak source. Keep the + return type the existing DTO so the FE never changes. +2. **Write** (create-zaak, WP-50) — a create needs a `zaaktype` URL from Catalogi (OpenZaak + validates it by fetching), then usually a follow-up `status` + `rol`. Route it through the + existing submit/mutation seam. +3. **Enforce server-side** for anything the FE gates — a config value the FE echoes is never + the authority (ADR-0001). + +## Coupling + +Low and one-directional. Consumer coupling is near zero — `IZaakSource` is injected at one +endpoint, and the FE is fully decoupled by the DTO. The producer side is contained in `Zgw/`: +add a slice by adding a source method + a mapper case, not by touching the FE or the contract. +Watch the **sync-over-async** `ponytail:` note in `OpenZaakZaakSource` — make the cases read +path async if OpenZaak becomes the default. + +## Config + +```jsonc +// appsettings.json — off by default (POC runs offline on the local store) +"Zgw": { + "Enabled": true, + "ZrcBaseUrl": "https://open-zaak.example/zaken/api/v1", + "ZtcBaseUrl": "https://open-zaak.example/catalogi/api/v1", + "ClientId": "big-register", "Secret": "", + "UserId": "", "UserRepresentation": "" +} +``` + +## See also + +- [ADR-0005 — OpenZaak behind the BFF](architecture/0005-openzaak-behind-bff.md) — the decision. +- [ADR-0001 — BFF-lite + decision DTOs](architecture/0001-bff-lite-decision-dtos.md) — why the FE doesn't change. +- [WP-49](../project/backlog/WP-49-openzaak-zaken-read-seam.md) (this), WP-50/51/52 (later slices). +- `backend/src/BigRegister.Api/Zgw/` — the client; `Data/IZaakSource.cs` — the seam. +- [ZGW standard (VNG)](https://vng-realisatie.github.io/gemma-zaken/) · [OpenZaak auth docs](https://open-zaak.readthedocs.io/en/stable/client-development/authentication.html). diff --git a/docs/reference/stamdata.md b/docs/reference/stamdata.md index b5641ed..863f47d 100644 --- a/docs/reference/stamdata.md +++ b/docs/reference/stamdata.md @@ -6,6 +6,11 @@ build**, not a runtime-editable database. For the _why_, see [ADR-0004 — Stamdata as code](architecture/0004-stamdata-as-code.md); this page is _how the code is laid out and how to add a table without coupling_. Built in WP-29, hardened in WP-48. +Tables today: `professions` (opleiding-program → beroep), `beroepen` (the BIG professions master +list), `opleidingen` (temporal; `beroep` → `beroepen.code`) and `specialismen` (`beroep` → +`beroepen.code`). The last two are **stamdata → stamdata** references — one table keyed on by two +others — enforced by the CI gate below. + ## The one rule that shapes everything: no runtime write path The catalog is the source of truth and lives in code. The admin editor **downloads** an @@ -50,8 +55,11 @@ component**. This is the payoff of the schema-driven design. `backend/tests/BigRegister.Tests/StamdataValidationTests.cs`. `Every_catalog_table_is_valid` covers every registered table generically; the `StamdataRef` list catches dangling -references (today: `Diploma.Opleiding → professions.program`). A bad edit, an orphaning -delete, or a premature expire **fails the PR build** — never prod. +references — both seed → stamdata (`Diploma.Opleiding → professions.program`) and +stamdata → stamdata (`Opleiding.beroep → beroepen.code`, `Specialisme.beroep → +beroepen.code`). A bad edit, an orphaning delete, or a premature expire **fails the PR +build** — never prod. Adding a cross-table FK is one `StamdataRef` entry: the referencing +keys + a resolver against the target table's (valid-today) keys. ## Coupling