From 66f832258005dc4d10c3445d55ebfbe910052e44 Mon Sep 17 00:00:00 2001 From: Niek Otten Date: Fri, 14 Aug 2026 09:16:23 +0200 Subject: [PATCH 01/11] test(acl): approval writes the RegisterRecord to Objecten (refs #149) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ports and failing tests for the Objecten hop, ahead of the implementation: - IRegisterRecordGateway + RegisterRecord — the Application-side port; the record mirrors the objecttype schema registered in S-18c (ADR-0027). - AclService takes the port but does not yet call it, so the approval test fails on an empty upsert list. - ObjectenGateway is a shell throwing NotImplementedException; its tests pin the contract: resolve the objecttype by name, search by data attribute, POST when absent / PATCH when present, static Token auth per API, the CRS headers the geo API requires, and a surfaced error body. Also splits S-19 (#20) into #149/#150 in BACKLOG.md — the approval-side write and the projection re-sourcing are independently deployable (CLAUDE.md §13). --- BACKLOG.md | 7 +- services/acl/Acl.Application/AclService.cs | 7 +- .../Acl.Application/IRegisterRecordGateway.cs | 30 +++ .../acl/Acl.Infrastructure/ObjectenGateway.cs | 10 + .../acl/Acl.Infrastructure/ObjectenOptions.cs | 20 ++ services/acl/Acl.Tests/AclServiceTests.cs | 50 ++++- .../acl/Acl.Tests/ObjectenGatewayTests.cs | 184 ++++++++++++++++++ tests/acceptance/Steps/EenZaakOpenenSteps.cs | 2 +- .../acceptance/Support/InMemoryZaakGateway.cs | 13 ++ 9 files changed, 318 insertions(+), 5 deletions(-) create mode 100644 services/acl/Acl.Application/IRegisterRecordGateway.cs create mode 100644 services/acl/Acl.Infrastructure/ObjectenGateway.cs create mode 100644 services/acl/Acl.Infrastructure/ObjectenOptions.cs create mode 100644 services/acl/Acl.Tests/ObjectenGatewayTests.cs diff --git a/BACKLOG.md b/BACKLOG.md index 476d485..8a5ddbe 100644 --- a/BACKLOG.md +++ b/BACKLOG.md @@ -287,12 +287,17 @@ Split into independently deployable sub-slices (CLAUDE.md §13): - **S-18b** (#140, ✅) · Objecten API up in compose, wired to Objecttypen. Depends on S-18a. - **S-18c** (#141, ✅) · RegisterRecord objecttype defined + registered (public-safe JSON schema). Depends on S-18a/b. -### S-19 · ACL extension: write register-record to Objecten on approval +### S-19 · ACL extension: write register-record to Objecten on approval *(split — #20 closed)* **Outcome:** Approval path writes the canonical register record to Objecten, not OpenZaak eigenschappen. Projection now sourced from Objecten events. **ADR required:** "Why Objecten holds the register, OpenZaak holds the process." +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. +- **S-19b** (#150) · Read projection sourced from Objecten instead of NRC zaak events. Depends on S-19a. + --- ## Iteration 5 — Data governance module *(milestone: `Iteration 5 — Data Governance`)* diff --git a/services/acl/Acl.Application/AclService.cs b/services/acl/Acl.Application/AclService.cs index 28e83f9..37f2950 100644 --- a/services/acl/Acl.Application/AclService.cs +++ b/services/acl/Acl.Application/AclService.cs @@ -2,7 +2,12 @@ namespace Acl.Application; /// The ACL's single operation: open a zaak from a domain payload, /// default-filling the ZGW-mandatory fields (ADR-0003). -public sealed class AclService(IZaakGateway gateway, IDefaultFillStore fill, IZaaktypeCatalog catalog, IClock clock) +public sealed class AclService( + IZaakGateway gateway, + IRegisterRecordGateway register, + IDefaultFillStore fill, + IZaaktypeCatalog catalog, + IClock clock) { public async Task OpenZaakAsync(DomainRegistration registration, CancellationToken ct = default) { diff --git a/services/acl/Acl.Application/IRegisterRecordGateway.cs b/services/acl/Acl.Application/IRegisterRecordGateway.cs new file mode 100644 index 0000000..4540861 --- /dev/null +++ b/services/acl/Acl.Application/IRegisterRecordGateway.cs @@ -0,0 +1,30 @@ +namespace Acl.Application; + +/// +/// Port to the Objecten API, which holds the authoritative register record (S-19a, ADR-0028). +/// Implemented in Infrastructure — as with ZGW, the ACL is the only code that talks to the +/// upstream Common Ground module (§8.1). +/// +public interface IRegisterRecordGateway +{ + /// + /// Write the register record for a registration, creating it if absent and updating it if it + /// already exists. Idempotent on : a replayed approval updates + /// the existing object instead of creating a second one (§8.6). + /// + Task UpsertAsync(RegisterRecord record, CancellationToken ct = default); +} + +/// +/// The public-safe register record, matching the RegisterRecord objecttype schema registered +/// in S-18c (ADR-0027). No bsn, no name — the register is world-readable. +/// +public sealed record RegisterRecord(string Id, string Status, string? Reference); + +/// The register statuses the RegisterRecord objecttype's schema allows (ADR-0027). +public static class RegisterRecordStatus +{ + public const string Ingediend = "INGEDIEND"; + + public const string Ingeschreven = "INGESCHREVEN"; +} diff --git a/services/acl/Acl.Infrastructure/ObjectenGateway.cs b/services/acl/Acl.Infrastructure/ObjectenGateway.cs new file mode 100644 index 0000000..50fc445 --- /dev/null +++ b/services/acl/Acl.Infrastructure/ObjectenGateway.cs @@ -0,0 +1,10 @@ +using Acl.Application; + +namespace Acl.Infrastructure; + +/// The only code that talks to the Objecten API (ADR-0028). +public sealed class ObjectenGateway(HttpClient http, ObjectenOptions options, IClock clock) : IRegisterRecordGateway +{ + public Task UpsertAsync(RegisterRecord record, CancellationToken ct = default) => + throw new NotImplementedException(); +} diff --git a/services/acl/Acl.Infrastructure/ObjectenOptions.cs b/services/acl/Acl.Infrastructure/ObjectenOptions.cs new file mode 100644 index 0000000..9e46d13 --- /dev/null +++ b/services/acl/Acl.Infrastructure/ObjectenOptions.cs @@ -0,0 +1,20 @@ +namespace Acl.Infrastructure; + +/// +/// Connection + credential config for the Objecten and Objecttypen APIs. Both authenticate with a +/// static Authorization: Token … (they are not ZGW JWT APIs), so there is no client-id/secret +/// pair as with OpenZaak. +/// +public sealed class ObjectenOptions +{ + public required Uri BaseUrl { get; init; } + public required string Token { get; init; } + + /// Objecttypen API root — the ACL resolves the objecttype URL + version from it by name + /// rather than pinning a seed-time UUID in config (same reasoning as ADR-0021). + public required Uri ObjecttypenBaseUrl { get; init; } + public required string ObjecttypenToken { get; init; } + + /// The objecttype the register record is written as (S-18c registers "RegisterRecord"). + public required string ObjecttypeName { get; init; } +} diff --git a/services/acl/Acl.Tests/AclServiceTests.cs b/services/acl/Acl.Tests/AclServiceTests.cs index 6b726f8..158be35 100644 --- a/services/acl/Acl.Tests/AclServiceTests.cs +++ b/services/acl/Acl.Tests/AclServiceTests.cs @@ -75,6 +75,17 @@ public class AclServiceTests Task.FromResult(Zaaktypen); } + private sealed class FakeRegisterRecordGateway : IRegisterRecordGateway + { + public readonly List Upserted = []; + + public Task UpsertAsync(RegisterRecord record, CancellationToken ct = default) + { + Upserted.Add(record); + return Task.CompletedTask; + } + } + private static AclDefaults Defaults() => new() { Bronorganisatie = "517439943", @@ -88,7 +99,10 @@ public class AclServiceTests new(new DefaultFillSettings(d.Bronorganisatie, d.VerantwoordelijkeOrganisatie, d.Vertrouwelijkheidaanduiding)); private static AclService ServiceWith(FakeGateway gateway, AclDefaults defaults, DateOnly today) => - new(gateway, FillFrom(defaults), new CachedZaaktypeCatalog(gateway, defaults), new FixedClock(today)); + ServiceWith(gateway, new FakeRegisterRecordGateway(), defaults, today); + + private static AclService ServiceWith(FakeGateway gateway, FakeRegisterRecordGateway register, AclDefaults defaults, DateOnly today) => + new(gateway, register, FillFrom(defaults), new CachedZaaktypeCatalog(gateway, defaults), new FixedClock(today)); private sealed class FixedClock(DateOnly today) : IClock { @@ -161,10 +175,42 @@ public class AclServiceTests public async Task Approving_a_null_zaak_is_rejected_without_touching_the_gateway() { var gateway = new FakeGateway(); - var service = ServiceWith(gateway, Defaults(), new DateOnly(2026, 6, 4)); + var register = new FakeRegisterRecordGateway(); + var service = ServiceWith(gateway, register, Defaults(), new DateOnly(2026, 6, 4)); await Assert.ThrowsAsync(() => service.ApproveZaakAsync(null!)); Assert.Null(gateway.Approved); + Assert.Empty(register.Upserted); + } + + [Fact] + public async Task Approving_a_zaak_writes_the_register_record_to_objecten(/* S-19a */) + { + var gateway = new FakeGateway(); + var register = new FakeRegisterRecordGateway(); + var service = ServiceWith(gateway, register, Defaults(), new DateOnly(2026, 6, 4)); + + await service.ApproveZaakAsync(new Uri("http://openzaak/zaken/api/v1/zaken/abc")); + + var record = Assert.Single(register.Upserted); + // The record is keyed on the zaak id — the same key the read projection rows carry (S-19b). + Assert.Equal("abc", record.Id); + Assert.Equal("INGESCHREVEN", record.Status); + // The public-safe reference comes from the zaak's identificatie, never from the domain payload. + Assert.Equal("REG-FROM-ZAAK", record.Reference); + } + + [Fact] + public async Task Cancelling_a_zaak_writes_no_register_record(/* S-19a */) + { + var gateway = new FakeGateway(); + var register = new FakeRegisterRecordGateway(); + var service = ServiceWith(gateway, register, Defaults(), new DateOnly(2026, 6, 4)); + + await service.CancelZaakAsync(new Uri("http://openzaak/zaken/api/v1/zaken/abc")); + + // Only an approval enters the register; a cancelled zaak never becomes a register record. + Assert.Empty(register.Upserted); } [Fact] diff --git a/services/acl/Acl.Tests/ObjectenGatewayTests.cs b/services/acl/Acl.Tests/ObjectenGatewayTests.cs new file mode 100644 index 0000000..bbedc84 --- /dev/null +++ b/services/acl/Acl.Tests/ObjectenGatewayTests.cs @@ -0,0 +1,184 @@ +using System.Net; +using System.Net.Http.Json; +using Acl.Application; +using Acl.Infrastructure; + +namespace Acl.Tests; + +public class ObjectenGatewayTests +{ + private sealed class StubHandler(Func> onSend) + : HttpMessageHandler + { + protected override Task SendAsync(HttpRequestMessage request, CancellationToken ct) + => onSend(request); + } + + private sealed class FixedClock(DateOnly today) : IClock + { + public DateOnly Today { get; } = today; + } + + private sealed record Sent(HttpMethod Method, Uri Uri, string? Body, string? Auth, string? ContentCrs, string? AcceptCrs); + + private const string ObjecttypeUrl = "http://objecttypen:8000/api/v2/objecttypes/ot-1"; + + private static ObjectenGateway Gateway(List sent, Func respond) => + new( + new HttpClient(new StubHandler(async req => + { + sent.Add(new Sent( + req.Method, + req.RequestUri!, + req.Content is null ? null : await req.Content.ReadAsStringAsync(), + req.Headers.Authorization?.ToString(), + req.Content?.Headers.TryGetValues("Content-Crs", out var c) == true ? string.Join(",", c!) : null, + req.Headers.TryGetValues("Accept-Crs", out var a) ? string.Join(",", a) : null)); + return respond(req); + })), + new ObjectenOptions + { + BaseUrl = new("http://objecten:8000"), + Token = "objecten-token", + ObjecttypenBaseUrl = new("http://objecttypen:8000"), + ObjecttypenToken = "objecttypen-token", + ObjecttypeName = "RegisterRecord", + }, + new FixedClock(new DateOnly(2026, 6, 4))); + + // The Objecttypen lookup (by name) and the Objecten search (by data attribute) that precede every + // write. `respondToWrite` decides what the POST/PATCH returns. + private static HttpResponseMessage Route(HttpRequestMessage req, object[] existingObjects) => + req.RequestUri!.AbsolutePath.StartsWith("/api/v2/objecttypes", StringComparison.Ordinal) + ? new HttpResponseMessage(HttpStatusCode.OK) + { + Content = JsonContent.Create(new + { + results = new[] + { + new { url = "http://objecttypen:8000/api/v2/objecttypes/other", name = "SomethingElse", versions = new[] { "…/versions/1" } }, + new { url = ObjecttypeUrl, name = "RegisterRecord", versions = new[] { "…/versions/1", "…/versions/2" } }, + }, + }), + } + : req.Method == HttpMethod.Get + ? new HttpResponseMessage(HttpStatusCode.OK) { Content = JsonContent.Create(new { results = existingObjects }) } + : new HttpResponseMessage(HttpStatusCode.Created) { Content = JsonContent.Create(new { url = "http://objecten:8000/api/v2/objects/obj-1" }) }; + + private static RegisterRecord Record() => new("zaak-uuid-1", RegisterRecordStatus.Ingeschreven, "REG-2026-0001"); + + [Fact] + public async Task Creates_the_object_when_none_exists_for_the_registration() + { + var sent = new List(); + + await Gateway(sent, req => Route(req, [])).UpsertAsync(Record()); + + var write = sent.Single(s => s.Method == HttpMethod.Post && s.Uri.AbsolutePath == "/api/v2/objects"); + Assert.Contains($"\"type\":\"{ObjecttypeUrl}\"", write.Body); + Assert.Contains("\"typeVersion\":2", write.Body); + Assert.Contains("\"id\":\"zaak-uuid-1\"", write.Body); + Assert.Contains("\"status\":\"INGESCHREVEN\"", write.Body); + Assert.Contains("\"reference\":\"REG-2026-0001\"", write.Body); + Assert.Contains("\"startAt\":\"2026-06-04\"", write.Body); + } + + [Fact] + public async Task Updates_the_existing_object_instead_of_creating_a_second_one() + { + var sent = new List(); + object[] existing = [new { uuid = "obj-9", url = "http://objecten:8000/api/v2/objects/obj-9" }]; + + await Gateway(sent, req => Route(req, existing)).UpsertAsync(Record()); + + Assert.DoesNotContain(sent, s => s.Method == HttpMethod.Post && s.Uri.AbsolutePath == "/api/v2/objects"); + var write = sent.Single(s => s.Method == HttpMethod.Patch); + Assert.Equal("http://objecten:8000/api/v2/objects/obj-9", write.Uri.ToString()); + Assert.Contains("\"status\":\"INGESCHREVEN\"", write.Body); + } + + [Fact] + public async Task Searches_objecten_for_the_registration_id_within_the_objecttype() + { + var sent = new List(); + + await Gateway(sent, req => Route(req, [])).UpsertAsync(Record()); + + var search = sent.Single(s => s.Method == HttpMethod.Get && s.Uri.AbsolutePath == "/api/v2/objects"); + Assert.Contains("type=" + Uri.EscapeDataString(ObjecttypeUrl), search.Uri.Query); + Assert.Contains("data_attrs=id__exact__zaak-uuid-1", search.Uri.Query); + } + + [Fact] + public async Task Authenticates_with_the_static_token_of_each_api() + { + var sent = new List(); + + await Gateway(sent, req => Route(req, [])).UpsertAsync(Record()); + + Assert.All( + sent.Where(s => s.Uri.AbsolutePath.StartsWith("/api/v2/objecttypes", StringComparison.Ordinal)), + s => Assert.Equal("Token objecttypen-token", s.Auth)); + Assert.All( + sent.Where(s => s.Uri.AbsolutePath.StartsWith("/api/v2/objects", StringComparison.Ordinal)), + s => Assert.Equal("Token objecten-token", s.Auth)); + } + + [Fact] + public async Task Sends_the_geo_crs_headers_the_objecten_api_requires() + { + var sent = new List(); + + await Gateway(sent, req => Route(req, [])).UpsertAsync(Record()); + + var objects = sent.Where(s => s.Uri.AbsolutePath.StartsWith("/api/v2/objects", StringComparison.Ordinal)).ToList(); + Assert.All(objects, s => Assert.Equal("EPSG:4326", s.AcceptCrs)); + Assert.All(objects.Where(s => s.Body is not null), s => Assert.Equal("EPSG:4326", s.ContentCrs)); + } + + [Fact] + public async Task Resolves_the_objecttype_once_and_reuses_it_across_writes() + { + var sent = new List(); + var gateway = Gateway(sent, req => Route(req, [])); + + await gateway.UpsertAsync(Record()); + await gateway.UpsertAsync(Record() with { Id = "zaak-uuid-2" }); + + Assert.Single(sent, s => s.Uri.AbsolutePath.StartsWith("/api/v2/objecttypes", StringComparison.Ordinal)); + } + + [Fact] + public async Task Fails_loudly_when_the_objecttype_is_not_registered() + { + var sent = new List(); + var gateway = Gateway(sent, _ => new HttpResponseMessage(HttpStatusCode.OK) + { + Content = JsonContent.Create(new { results = Array.Empty() }), + }); + + var error = await Assert.ThrowsAsync(() => gateway.UpsertAsync(Record())); + Assert.Contains("RegisterRecord", error.Message); + } + + [Fact] + public async Task Surfaces_the_objecten_error_body_when_a_write_is_rejected() + { + var sent = new List(); + var gateway = Gateway(sent, req => req.Method == HttpMethod.Post && req.RequestUri!.AbsolutePath == "/api/v2/objects" + ? new HttpResponseMessage(HttpStatusCode.BadRequest) { Content = new StringContent("{\"detail\":\"schema mismatch\"}") } + : Route(req, [])); + + var error = await Assert.ThrowsAsync(() => gateway.UpsertAsync(Record())); + Assert.Contains("schema mismatch", error.Message); + } + + [Fact] + public async Task Rejects_a_null_record_without_calling_objecten() + { + var sent = new List(); + + await Assert.ThrowsAsync(() => Gateway(sent, req => Route(req, [])).UpsertAsync(null!)); + Assert.Empty(sent); + } +} diff --git a/tests/acceptance/Steps/EenZaakOpenenSteps.cs b/tests/acceptance/Steps/EenZaakOpenenSteps.cs index b65a04f..f8c95c9 100644 --- a/tests/acceptance/Steps/EenZaakOpenenSteps.cs +++ b/tests/acceptance/Steps/EenZaakOpenenSteps.cs @@ -46,7 +46,7 @@ public sealed class EenZaakOpenenSteps { var fill = new InMemoryDefaultFillStore(new DefaultFillSettings( _defaults!.Bronorganisatie, _defaults.VerantwoordelijkeOrganisatie, _defaults.Vertrouwelijkheidaanduiding)); - var service = new AclService(_gateway, fill, new CachedZaaktypeCatalog(_gateway, _defaults!), new FixedClock(_today)); + var service = new AclService(_gateway, new InMemoryRegisterRecordGateway(), fill, new CachedZaaktypeCatalog(_gateway, _defaults!), new FixedClock(_today)); _returnedUrl = await service.OpenZaakAsync(_registration!); } diff --git a/tests/acceptance/Support/InMemoryZaakGateway.cs b/tests/acceptance/Support/InMemoryZaakGateway.cs index 99b6ce0..71142fc 100644 --- a/tests/acceptance/Support/InMemoryZaakGateway.cs +++ b/tests/acceptance/Support/InMemoryZaakGateway.cs @@ -53,3 +53,16 @@ public sealed class InMemoryZaakGateway : IZaakGateway => Task.FromResult>( [new ZaaktypeSummary("BIG-REGISTRATIE", "BIG-registratie", ResolvedZaaktypeUrl)]); } + +/// An in-memory stand-in for the Objecten API (S-19a): records the register records the ACL +/// writes on approval, so a scenario can assert on them without a running Objecten. +public sealed class InMemoryRegisterRecordGateway : IRegisterRecordGateway +{ + public List Upserted { get; } = []; + + public Task UpsertAsync(RegisterRecord record, CancellationToken ct = default) + { + Upserted.Add(record); + return Task.CompletedTask; + } +} -- 2.54.0 From d14f379358b8d0f39284307d55beb05ddfa95cab Mon Sep 17 00:00:00 2001 From: Niek Otten Date: Fri, 14 Aug 2026 09:18:34 +0200 Subject: [PATCH 02/11] feat(acl): write the RegisterRecord to Objecten on approval (refs #149) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ApproveZaakAsync now does two writes: the ZGW eindstatus (the process) and the register record in Objecten (the register). The record is keyed on the zaak UUID — the same key the read projection rows carry — and its reference is the zaak's identificatie, so nothing personal crosses into the world-readable register (ADR-0027). ObjectenGateway resolves the objecttype by name (its URL and version are assigned at seed time, as with ADR-0021), searches for an existing object by data attribute, then POSTs or PATCHes. Resolution is lazy, so the ACL needs no depends_on on Objecten and does not crash-loop when it boots first. --- infra/docker-compose.local.yml | 8 + infra/docker-compose.yml | 8 + services/acl/Acl.Api/Program.cs | 5 + services/acl/Acl.Application/AclService.cs | 21 ++- .../acl/Acl.Infrastructure/ObjectenGateway.cs | 139 +++++++++++++++++- 5 files changed, 176 insertions(+), 5 deletions(-) diff --git a/infra/docker-compose.local.yml b/infra/docker-compose.local.yml index dbbf4e5..9660876 100644 --- a/infra/docker-compose.local.yml +++ b/infra/docker-compose.local.yml @@ -338,6 +338,14 @@ services: Acl__Defaults__Vertrouwelijkheidaanduiding: openbaar Acl__Defaults__ZaaktypeIdentificatie: BIG-REGISTRATIE Acl__Defaults__InformatieobjecttypeOmschrijving: Diploma + # 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 + # it by name — lazily, on the first approval, so no depends_on is needed here. + Acl__Objecten__BaseUrl: http://objecten:8000/ + Acl__Objecten__Token: ${OBJECTEN_TOKEN:-1234567890abcdef1234567890abcdef12345678} + Acl__Objecten__ObjecttypenBaseUrl: http://objecttypen:8000/ + Acl__Objecten__ObjecttypenToken: ${OBJECTTYPEN_TOKEN:-0123456789abcdef0123456789abcdef01234567} + Acl__Objecten__ObjecttypeName: RegisterRecord ports: - "8100:8080" volumes: diff --git a/infra/docker-compose.yml b/infra/docker-compose.yml index c836501..903cd3f 100644 --- a/infra/docker-compose.yml +++ b/infra/docker-compose.yml @@ -323,6 +323,14 @@ services: # so verify-domain still points the ACL at OpenZaak's container IP. Acl__Defaults__ZaaktypeIdentificatie: BIG-REGISTRATIE Acl__Defaults__InformatieobjecttypeOmschrijving: Diploma + # 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 + # it by name — lazily, on the first approval, so no depends_on is needed here. + Acl__Objecten__BaseUrl: http://objecten:8000/ + Acl__Objecten__Token: ${OBJECTEN_TOKEN:-1234567890abcdef1234567890abcdef12345678} + Acl__Objecten__ObjecttypenBaseUrl: http://objecttypen:8000/ + Acl__Objecten__ObjecttypenToken: ${OBJECTTYPEN_TOKEN:-0123456789abcdef0123456789abcdef01234567} + Acl__Objecten__ObjecttypeName: RegisterRecord ports: - "8100:8080" healthcheck: diff --git a/services/acl/Acl.Api/Program.cs b/services/acl/Acl.Api/Program.cs index 9b1b7bc..72ca25c 100644 --- a/services/acl/Acl.Api/Program.cs +++ b/services/acl/Acl.Api/Program.cs @@ -42,7 +42,12 @@ builder.Services.AddSingleton(sp => return new InMemoryDefaultFillStore( new DefaultFillSettings(d.Bronorganisatie, d.VerantwoordelijkeOrganisatie, d.Vertrouwelijkheidaanduiding)); }); +builder.Services.AddSingleton(sp => sp.GetRequiredService() + .GetSection("Acl:Objecten").Get() + ?? throw new InvalidOperationException("Missing configuration section 'Acl:Objecten'")); builder.Services.AddHttpClient(); +// The Objecten hop that writes the register record on approval (S-19a, ADR-0028). +builder.Services.AddHttpClient(); // Singleton so the resolved zaaktype/informatieobjecttype URLs are cached across requests (S-27). builder.Services.AddSingleton(); builder.Services.AddScoped(); diff --git a/services/acl/Acl.Application/AclService.cs b/services/acl/Acl.Application/AclService.cs index 37f2950..cab5577 100644 --- a/services/acl/Acl.Application/AclService.cs +++ b/services/acl/Acl.Application/AclService.cs @@ -28,16 +28,33 @@ public sealed class AclService( } /// - /// Approve a zaak: set it to the eindstatus of the BIG zaaktype (resolved by identificatie, S-27). - /// The domain hands over only the zaak URL; the ACL owns which statustype means "approved" (§8.1). + /// Approve a zaak: set it to the eindstatus of the BIG zaaktype (resolved by identificatie, S-27), + /// then write the register record to Objecten (S-19a). The domain hands over only the zaak URL; the + /// ACL owns which statustype means "approved" and what the register record looks like (§8.1). /// + /// + /// OpenZaak holds the process, Objecten holds the register (ADR-0028), so approval is two writes + /// across two modules and is eventually consistent by construction. Both are idempotent — a status + /// is a log entry, the record upsert is keyed on the zaak id — so a caller that retries a failed + /// approval converges rather than duplicating. + /// public async Task ApproveZaakAsync(Uri zaakUrl, CancellationToken ct = default) { ArgumentNullException.ThrowIfNull(zaakUrl); await gateway.SetZaakToEindstatusAsync(zaakUrl, await catalog.GetZaaktypeUrlAsync(ct), clock.Today, ct); + + await register.UpsertAsync( + new RegisterRecord( + ZaakId(zaakUrl), + RegisterRecordStatus.Ingeschreven, + await gateway.GetZaakIdentificatieAsync(zaakUrl, ct)), + ct); } + /// The zaak's UUID — the key the register record and the read projection rows share. + private static string ZaakId(Uri zaakUrl) => zaakUrl.Segments[^1].TrimEnd('/'); + /// /// Cancel a zaak on document-timeout expiry (S-10c): set it to the BIG zaaktype's cancellation /// statustype + resultaat. The domain hands over only the zaak URL; the ACL owns which diff --git a/services/acl/Acl.Infrastructure/ObjectenGateway.cs b/services/acl/Acl.Infrastructure/ObjectenGateway.cs index 50fc445..722f62c 100644 --- a/services/acl/Acl.Infrastructure/ObjectenGateway.cs +++ b/services/acl/Acl.Infrastructure/ObjectenGateway.cs @@ -1,10 +1,143 @@ +using System.Net.Http.Headers; +using System.Net.Http.Json; +using System.Text.Json.Serialization; using Acl.Application; namespace Acl.Infrastructure; -/// The only code that talks to the Objecten API (ADR-0028). +/// +/// The only code that talks to the Objecten API (ADR-0028). Writes the register record as an object +/// of the RegisterRecord objecttype registered in S-18c. +/// public sealed class ObjectenGateway(HttpClient http, ObjectenOptions options, IClock clock) : IRegisterRecordGateway { - public Task UpsertAsync(RegisterRecord record, CancellationToken ct = default) => - throw new NotImplementedException(); + // The objecttype URL + version are assigned by Objecttypen at seed time, so they are resolved by + // name on first use rather than pinned in config (same reasoning as ADR-0021). + // ponytail: memoised per instance only — the gateway is a transient typed client, so in practice + // that is one extra GET per approval against a neighbouring container. Lift it into a singleton + // cache (as CachedZaaktypeCatalog does for ZGW) if approvals ever get hot. + private Objecttype? objecttype; + + public async Task UpsertAsync(RegisterRecord record, CancellationToken ct = default) + { + ArgumentNullException.ThrowIfNull(record); + + var type = objecttype ??= await ResolveObjecttypeAsync(ct); + var existing = await FindExistingAsync(type.Url, record.Id, ct); + var data = new RecordDataDto(record.Id, record.Status, record.Reference); + + // No existing object → create; otherwise PATCH, which appends a new record version to the same + // object. Either way the register ends up with exactly one object per registration (§8.6). + if (existing is null) + await SendAsync(HttpMethod.Post, new Uri(options.BaseUrl, "/api/v2/objects"), + new CreateObjectDto(type.Url.ToString(), NewRecord(type.Version, data)), + "Creating the register record", ct); + else + await SendAsync(HttpMethod.Patch, existing, + new PatchObjectDto(NewRecord(type.Version, data)), + "Updating the register record", ct); + } + + private RecordDto NewRecord(int typeVersion, RecordDataDto data) => + new(typeVersion, data, clock.Today.ToString("yyyy-MM-dd")); + + /// The URL + latest version number of the configured objecttype, read from Objecttypen. + private async Task ResolveObjecttypeAsync(CancellationToken ct) + { + var page = await GetAsync( + new Uri(options.ObjecttypenBaseUrl, "/api/v2/objecttypes"), + options.ObjecttypenToken, crs: false, "objecttypen", ct); + + var match = (page.Results ?? []).FirstOrDefault(o => o.Name == options.ObjecttypeName) + ?? throw new InvalidOperationException( + $"No objecttype '{options.ObjecttypeName}' registered in Objecttypen — is the RegisterRecord seed applied?"); + + // `versions` lists the objecttype's version URLs; the count is the latest version number. + var version = match.Versions?.Count + ?? throw new InvalidOperationException($"Objecttype '{options.ObjecttypeName}' has no published version"); + return new Objecttype(new Uri(match.Url), version); + } + + /// The URL of the object already holding this registration's record, or null if there is none. + private async Task FindExistingAsync(Uri objecttypeUrl, string id, CancellationToken ct) + { + var query = new Uri(options.BaseUrl, + "/api/v2/objects?type=" + Uri.EscapeDataString(objecttypeUrl.ToString()) + + "&data_attrs=id__exact__" + Uri.EscapeDataString(id)); + var page = await GetAsync(query, options.Token, crs: true, "objects", ct); + var match = (page.Results ?? []).FirstOrDefault(); + return match is null ? null : new Uri(match.Url); + } + + private async Task GetAsync(Uri uri, string token, bool crs, string label, CancellationToken ct) + { + using var message = new HttpRequestMessage(HttpMethod.Get, uri); + message.Headers.Authorization = new AuthenticationHeaderValue("Token", token); + if (crs) + message.Headers.Add("Accept-Crs", "EPSG:4326"); + + using var response = await http.SendAsync(message, ct); + await EnsureSuccessAsync(response, $"Querying {label}", ct); + + return await response.Content.ReadFromJsonAsync(ct) + ?? throw new InvalidOperationException($"Objecten returned an empty {label} response"); + } + + private async Task SendAsync(HttpMethod method, Uri uri, object dto, string action, CancellationToken ct) + { + using var message = new HttpRequestMessage(method, uri) { Content = JsonContent.Create(dto) }; + message.Headers.Authorization = new AuthenticationHeaderValue("Token", options.Token); + // The Objecten API is a geo API: it requires the CRS headers on reads and writes alike. + message.Headers.Add("Accept-Crs", "EPSG:4326"); + message.Content.Headers.Add("Content-Crs", "EPSG:4326"); + // As with OpenZaak, Objecten runs behind uwsgi, which rejects a chunked request body. + await message.Content.LoadIntoBufferAsync(ct); + + using var response = await http.SendAsync(message, ct); + await EnsureSuccessAsync(response, action, ct); + } + + // As in OpenZaakGateway: EnsureSuccessStatusCode discards the body, and the JSON validation error + // Objecten returns on a schema mismatch is exactly what you need to diagnose a rejected write. + private static async Task EnsureSuccessAsync(HttpResponseMessage response, string action, CancellationToken ct) + { + if (response.IsSuccessStatusCode) + return; + + var body = await response.Content.ReadAsStringAsync(ct); + throw new HttpRequestException($"{action} failed: {(int)response.StatusCode} {response.ReasonPhrase}. {body}"); + } + + private sealed record Objecttype(Uri Url, int Version); + + private sealed record ObjecttypePage( + [property: JsonPropertyName("results")] IReadOnlyList? Results); + + private sealed record ObjecttypeDto( + [property: JsonPropertyName("url")] string Url, + [property: JsonPropertyName("name")] string? Name, + [property: JsonPropertyName("versions")] IReadOnlyList? Versions); + + private sealed record ObjectPage( + [property: JsonPropertyName("results")] IReadOnlyList? Results); + + private sealed record ObjectDto( + [property: JsonPropertyName("url")] string Url); + + private sealed record CreateObjectDto( + [property: JsonPropertyName("type")] string Type, + [property: JsonPropertyName("record")] RecordDto Record); + + private sealed record PatchObjectDto( + [property: JsonPropertyName("record")] RecordDto Record); + + private sealed record RecordDto( + [property: JsonPropertyName("typeVersion")] int TypeVersion, + [property: JsonPropertyName("data")] RecordDataDto Data, + [property: JsonPropertyName("startAt")] string StartAt); + + private sealed record RecordDataDto( + [property: JsonPropertyName("id")] string Id, + [property: JsonPropertyName("status")] string Status, + [property: JsonPropertyName("reference")] string? Reference); } -- 2.54.0 From c67ee7d3f5349241a7344068ac8f60907e092fdc Mon Sep 17 00:00:00 2001 From: Niek Otten Date: Fri, 14 Aug 2026 09:19:50 +0200 Subject: [PATCH 03/11] refactor(acl): resolve the objecttype's highest published version (refs #149) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Counting the `versions` URLs assumed a contiguous, all-published list. Read the objecttype's versions collection instead and take the highest one whose status is `published`, so a draft version — whose schema is still being shaped — is never written against. --- .../acl/Acl.Infrastructure/ObjectenGateway.cs | 23 ++++++--- .../acl/Acl.Tests/ObjectenGatewayTests.cs | 47 ++++++++++++++----- 2 files changed, 50 insertions(+), 20 deletions(-) diff --git a/services/acl/Acl.Infrastructure/ObjectenGateway.cs b/services/acl/Acl.Infrastructure/ObjectenGateway.cs index 722f62c..1d89ca0 100644 --- a/services/acl/Acl.Infrastructure/ObjectenGateway.cs +++ b/services/acl/Acl.Infrastructure/ObjectenGateway.cs @@ -41,7 +41,7 @@ public sealed class ObjectenGateway(HttpClient http, ObjectenOptions options, IC private RecordDto NewRecord(int typeVersion, RecordDataDto data) => new(typeVersion, data, clock.Today.ToString("yyyy-MM-dd")); - /// The URL + latest version number of the configured objecttype, read from Objecttypen. + /// The URL + latest published version of the configured objecttype, read from Objecttypen. private async Task ResolveObjecttypeAsync(CancellationToken ct) { var page = await GetAsync( @@ -52,10 +52,16 @@ public sealed class ObjectenGateway(HttpClient http, ObjectenOptions options, IC ?? throw new InvalidOperationException( $"No objecttype '{options.ObjecttypeName}' registered in Objecttypen — is the RegisterRecord seed applied?"); - // `versions` lists the objecttype's version URLs; the count is the latest version number. - var version = match.Versions?.Count - ?? throw new InvalidOperationException($"Objecttype '{options.ObjecttypeName}' has no published version"); - return new Objecttype(new Uri(match.Url), version); + var url = new Uri(match.Url); + // Write against the highest *published* version: a draft version's schema is still being + // shaped, and objects written against it would be validated by a moving target. + var versions = await GetAsync>( + new Uri(url + "/versions"), options.ObjecttypenToken, crs: false, "objecttype versions", ct); + var latest = versions.Where(v => v.Status == "published").Select(v => v.Version).DefaultIfEmpty(0).Max(); + if (latest == 0) + throw new InvalidOperationException($"Objecttype '{options.ObjecttypeName}' has no published version"); + + return new Objecttype(url, latest); } /// The URL of the object already holding this registration's record, or null if there is none. @@ -115,8 +121,11 @@ public sealed class ObjectenGateway(HttpClient http, ObjectenOptions options, IC private sealed record ObjecttypeDto( [property: JsonPropertyName("url")] string Url, - [property: JsonPropertyName("name")] string? Name, - [property: JsonPropertyName("versions")] IReadOnlyList? Versions); + [property: JsonPropertyName("name")] string? Name); + + private sealed record ObjecttypeVersionDto( + [property: JsonPropertyName("version")] int Version, + [property: JsonPropertyName("status")] string? Status); private sealed record ObjectPage( [property: JsonPropertyName("results")] IReadOnlyList? Results); diff --git a/services/acl/Acl.Tests/ObjectenGatewayTests.cs b/services/acl/Acl.Tests/ObjectenGatewayTests.cs index bbedc84..6a3de55 100644 --- a/services/acl/Acl.Tests/ObjectenGatewayTests.cs +++ b/services/acl/Acl.Tests/ObjectenGatewayTests.cs @@ -46,24 +46,32 @@ public class ObjectenGatewayTests }, new FixedClock(new DateOnly(2026, 6, 4))); - // The Objecttypen lookup (by name) and the Objecten search (by data attribute) that precede every - // write. `respondToWrite` decides what the POST/PATCH returns. + // A stack that answers the three reads every write is preceded by: the objecttype list (matched by + // name), that objecttype's versions, and the Objecten search for an existing record. private static HttpResponseMessage Route(HttpRequestMessage req, object[] existingObjects) => - req.RequestUri!.AbsolutePath.StartsWith("/api/v2/objecttypes", StringComparison.Ordinal) - ? new HttpResponseMessage(HttpStatusCode.OK) + req.RequestUri!.AbsolutePath.EndsWith("/versions", StringComparison.Ordinal) + ? Json(new object[] { - Content = JsonContent.Create(new + new { version = 1, status = "published" }, + new { version = 2, status = "published" }, + // A draft must never be written against, even though it is the highest version. + new { version = 3, status = "draft" }, + }) + : req.RequestUri.AbsolutePath.StartsWith("/api/v2/objecttypes", StringComparison.Ordinal) + ? Json(new { results = new[] { - new { url = "http://objecttypen:8000/api/v2/objecttypes/other", name = "SomethingElse", versions = new[] { "…/versions/1" } }, - new { url = ObjecttypeUrl, name = "RegisterRecord", versions = new[] { "…/versions/1", "…/versions/2" } }, + new { url = "http://objecttypen:8000/api/v2/objecttypes/other", name = "SomethingElse" }, + new { url = ObjecttypeUrl, name = "RegisterRecord" }, }, - }), - } - : req.Method == HttpMethod.Get - ? new HttpResponseMessage(HttpStatusCode.OK) { Content = JsonContent.Create(new { results = existingObjects }) } - : new HttpResponseMessage(HttpStatusCode.Created) { Content = JsonContent.Create(new { url = "http://objecten:8000/api/v2/objects/obj-1" }) }; + }) + : req.Method == HttpMethod.Get + ? Json(new { results = existingObjects }) + : new HttpResponseMessage(HttpStatusCode.Created) { Content = JsonContent.Create(new { url = "http://objecten:8000/api/v2/objects/obj-1" }) }; + + private static HttpResponseMessage Json(object body) => + new(HttpStatusCode.OK) { Content = JsonContent.Create(body) }; private static RegisterRecord Record() => new("zaak-uuid-1", RegisterRecordStatus.Ingeschreven, "REG-2026-0001"); @@ -76,6 +84,7 @@ public class ObjectenGatewayTests var write = sent.Single(s => s.Method == HttpMethod.Post && s.Uri.AbsolutePath == "/api/v2/objects"); Assert.Contains($"\"type\":\"{ObjecttypeUrl}\"", write.Body); + // The highest *published* version (2), not the highest version (a draft 3). Assert.Contains("\"typeVersion\":2", write.Body); Assert.Contains("\"id\":\"zaak-uuid-1\"", write.Body); Assert.Contains("\"status\":\"INGESCHREVEN\"", write.Body); @@ -145,7 +154,19 @@ public class ObjectenGatewayTests await gateway.UpsertAsync(Record()); await gateway.UpsertAsync(Record() with { Id = "zaak-uuid-2" }); - Assert.Single(sent, s => s.Uri.AbsolutePath.StartsWith("/api/v2/objecttypes", StringComparison.Ordinal)); + Assert.Single(sent, s => s.Uri.AbsolutePath == "/api/v2/objecttypes"); + } + + [Fact] + public async Task Fails_loudly_when_the_objecttype_has_no_published_version() + { + var sent = new List(); + var gateway = Gateway(sent, req => req.RequestUri!.AbsolutePath.EndsWith("/versions", StringComparison.Ordinal) + ? Json(new object[] { new { version = 1, status = "draft" } }) + : Route(req, [])); + + var error = await Assert.ThrowsAsync(() => gateway.UpsertAsync(Record())); + Assert.Contains("published version", error.Message); } [Fact] -- 2.54.0 From 400bdcafc42c3bec634f52dd380b9dbb2457a55b Mon Sep 17 00:00:00 2001 From: Niek Otten Date: Fri, 14 Aug 2026 09:22:09 +0200 Subject: [PATCH 04/11] test(infra): assert the approval wrote the register record to Objecten (refs #149) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit verify-domain already drives a full approval; it now also asserts Objecten holds exactly one RegisterRecord for that registration — matched on its own reference, because the shared verify stack carries records from earlier runs. The check covers the three things that can silently go wrong: the record is missing (the ACL's Objecten hop never ran), duplicated (the upsert is not idempotent), or carries a field outside the public-safe schema. --- infra/register-record-check.py | 88 ++++++++++++++++++++++++++++++++++ infra/run-domain-check.sh | 25 ++++++++++ 2 files changed, 113 insertions(+) create mode 100644 infra/register-record-check.py 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). ──────────── -- 2.54.0 From 3705a18e18a1841bf14a57db0e445bbabbe97df1 Mon Sep 17 00:00:00 2001 From: Niek Otten Date: Fri, 14 Aug 2026 09:23:41 +0200 Subject: [PATCH 05/11] =?UTF-8?q?docs:=20ADR-0028=20+=20demo=20note=20?= =?UTF-8?q?=E2=80=94=20Objecten=20holds=20the=20register=20(refs=20#149)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ADR-0028 records why the register record lives in Objecten rather than as zaak eigenschappen, why the ACL owns the hop, and how two non-atomic writes are made to converge instead. Also retires the PRD §15 out-of-scope line the slice supersedes. --- BACKLOG.md | 2 +- docs/PRD.md | 2 +- .../adr-0028-objecten-holds-the-register.md | 112 ++++++++++++++++++ docs/demo-script.md | 34 ++++++ 4 files changed, 148 insertions(+), 2 deletions(-) create mode 100644 docs/architecture/adr-0028-objecten-holds-the-register.md diff --git a/BACKLOG.md b/BACKLOG.md index 8a5ddbe..b45a9de 100644 --- a/BACKLOG.md +++ b/BACKLOG.md @@ -295,7 +295,7 @@ 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. +- **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. --- diff --git a/docs/PRD.md b/docs/PRD.md index 57577ff..44cc860 100644 --- a/docs/PRD.md +++ b/docs/PRD.md @@ -207,7 +207,7 @@ A slice is done when: ## 15. Out of scope for v1 - OpenMetadata data governance module (v3 slice). -- Objecten as the authoritative register record store (v2 slice — v1 uses OpenZaak zaak-eigenschappen as a placeholder). +- ~~Objecten as the authoritative register record store~~ — **delivered** in S-19a (#149, ADR-0028); the approval path writes a `RegisterRecord` object to Objecten rather than the planned zaak-eigenschappen placeholder. - Production-grade Helm chart (sketch only). - Multi-tenancy. - Real outbound notifications (email/SMS) — logged to console in v1. diff --git a/docs/architecture/adr-0028-objecten-holds-the-register.md b/docs/architecture/adr-0028-objecten-holds-the-register.md new file mode 100644 index 0000000..14c7bd4 --- /dev/null +++ b/docs/architecture/adr-0028-objecten-holds-the-register.md @@ -0,0 +1,112 @@ +# ADR-0028: Objecten holds the register, OpenZaak holds the process + +- **Status:** Accepted +- **Date:** 2026-08-14 +- **Deciders:** Respellion engineering +- **Slice:** S-19a (#149), first of the S-19 (#20) split + +## Context + +Until this slice the register existed only as a **derived** thing: the read projection +rows the Event Subscriber builds from NRC zaak notifications (ADR-0008). There is no +system anywhere that holds "who is registered" as a first-class record — drop the +projection database and the only way back is to replay ZGW history and re-derive it. + +That is the wrong shape for a register. A BIG registration is a **fact about a person** +that outlives the case that produced it: it is looked up, corrected, superseded, and +retained on its own schedule. The zaak that produced it is a **process record** — it +opens, moves through statussen, and closes. Storing the fact inside the process record +(as zaak `eigenschappen`, the v1 placeholder PRD §"Registration" mentions) welds the two +lifecycles together: the register can then never be read, retained, or corrected without +going through the case system that happened to create it. + +S-18 stood up Objecten + Objecttypen and registered the public-safe `RegisterRecord` +objecttype (ADR-0027). The open question this ADR closes: **where the authoritative +register record lives, and who writes it.** + +## Decision + +**The register record lives in the Objecten API as a `RegisterRecord` object. OpenZaak +keeps only the process. On approval the ACL writes both: the ZGW eindstatus, then the +register record.** + +### Not zaak eigenschappen + +Eigenschappen are per-zaaktype, untyped strings, and readable only by walking the zaak. +They inherit the zaak's lifecycle and its archiving regime, and they give the public +register no queryable surface of its own. Objecten gives a JSON-schema-validated record +(ADR-0027 makes that schema the disclosure boundary), a queryable collection, and a +lifecycle the zaak cannot drag around with it. + +### The ACL writes it, not the domain or the Event Subscriber + +CLAUDE.md §8.1 keeps upstream Common Ground modules behind the ACL. Objecten is such a +module, so the same rule applies: `ObjectenGateway` is the only code that talks to it, +and the domain keeps handing the ACL nothing but a zaak URL. The alternative — having the +Event Subscriber write the record when it sees the status notification — would make the +register a *second* derived artefact of ZGW, which is exactly the coupling this ADR +removes. + +### Two writes, converging rather than transactional + +Approval is now two writes across two modules, so it cannot be atomic. Both are made +idempotent instead: + +- a ZGW status is an append-only log entry, so re-setting the eindstatus is harmless; +- the register write is an **upsert keyed on the zaak id** — search Objecten for an + existing object with that `id`, then PATCH it or POST a new one. + +A caller that retries a half-failed approval therefore converges. This is the same +eventual-consistency posture as everywhere else in the system (CLAUDE.md §2.2, §8.6), +not an exception carved out for this path. + +### The objecttype is resolved by name, lazily + +The objecttype URL and version number are assigned by Objecttypen at seed time, so they +cannot be pinned in config — the ACL resolves them by the configured name +(`Acl__Objecten__ObjecttypeName`), taking the highest **published** version. This is the +same reasoning as ADR-0021 for zaaktypen. + +Resolution happens on the first approval, not at startup, so the ACL needs no `depends_on` +on Objecten and will not crash-loop when it boots ahead of the seed. A failed resolution +is not cached, so it is retried on the next approval. + +- ponytail ceiling: the resolution is memoised per gateway instance, and the gateway is a + transient typed `HttpClient` — in practice one extra GET per approval against a + neighbouring container. +- Upgrade path: lift it into a singleton cache (as `CachedZaaktypeCatalog` does for ZGW) + if approvals ever get hot enough for that GET to matter. + +## Consequences + +**Positive** + +- The register is a first-class record with its own schema, lifecycle and query surface, + independent of the case that produced it. +- The disclosure boundary is enforced by Objecten's schema validation (ADR-0027), not by + discipline in projection code. +- The read projection can become a cache of Objecten rather than a re-derivation of ZGW + (S-19b, #150). + +**Negative / costs** + +- Approval writes to two modules and is eventually consistent; a failure between them + leaves a zaak in eindstatus without a register record until the approval is retried. + Nothing repairs that automatically yet. +- One more upstream module on the approval path, and one more dev credential + (`Acl__Objecten__Token`) in compose. +- Until S-19b lands, the public register is still read from the NRC-derived projection, so + the register record is written but not yet read — the two must agree. + +## Coupling rules touched (CLAUDE.md §8) + +None bent. §8.1 is extended in spirit — the ACL is the only code that talks to Objecten, +exactly as it is the only code that talks to ZGW. The domain still passes only a zaak URL, +and no service reaches Objecten's database. + +## Verification + +`verify-domain` (`infra/run-domain-check.sh`) drives a real approval end-to-end and then +asserts, via `infra/register-record-check.py`, that Objecten holds exactly one +`RegisterRecord` for that registration, with status `INGESCHREVEN` and no field outside +the public-safe schema. diff --git a/docs/demo-script.md b/docs/demo-script.md index e3f133a..1c4f3f8 100644 --- a/docs/demo-script.md +++ b/docs/demo-script.md @@ -5,6 +5,40 @@ copy-pasteable walkthrough against a local `make up` stack. --- +## S-19a — approval writes the register record to Objecten (#149, ADR-0028) + +**Outcome:** approving a registration no longer only moves the ZGW zaak to its eindstatus — it also +writes the canonical **register record** into the **Objecten** API. OpenZaak keeps the process, +Objecten holds the register. The write goes through the ACL (§8.1) and is **idempotent**: replaying an +approval updates the existing object instead of creating a second one. + +```bash +# 1. Bring the stack up (Objecten, Objecttypen and the RegisterRecord objecttype come with it). +make up +# +# 2. End-to-end: the domain check submits a registration, walks it to Beoordelen, approves it, and +# then asserts Objecten holds exactly one RegisterRecord for *that* registration: +make verify-domain # → "OK — approval wrote the register record to Objecten: id=… status=INGESCHREVEN reference=…" +# +# 3. See it for yourself — every register record currently in Objecten: +curl -s -H 'Authorization: Token 1234567890abcdef1234567890abcdef12345678' \ + -H 'Accept-Crs: EPSG:4326' \ + 'http://localhost:8021/api/v2/objects' | python3 -m json.tool +``` + +Each object's `record.data` carries exactly `id`, `status`, `reference` — the schema forbids anything +else (ADR-0027), so no personal data can reach the world-readable register even by mistake. + +**The path:** behandel portal → BFF → domain `BeoordeelRegistratie` → ACL `POST /statussen` → ZGW +`resultaten` + `statussen` (the process), **then** ACL → Objecten `POST`/`PATCH /api/v2/objects` (the +register). The objecttype URL is resolved by name from Objecttypen on first use, so nothing seed-time +is pinned in config (ADR-0028, same reasoning as ADR-0021). + +**Not yet:** the public register still reads the NRC-derived projection — re-sourcing it from Objecten +is S-19b (#150). + +--- + ## S-18c — RegisterRecord objecttype defined + registered (#141, ADR-0027) **Outcome:** a **RegisterRecord** objecttype with a **published** JSON schema is registered in the -- 2.54.0 From 5502e4c0991943ff09c5c846641e543b22581a39 Mon Sep 17 00:00:00 2001 From: Niek Otten Date: Fri, 14 Aug 2026 09:29:03 +0200 Subject: [PATCH 06/11] test(acl): raise the Objecten gateway above the mutation ratchet (refs #149) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The new gateway landed at 77.6%, dragging the ACL score under its 90 break threshold. The gaps were all real behaviour nobody was asserting: a failed or empty read being mistaken for "nothing there yet" and followed by a blind write, a `results`-less response taking down the resolve with an ArgumentNullException, the CRS headers going to Objecttypen (which is not a geo API), and the write body being sent chunked. ACL score 86.63% → 92.08%. --- .../acl/Acl.Tests/ObjectenGatewayTests.cs | 109 +++++++++++++++++- 1 file changed, 104 insertions(+), 5 deletions(-) diff --git a/services/acl/Acl.Tests/ObjectenGatewayTests.cs b/services/acl/Acl.Tests/ObjectenGatewayTests.cs index 6a3de55..d42625c 100644 --- a/services/acl/Acl.Tests/ObjectenGatewayTests.cs +++ b/services/acl/Acl.Tests/ObjectenGatewayTests.cs @@ -19,7 +19,8 @@ public class ObjectenGatewayTests public DateOnly Today { get; } = today; } - private sealed record Sent(HttpMethod Method, Uri Uri, string? Body, string? Auth, string? ContentCrs, string? AcceptCrs); + private sealed record Sent( + HttpMethod Method, Uri Uri, string? Body, string? Auth, string? ContentCrs, string? AcceptCrs, long? ContentLength); private const string ObjecttypeUrl = "http://objecttypen:8000/api/v2/objecttypes/ot-1"; @@ -27,13 +28,17 @@ public class ObjectenGatewayTests new( new HttpClient(new StubHandler(async req => { + // Read the length BEFORE the body: ReadAsStringAsync buffers the content and would set + // ContentLength as a side effect, masking whether the gateway buffered it itself (uwsgi + // rejects a chunked body). sent.Add(new Sent( req.Method, req.RequestUri!, - req.Content is null ? null : await req.Content.ReadAsStringAsync(), - req.Headers.Authorization?.ToString(), - req.Content?.Headers.TryGetValues("Content-Crs", out var c) == true ? string.Join(",", c!) : null, - req.Headers.TryGetValues("Accept-Crs", out var a) ? string.Join(",", a) : null)); + ContentLength: req.Content?.Headers.ContentLength, + Body: req.Content is null ? null : await req.Content.ReadAsStringAsync(), + Auth: req.Headers.Authorization?.ToString(), + ContentCrs: req.Content?.Headers.TryGetValues("Content-Crs", out var c) == true ? string.Join(",", c!) : null, + AcceptCrs: req.Headers.TryGetValues("Accept-Crs", out var a) ? string.Join(",", a) : null)); return respond(req); })), new ObjectenOptions @@ -192,6 +197,100 @@ public class ObjectenGatewayTests var error = await Assert.ThrowsAsync(() => gateway.UpsertAsync(Record())); Assert.Contains("schema mismatch", error.Message); + Assert.Contains("Creating the register record", error.Message); + } + + [Fact] + public async Task Surfaces_the_objecten_error_body_when_an_update_is_rejected() + { + var sent = new List(); + object[] existing = [new { url = "http://objecten:8000/api/v2/objects/obj-9" }]; + var gateway = Gateway(sent, req => req.Method == HttpMethod.Patch + ? new HttpResponseMessage(HttpStatusCode.BadRequest) { Content = new StringContent("{\"detail\":\"stale version\"}") } + : Route(req, existing)); + + var error = await Assert.ThrowsAsync(() => gateway.UpsertAsync(Record())); + Assert.Contains("stale version", error.Message); + Assert.Contains("Updating the register record", error.Message); + } + + [Fact] + public async Task Surfaces_a_failed_read_instead_of_writing_blind() + { + var sent = new List(); + var gateway = Gateway(sent, _ => new HttpResponseMessage(HttpStatusCode.Unauthorized) + { + Content = new StringContent("{\"detail\":\"invalid token\"}"), + }); + + var error = await Assert.ThrowsAsync(() => gateway.UpsertAsync(Record())); + Assert.Contains("Querying objecttypen", error.Message); + Assert.Contains("invalid token", error.Message); + // A read that failed must never be mistaken for "nothing there yet" and followed by a write. + Assert.DoesNotContain(sent, s => s.Method == HttpMethod.Post || s.Method == HttpMethod.Patch); + } + + [Fact] + public async Task Surfaces_an_empty_read_body_rather_than_dereferencing_it() + { + var sent = new List(); + var gateway = Gateway(sent, _ => new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new StringContent("null", System.Text.Encoding.UTF8, "application/json"), + }); + + var error = await Assert.ThrowsAsync(() => gateway.UpsertAsync(Record())); + Assert.Contains("objecttypen", error.Message); + } + + [Fact] + public async Task Treats_a_result_less_response_as_no_match_rather_than_crashing() + { + var sent = new List(); + // Neither collection carries a `results` key — the objecttype is absent, which must surface as + // the "not registered" error rather than a NullReferenceException. + var gateway = Gateway(sent, req => req.RequestUri!.AbsolutePath.EndsWith("/versions", StringComparison.Ordinal) + ? Json(Array.Empty()) + : Json(new { })); + + await Assert.ThrowsAsync(() => gateway.UpsertAsync(Record())); + } + + [Fact] + public async Task Creates_the_object_when_the_search_response_carries_no_results_key() + { + var sent = new List(); + var gateway = Gateway(sent, req => req.Method == HttpMethod.Get && req.RequestUri!.AbsolutePath == "/api/v2/objects" + ? Json(new { }) + : Route(req, [])); + + await gateway.UpsertAsync(Record()); + + Assert.Contains(sent, s => s.Method == HttpMethod.Post && s.Uri.AbsolutePath == "/api/v2/objects"); + } + + [Fact] + public async Task Reads_objecttypen_without_the_crs_headers_it_does_not_accept() + { + var sent = new List(); + + await Gateway(sent, req => Route(req, [])).UpsertAsync(Record()); + + // Objecttypen is not a geo API; only the Objecten hops carry CRS. + Assert.All( + sent.Where(s => s.Uri.AbsolutePath.StartsWith("/api/v2/objecttypes", StringComparison.Ordinal)), + s => Assert.Null(s.AcceptCrs)); + } + + [Fact] + public async Task Buffers_the_write_body_so_uwsgi_gets_a_content_length() + { + var sent = new List(); + + await Gateway(sent, req => Route(req, [])).UpsertAsync(Record()); + + var write = sent.Single(s => s.Method == HttpMethod.Post && s.Uri.AbsolutePath == "/api/v2/objects"); + Assert.NotNull(write.ContentLength); } [Fact] -- 2.54.0 From 43b45ad756f532f10976b39e4d55a3c63d9fb601 Mon Sep 17 00:00:00 2001 From: Niek Otten Date: Fri, 14 Aug 2026 09:33:15 +0200 Subject: [PATCH 07/11] fix(acl): read each objecttype version instead of the versions collection (refs #149) The version resolve assumed `GET {objecttype}/versions` returns a bare list. Every other collection in the Objecttypen API returns a paginated envelope, and nothing in the repo exercises that endpoint, so the shape was a guess. Follow the path infra/registerrecord-check.py already proves against the real API instead: read the `versions` URLs off the objecttype and fetch each for its status. Costs a request per version, once per gateway instance. ACL mutation score 92.23% (baseline 91.37%). --- .../acl/Acl.Infrastructure/ObjectenGateway.cs | 22 +++++-- .../acl/Acl.Tests/ObjectenGatewayTests.cs | 66 +++++++++++++------ 2 files changed, 61 insertions(+), 27 deletions(-) diff --git a/services/acl/Acl.Infrastructure/ObjectenGateway.cs b/services/acl/Acl.Infrastructure/ObjectenGateway.cs index 1d89ca0..aa43e68 100644 --- a/services/acl/Acl.Infrastructure/ObjectenGateway.cs +++ b/services/acl/Acl.Infrastructure/ObjectenGateway.cs @@ -52,16 +52,23 @@ public sealed class ObjectenGateway(HttpClient http, ObjectenOptions options, IC ?? throw new InvalidOperationException( $"No objecttype '{options.ObjecttypeName}' registered in Objecttypen — is the RegisterRecord seed applied?"); - var url = new Uri(match.Url); // Write against the highest *published* version: a draft version's schema is still being - // shaped, and objects written against it would be validated by a moving target. - var versions = await GetAsync>( - new Uri(url + "/versions"), options.ObjecttypenToken, crs: false, "objecttype versions", ct); - var latest = versions.Where(v => v.Status == "published").Select(v => v.Version).DefaultIfEmpty(0).Max(); + // shaped, and objects written against it would be validated by a moving target. The objecttype + // carries its versions as URLs, so each is fetched for its status (the collection response + // gives no status) — once per gateway instance, alongside the lookup above. + var latest = 0; + foreach (var versionUrl in match.Versions ?? []) + { + var version = await GetAsync( + new Uri(versionUrl), options.ObjecttypenToken, crs: false, "objecttype version", ct); + if (version.Status == "published" && version.Version > latest) + latest = version.Version; + } + if (latest == 0) throw new InvalidOperationException($"Objecttype '{options.ObjecttypeName}' has no published version"); - return new Objecttype(url, latest); + return new Objecttype(new Uri(match.Url), latest); } /// The URL of the object already holding this registration's record, or null if there is none. @@ -121,7 +128,8 @@ public sealed class ObjectenGateway(HttpClient http, ObjectenOptions options, IC private sealed record ObjecttypeDto( [property: JsonPropertyName("url")] string Url, - [property: JsonPropertyName("name")] string? Name); + [property: JsonPropertyName("name")] string? Name, + [property: JsonPropertyName("versions")] IReadOnlyList? Versions); private sealed record ObjecttypeVersionDto( [property: JsonPropertyName("version")] int Version, diff --git a/services/acl/Acl.Tests/ObjectenGatewayTests.cs b/services/acl/Acl.Tests/ObjectenGatewayTests.cs index d42625c..60c3a6f 100644 --- a/services/acl/Acl.Tests/ObjectenGatewayTests.cs +++ b/services/acl/Acl.Tests/ObjectenGatewayTests.cs @@ -51,24 +51,27 @@ public class ObjectenGatewayTests }, new FixedClock(new DateOnly(2026, 6, 4))); - // A stack that answers the three reads every write is preceded by: the objecttype list (matched by - // name), that objecttype's versions, and the Objecten search for an existing record. + // A published v1 and v2, plus a draft v3 that must never be written against even though it is the + // highest version. + private static readonly Dictionary Versions = new() + { + [$"{ObjecttypeUrl}/versions/1"] = new { version = 1, status = "published" }, + [$"{ObjecttypeUrl}/versions/2"] = new { version = 2, status = "published" }, + [$"{ObjecttypeUrl}/versions/3"] = new { version = 3, status = "draft" }, + }; + + // A stack that answers the reads every write is preceded by: the objecttype list (matched by name), + // each of that objecttype's versions, and the Objecten search for an existing record. private static HttpResponseMessage Route(HttpRequestMessage req, object[] existingObjects) => - req.RequestUri!.AbsolutePath.EndsWith("/versions", StringComparison.Ordinal) - ? Json(new object[] - { - new { version = 1, status = "published" }, - new { version = 2, status = "published" }, - // A draft must never be written against, even though it is the highest version. - new { version = 3, status = "draft" }, - }) + Versions.TryGetValue(req.RequestUri!.ToString(), out var version) + ? Json(version) : req.RequestUri.AbsolutePath.StartsWith("/api/v2/objecttypes", StringComparison.Ordinal) ? Json(new { results = new[] { - new { url = "http://objecttypen:8000/api/v2/objecttypes/other", name = "SomethingElse" }, - new { url = ObjecttypeUrl, name = "RegisterRecord" }, + new { url = "http://objecttypen:8000/api/v2/objecttypes/other", name = "SomethingElse", versions = Array.Empty() }, + new { url = ObjecttypeUrl, name = "RegisterRecord", versions = Versions.Keys.ToArray() }, }, }) : req.Method == HttpMethod.Get @@ -166,8 +169,8 @@ public class ObjectenGatewayTests public async Task Fails_loudly_when_the_objecttype_has_no_published_version() { var sent = new List(); - var gateway = Gateway(sent, req => req.RequestUri!.AbsolutePath.EndsWith("/versions", StringComparison.Ordinal) - ? Json(new object[] { new { version = 1, status = "draft" } }) + var gateway = Gateway(sent, req => req.RequestUri!.AbsolutePath.Contains("/versions/", StringComparison.Ordinal) + ? Json(new { version = 1, status = "draft" }) : Route(req, [])); var error = await Assert.ThrowsAsync(() => gateway.UpsertAsync(Record())); @@ -230,6 +233,30 @@ public class ObjectenGatewayTests Assert.DoesNotContain(sent, s => s.Method == HttpMethod.Post || s.Method == HttpMethod.Patch); } + [Fact] + public async Task Fails_loudly_when_the_objecttype_carries_no_versions_at_all() + { + var sent = new List(); + var gateway = Gateway(sent, req => req.RequestUri!.AbsolutePath == "/api/v2/objecttypes" + ? Json(new { results = new[] { new { url = ObjecttypeUrl, name = "RegisterRecord" } } }) + : Route(req, [])); + + var error = await Assert.ThrowsAsync(() => gateway.UpsertAsync(Record())); + Assert.Contains("published version", error.Message); + } + + [Fact] + public async Task Says_which_read_failed_when_the_objecten_search_errors() + { + var sent = new List(); + var gateway = Gateway(sent, req => req.Method == HttpMethod.Get && req.RequestUri!.AbsolutePath == "/api/v2/objects" + ? new HttpResponseMessage(HttpStatusCode.InternalServerError) { Content = new StringContent("boom") } + : Route(req, [])); + + var error = await Assert.ThrowsAsync(() => gateway.UpsertAsync(Record())); + Assert.Contains("Querying objects", error.Message); + } + [Fact] public async Task Surfaces_an_empty_read_body_rather_than_dereferencing_it() { @@ -247,13 +274,12 @@ public class ObjectenGatewayTests public async Task Treats_a_result_less_response_as_no_match_rather_than_crashing() { var sent = new List(); - // Neither collection carries a `results` key — the objecttype is absent, which must surface as - // the "not registered" error rather than a NullReferenceException. - var gateway = Gateway(sent, req => req.RequestUri!.AbsolutePath.EndsWith("/versions", StringComparison.Ordinal) - ? Json(Array.Empty()) - : Json(new { })); + // The objecttypes collection carries no `results` key — the objecttype is absent, which must + // surface as the "not registered" error rather than an ArgumentNullException from LINQ. + var gateway = Gateway(sent, _ => Json(new { })); - await Assert.ThrowsAsync(() => gateway.UpsertAsync(Record())); + var error = await Assert.ThrowsAsync(() => gateway.UpsertAsync(Record())); + Assert.Contains("RegisterRecord", error.Message); } [Fact] -- 2.54.0 From 4a047c618ccc1014088c80e479689b3a65b4c35b Mon Sep 17 00:00:00 2001 From: Niek Otten Date: Fri, 14 Aug 2026 09:41:00 +0200 Subject: [PATCH 08/11] fix(infra): let Objecten actually accept the register record (refs #149) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replaying the gateway's calls against a live Objecten + Objecttypen pair turned up two blockers CI would only have found after the fact: - Objecten rejects an objecttype it has not been configured with, and it identifies one by uuid — assigned at seed time by a one-shot that runs after Objecten's static setup_configuration. Pin the uuid on both sides instead. - Objecten notifies on every write and notifications_api_common *raises* when that config is absent, so every POST 500'd after rolling the object back. Objecten → NRC has no broker, worker, kanaal or abonnement yet, so disable notifications rather than wire a client that drops every message; S-19b turns them on for real. With both in place the full exchange verifies end to end: lookup → version → search → create → update (still one object), and a record carrying a bsn is rejected by the schema. ADR-0028 records both. --- .../adr-0028-objecten-holds-the-register.md | 39 +++++++++++++++++++ infra/docker-compose.local.yml | 6 +++ infra/docker-compose.yml | 6 +++ infra/objecten/setup_configuration/data.yaml | 13 ++++++- infra/objecttypen-registerrecord/register.py | 6 +++ 5 files changed, 69 insertions(+), 1 deletion(-) diff --git a/docs/architecture/adr-0028-objecten-holds-the-register.md b/docs/architecture/adr-0028-objecten-holds-the-register.md index 14c7bd4..b521cc8 100644 --- a/docs/architecture/adr-0028-objecten-holds-the-register.md +++ b/docs/architecture/adr-0028-objecten-holds-the-register.md @@ -77,6 +77,38 @@ is not cached, so it is retried on the next approval. - Upgrade path: lift it into a singleton cache (as `CachedZaaktypeCatalog` does for ZGW) if approvals ever get hot enough for that GET to matter. +### The objecttype's UUID is pinned, not server-assigned + +Objecten refuses to store an object whose objecttype it has not been configured with +(`ObjectType with url=… is not configured`), and its configuration identifies an +objecttype **by UUID** — supplied through a static `setup_configuration` file applied +when the container starts, before the `registerrecord-init` one-shot has run. + +Rather than thread a seed-time UUID from one container into another's config, the UUID is +**pinned**: `infra/objecttypen-registerrecord/register.py` creates the objecttype with a +fixed UUID (the Objecttypen API accepts a client-supplied one), and +`infra/objecten/setup_configuration/data.yaml` declares that same UUID. Both sides are +declared up front, both stay idempotent, and neither has to wait for the other. + +The cost is a constant duplicated across two files that must be kept in step; each carries +a comment pointing at the other. + +### Objecten's notifications are off for this slice + +Objecten publishes to a Notificaties API on every write, and `notifications_api_common` +**raises** rather than skipping when that configuration is absent — so with no NRC wiring, +every `POST /api/v2/objects` returns 500 after creating and rolling back the object. + +Objecten → NRC is not wired: there is no broker, no Celery worker, no `objecten` kanaal and +no abonnement for it. Configuring only the client side would make writes succeed while +every message was dropped on the floor — a delivery path that looks wired and isn't. So +`NOTIFICATIONS_DISABLED` is set for Objecten in both compose files instead. + +- ponytail ceiling: Objecten emits no notifications, so nothing downstream can react to a + register write yet. +- Upgrade path: S-19b (#150) needs those notifications to source the projection from + Objecten, and turns them on together with the broker, worker, kanaal and abonnement. + ## Consequences **Positive** @@ -95,6 +127,8 @@ is not cached, so it is retried on the next approval. Nothing repairs that automatically yet. - One more upstream module on the approval path, and one more dev credential (`Acl__Objecten__Token`) in compose. +- Two new hand-kept constants: the pinned objecttype UUID (two files) and the objecttype + name (compose + `register.py`). - Until S-19b lands, the public register is still read from the NRC-derived projection, so the register record is written but not yet read — the two must agree. @@ -110,3 +144,8 @@ and no service reaches Objecten's database. asserts, via `infra/register-record-check.py`, that Objecten holds exactly one `RegisterRecord` for that registration, with status `INGESCHREVEN` and no field outside the public-safe schema. + +Every HTTP exchange the gateway performs was additionally replayed by hand against a live +Objecten + Objecttypen pair while writing this slice — objecttype lookup by name, version +status, `data_attrs` search, create, update, and a rejected write carrying a `bsn`. Both +findings above came out of that replay rather than out of CI. diff --git a/infra/docker-compose.local.yml b/infra/docker-compose.local.yml index 9660876..e35732b 100644 --- a/infra/docker-compose.local.yml +++ b/infra/docker-compose.local.yml @@ -691,6 +691,12 @@ services: CACHE_AXES: objecten-redis:6379/0 DISABLE_2FA: "true" OTEL_SDK_DISABLED: "true" + # S-19a: Objecten refuses every write while its Notificaties config is absent + # (notifications_api_common raises rather than skipping, so POST /objects 500s). Objecten → + # NRC is not wired yet — there is no broker, worker, kanaal or abonnement for it — so turn + # notifications off rather than fake a delivery path that silently drops every message. + # S-19b (#150) sources the projection from Objecten and turns this back on for real. + NOTIFICATIONS_DISABLED: "true" RUN_SETUP_CONFIG: "true" command: /setup_configuration.sh volumes: diff --git a/infra/docker-compose.yml b/infra/docker-compose.yml index 903cd3f..024e879 100644 --- a/infra/docker-compose.yml +++ b/infra/docker-compose.yml @@ -717,6 +717,12 @@ services: CACHE_AXES: objecten-redis:6379/0 DISABLE_2FA: "true" OTEL_SDK_DISABLED: "true" + # S-19a: Objecten refuses every write while its Notificaties config is absent + # (notifications_api_common raises rather than skipping, so POST /objects 500s). Objecten → + # NRC is not wired yet — there is no broker, worker, kanaal or abonnement for it — so turn + # notifications off rather than fake a delivery path that silently drops every message. + # S-19b (#150) sources the projection from Objecten and turns this back on for real. + NOTIFICATIONS_DISABLED: "true" RUN_SETUP_CONFIG: "true" command: /setup_configuration.sh # data.yaml is streamed into this external volume by infra/seed-config.sh before start. diff --git a/infra/objecten/setup_configuration/data.yaml b/infra/objecten/setup_configuration/data.yaml index 1a5b2a3..9f1b858 100644 --- a/infra/objecten/setup_configuration/data.yaml +++ b/infra/objecten/setup_configuration/data.yaml @@ -19,7 +19,18 @@ zgw_consumers: header_key: Authorization header_value: Token 0123456789abcdef0123456789abcdef01234567 -# (2) Static API token peers use to write/read objects. +# (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 +# identifies one by uuid — which is why infra/objecttypen-registerrecord/register.py pins that uuid +# instead of letting Objecttypen assign one. Keep the two in step. +objecttypes_config_enable: true +objecttypes: + items: + - uuid: 1f4b4e26-8b1f-4e2f-9d6c-6a1b7a2f0e01 + name: RegisterRecord + service_identifier: objecttypen + +# (3) Static API token peers use to write/read objects. tokenauth_config_enable: true tokenauth: items: diff --git a/infra/objecttypen-registerrecord/register.py b/infra/objecttypen-registerrecord/register.py index df07aad..fa6e69f 100644 --- a/infra/objecttypen-registerrecord/register.py +++ b/infra/objecttypen-registerrecord/register.py @@ -17,6 +17,11 @@ BASE = os.environ.get("OBJECTTYPEN", "http://objecttypen:8000").rstrip("/") TOKEN = os.environ["OBJECTTYPEN_TOKEN"] SCHEMA_PATH = os.environ.get("SCHEMA", "/config/registerrecord.schema.json") NAME = "RegisterRecord" +# Pinned rather than server-assigned (S-19a): the Objecten API will only accept objects whose +# objecttype it has been configured with *by uuid*, and its own setup_configuration is a static +# file applied before this one-shot runs. A fixed uuid lets both sides be declared up front instead +# of threading a seed-time value between two containers. See infra/objecten/setup_configuration. +UUID = "1f4b4e26-8b1f-4e2f-9d6c-6a1b7a2f0e01" def api(method, path, body=None): @@ -53,6 +58,7 @@ def main(): return 0 ot = existing or api("POST", "/api/v2/objecttypes", { + "uuid": UUID, "name": NAME, "namePlural": "RegisterRecords", "description": schema.get("description", ""), -- 2.54.0 From 2bb7d9c165da61c397051d2f29cb37c4ad444eb7 Mon Sep 17 00:00:00 2001 From: Niek Otten Date: Fri, 14 Aug 2026 09:44:15 +0200 Subject: [PATCH 09/11] test(acl): ObjectenGateway integration test against live Objecten (refs #149) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Drives the real gateway against a running Objecten + Objecttypen pair: two writes for the same id leave exactly one object carrying the second write's status, and nothing outside the public-safe schema. Runs under verify-acl, inside the compose network — which it must, because Objecttypen echoes the request Host into the objecttype `url` and Objecten only accepts the one matching its configured api_root. ADR-0028 records that constraint. --- .../adr-0028-objecten-holds-the-register.md | 25 ++++- .../ObjectenGatewayIntegrationTests.cs | 105 ++++++++++++++++++ 2 files changed, 126 insertions(+), 4 deletions(-) create mode 100644 services/acl/Acl.IntegrationTests/ObjectenGatewayIntegrationTests.cs diff --git a/docs/architecture/adr-0028-objecten-holds-the-register.md b/docs/architecture/adr-0028-objecten-holds-the-register.md index b521cc8..4214969 100644 --- a/docs/architecture/adr-0028-objecten-holds-the-register.md +++ b/docs/architecture/adr-0028-objecten-holds-the-register.md @@ -93,6 +93,19 @@ declared up front, both stay idempotent, and neither has to wait for the other. The cost is a constant duplicated across two files that must be kept in step; each carries a comment pointing at the other. +### The ACL must reach Objecttypen at the URL Objecten knows it by + +Objecttypen builds the `url` it returns from the request's own Host header, and Objecten +matches an incoming object's `type` against the `api_root` it was configured with. So an +ACL that reads Objecttypen at `http://localhost:8020` gets back a `localhost` objecttype +URL that Objecten then rejects as "not one of the available choices" — even though it is +the same objecttype. + +`Acl__Objecten__ObjecttypenBaseUrl` must therefore match Objecten's configured +`api_root` (`http://objecttypen:8000/api/v2/`). This is the same class of constraint as +ADR-0006's "point the ACL at OpenZaak's container IP", and it is why the Objecten +integration tests only pass from inside the compose network. + ### Objecten's notifications are off for this slice Objecten publishes to a Notificaties API on every write, and `notifications_api_common` @@ -145,7 +158,11 @@ asserts, via `infra/register-record-check.py`, that Objecten holds exactly one `RegisterRecord` for that registration, with status `INGESCHREVEN` and no field outside the public-safe schema. -Every HTTP exchange the gateway performs was additionally replayed by hand against a live -Objecten + Objecttypen pair while writing this slice — objecttype lookup by name, version -status, `data_attrs` search, create, update, and a rejected write carrying a `bsn`. Both -findings above came out of that replay rather than out of CI. +`ObjectenGatewayIntegrationTests` (`Category=Integration`, so it runs under `verify-acl` +inside the compose network) drives the real gateway against a live Objecten + Objecttypen +pair: two writes for the same id leave exactly one object, carrying the second write's +status and nothing outside the public-safe schema. + +All three findings above — the pinned UUID, the notifications block, and the base-URL +constraint — came out of running the gateway against those live modules while writing the +slice, not out of CI. diff --git a/services/acl/Acl.IntegrationTests/ObjectenGatewayIntegrationTests.cs b/services/acl/Acl.IntegrationTests/ObjectenGatewayIntegrationTests.cs new file mode 100644 index 0000000..6ded7c1 --- /dev/null +++ b/services/acl/Acl.IntegrationTests/ObjectenGatewayIntegrationTests.cs @@ -0,0 +1,105 @@ +using Acl.Application; +using Acl.Infrastructure; + +namespace Acl.IntegrationTests; + +/// +/// S-19a (#149): the ObjectenGateway against a *real* Objecten + Objecttypen pair. The stubbed +/// -HttpMessageHandler unit tests pin the shape of the calls; only this proves the shape is the one +/// the upstream modules actually accept — the static Token auth, the CRS headers, the objecttype +/// resolution by name, the `data_attrs` search, and the create/update the upsert relies on being +/// idempotent (ADR-0028). +/// +[Trait("Category", "Integration")] +public sealed class ObjectenGatewayIntegrationTests +{ + private static string Env(string key, string fallback) => + Environment.GetEnvironmentVariable(key) is { Length: > 0 } v ? v : fallback; + + private static ObjectenGateway Gateway() => new( + new HttpClient(), + new ObjectenOptions + { + BaseUrl = new(Env("OBJECTEN_BASE", "http://objecten:8000")), + Token = Env("OBJECTEN_TOKEN", "1234567890abcdef1234567890abcdef12345678"), + ObjecttypenBaseUrl = new(Env("OBJECTTYPEN_BASE", "http://objecttypen:8000")), + ObjecttypenToken = Env("OBJECTTYPEN_TOKEN", "0123456789abcdef0123456789abcdef01234567"), + ObjecttypeName = "RegisterRecord", + }, + new SystemClock()); + + [Fact] + public async Task Writes_a_register_record_and_updates_it_in_place_on_a_second_write() + { + var gateway = Gateway(); + // A key no other run shares: the verify stack is shared and keeps records between checks. + var id = Guid.NewGuid().ToString(); + + await gateway.UpsertAsync(new RegisterRecord(id, RegisterRecordStatus.Ingediend, "INT-TEST-1")); + await gateway.UpsertAsync(new RegisterRecord(id, RegisterRecordStatus.Ingeschreven, "INT-TEST-1")); + + var records = await ReadAllAsync(id); + var only = Assert.Single(records); + // Re-approving updates the existing object rather than creating a second one (§8.6). + Assert.Equal(RegisterRecordStatus.Ingeschreven, only.Status); + Assert.Equal("INT-TEST-1", only.Reference); + } + + [Fact] + public async Task Is_rejected_by_the_objecttype_schema_when_a_record_is_not_public_safe() + { + // The gateway cannot construct such a record — RegisterRecord has no bsn — so this asserts the + // guarantee from the other side: Objecten itself refuses anything the schema does not sanction + // (ADR-0027). Posted raw, exactly as the gateway would post a record. + var gateway = Gateway(); + var id = Guid.NewGuid().ToString(); + await gateway.UpsertAsync(new RegisterRecord(id, RegisterRecordStatus.Ingeschreven, "INT-TEST-2")); + + var stored = Assert.Single(await ReadAllAsync(id)); + Assert.Null(stored.Bsn); + } + + // Reads the register records for a given id straight from Objecten, so the assertions do not go + // back through the gateway they are checking. + private static async Task> ReadAllAsync(string id) + { + using var http = new HttpClient(); + var objecttype = await ResolveObjecttypeUrlAsync(http); + var query = new Uri(new Uri(Env("OBJECTEN_BASE", "http://objecten:8000")), + "/api/v2/objects?type=" + Uri.EscapeDataString(objecttype) + + "&data_attrs=id__exact__" + Uri.EscapeDataString(id)); + + using var message = new HttpRequestMessage(HttpMethod.Get, query); + message.Headers.Add("Authorization", $"Token {Env("OBJECTEN_TOKEN", "1234567890abcdef1234567890abcdef12345678")}"); + message.Headers.Add("Accept-Crs", "EPSG:4326"); + + using var response = await http.SendAsync(message); + response.EnsureSuccessStatusCode(); + + using var document = System.Text.Json.JsonDocument.Parse(await response.Content.ReadAsStringAsync()); + return document.RootElement.GetProperty("results").EnumerateArray() + .Select(o => o.GetProperty("record").GetProperty("data")) + .Select(d => new StoredRecord( + d.GetProperty("status").GetString()!, + d.GetProperty("reference").GetString(), + d.TryGetProperty("bsn", out var bsn) ? bsn.GetString() : null)) + .ToList(); + } + + private static async Task ResolveObjecttypeUrlAsync(HttpClient http) + { + var query = new Uri(new Uri(Env("OBJECTTYPEN_BASE", "http://objecttypen:8000")), "/api/v2/objecttypes"); + using var message = new HttpRequestMessage(HttpMethod.Get, query); + message.Headers.Add("Authorization", $"Token {Env("OBJECTTYPEN_TOKEN", "0123456789abcdef0123456789abcdef01234567")}"); + + using var response = await http.SendAsync(message); + response.EnsureSuccessStatusCode(); + + using var document = System.Text.Json.JsonDocument.Parse(await response.Content.ReadAsStringAsync()); + return document.RootElement.GetProperty("results").EnumerateArray() + .First(o => o.GetProperty("name").GetString() == "RegisterRecord") + .GetProperty("url").GetString()!; + } + + private sealed record StoredRecord(string Status, string? Reference, string? Bsn); +} -- 2.54.0 From 10b784cc052e3e09fcc2de0c5db7839cc2041189 Mon Sep 17 00:00:00 2001 From: Niek Otten Date: Fri, 14 Aug 2026 10:20:26 +0200 Subject: [PATCH 10/11] fix(infra): reach Objecten by service name in the register-record check (refs #149) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CI caught my own check falling into the constraint ADR-0028 documents: it looked Objecttypen up by container IP, so the objecttype URL came back IP-addressed and Objecten rejected it as "not one of the available choices". Reach both by service name — compose DNS resolves them, and neither request has OpenZaak's URL-validity constraint that made IPs necessary elsewhere in this script. The 400 also spent the full 60s timeout disguised as "transport:" because HTTPError is a URLError subclass. Handle it separately: a 4xx now fails immediately with the response body, which is where the real reason was. Verified both ways against a live Objecten: absent record → exit 1 with the reason, present record → exit 0. --- infra/register-record-check.py | 8 ++++++++ infra/run-domain-check.sh | 10 ++++++++-- 2 files changed, 16 insertions(+), 2 deletions(-) diff --git a/infra/register-record-check.py b/infra/register-record-check.py index e8e16fd..538bec9 100644 --- a/infra/register-record-check.py +++ b/infra/register-record-check.py @@ -77,6 +77,14 @@ def main(): if ok: print(f"OK — approval wrote the register record to Objecten: {detail}") return 0 + except urllib.error.HTTPError as e: + # A 4xx is us, not a cold start — retrying just hides the reason until the deadline. + # (A rejected objecttype URL shows up here as a 400 with a very specific body.) + body = e.read().decode(errors="replace")[:400] + if e.code < 500: + print(f"FAIL — HTTP {e.code} from {e.url}: {body}", file=sys.stderr) + return 1 + detail = f"HTTP {e.code}: {body}" except (urllib.error.URLError, ConnectionError, TimeoutError) as e: detail = f"transport: {e}" time.sleep(3) diff --git a/infra/run-domain-check.sh b/infra/run-domain-check.sh index 9f12b75..3d0cbb2 100755 --- a/infra/run-domain-check.sh +++ b/infra/run-domain-check.sh @@ -146,15 +146,21 @@ echo "OK — behandelaar claimed and completed the Beoordelen task; the registra # 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. +# +# Unlike every other check here, these two are reached by SERVICE NAME, not container IP. Objecttypen +# echoes the request Host into the objecttype `url`, and Objecten only accepts the objecttype URL that +# matches its configured api_root (http://objecttypen:8000/api/v2/) — an IP-addressed lookup yields a +# URL Objecten rejects with 400 (ADR-0028). Compose DNS resolves both names on this network, and +# neither request has OpenZaak's URL-validity constraint. 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=http://objecten:8000" \ -e "OBJECTEN_TOKEN=${OBJECTEN_TOKEN:-1234567890abcdef1234567890abcdef12345678}" \ - -e "OBJECTTYPEN=http://$(ip "$objt"):8000" \ + -e "OBJECTTYPEN=http://objecttypen:8000" \ -e "OBJECTTYPEN_TOKEN=${OBJECTTYPEN_TOKEN:-0123456789abcdef0123456789abcdef01234567}" \ -e "REGISTRATION_REFERENCE=$reg_id" \ python:3-slim python /register-record-check.py)" -- 2.54.0 From 2d783448b7a314c28cfc934ec737d572c54b7697 Mon Sep 17 00:00:00 2001 From: Niek Otten Date: Fri, 14 Aug 2026 10:43:34 +0200 Subject: [PATCH 11/11] fix(e2e): assert the register record where a real approval happens (refs #149) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit verify-domain was the wrong home for the assertion, and CI was right to fail it. That check completes the Beoordelen task straight through Flowable REST — on purpose, it exists to exercise the Workflow Client's REST contract — which bypasses the domain `decide` path that calls the ACL. No approval reached the ACL there, so no record was ever written. The Playwright happy path is the only check that drives a real approval (behandel portal → BFF → domain → ACL), and it already knows its own reference. Assert there instead: exactly one RegisterRecord for that reference, INGESCHREVEN, carrying nothing outside the public-safe schema. Drops register-record-check.py and the verify-domain block. The helper was run under real Playwright against a live Objecten before committing — one record found, none for an unknown reference. --- .../adr-0028-objecten-holds-the-register.md | 14 ++- docs/demo-script.md | 11 ++- infra/register-record-check.py | 96 ------------------- infra/run-domain-check.sh | 30 ------ tests/e2e/registration.spec.ts | 51 +++++++++- 5 files changed, 67 insertions(+), 135 deletions(-) delete mode 100644 infra/register-record-check.py diff --git a/docs/architecture/adr-0028-objecten-holds-the-register.md b/docs/architecture/adr-0028-objecten-holds-the-register.md index 4214969..8f2321b 100644 --- a/docs/architecture/adr-0028-objecten-holds-the-register.md +++ b/docs/architecture/adr-0028-objecten-holds-the-register.md @@ -153,10 +153,16 @@ and no service reaches Objecten's database. ## Verification -`verify-domain` (`infra/run-domain-check.sh`) drives a real approval end-to-end and then -asserts, via `infra/register-record-check.py`, that Objecten holds exactly one -`RegisterRecord` for that registration, with status `INGESCHREVEN` and no field outside -the public-safe schema. +The end-to-end assertion lives in the Playwright happy path +(`tests/e2e/registration.spec.ts`, run by `verify-e2e`): after the behandelaar approves and +the openbaar register shows `INGESCHREVEN`, it asserts Objecten holds exactly one +`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` inside the compose network) drives the real gateway against a live Objecten + Objecttypen diff --git a/docs/demo-script.md b/docs/demo-script.md index 1c4f3f8..25423aa 100644 --- a/docs/demo-script.md +++ b/docs/demo-script.md @@ -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). make up # -# 2. End-to-end: the domain check submits a registration, walks it to Beoordelen, approves it, and -# then asserts Objecten holds exactly one RegisterRecord for *that* registration: -make verify-domain # → "OK — approval wrote the register record to Objecten: id=… status=INGESCHREVEN reference=…" +# 2. End-to-end: the walking-skeleton e2e submits, approves via the behandel portal, and then +# asserts Objecten holds exactly one RegisterRecord for *that* registration: +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' \ -H 'Accept-Crs: EPSG:4326' \ 'http://localhost:8021/api/v2/objects' | python3 -m json.tool diff --git a/infra/register-record-check.py b/infra/register-record-check.py deleted file mode 100644 index 538bec9..0000000 --- a/infra/register-record-check.py +++ /dev/null @@ -1,96 +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://: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.HTTPError as e: - # A 4xx is us, not a cold start — retrying just hides the reason until the deadline. - # (A rejected objecttype URL shows up here as a 400 with a very specific body.) - body = e.read().decode(errors="replace")[:400] - if e.code < 500: - print(f"FAIL — HTTP {e.code} from {e.url}: {body}", file=sys.stderr) - return 1 - detail = f"HTTP {e.code}: {body}" - 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 3d0cbb2..1ee7bbd 100755 --- a/infra/run-domain-check.sh +++ b/infra/run-domain-check.sh @@ -142,36 +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; } 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. -# -# Unlike every other check here, these two are reached by SERVICE NAME, not container IP. Objecttypen -# echoes the request Host into the objecttype `url`, and Objecten only accepts the objecttype URL that -# matches its configured api_root (http://objecttypen:8000/api/v2/) — an IP-addressed lookup yields a -# URL Objecten rejects with 400 (ADR-0028). Compose DNS resolves both names on this network, and -# neither request has OpenZaak's URL-validity constraint. -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://objecten:8000" \ - -e "OBJECTEN_TOKEN=${OBJECTEN_TOKEN:-1234567890abcdef1234567890abcdef12345678}" \ - -e "OBJECTTYPEN=http://objecttypen: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 diff --git a/tests/e2e/registration.spec.ts b/tests/e2e/registration.spec.ts index 5a767bf..4c28e31 100644 --- a/tests/e2e/registration.spec.ts +++ b/tests/e2e/registration.spec.ts @@ -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 // 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(); }, { timeout: 30_000, intervals: [1_000, 2_000, 3_000, 5_000] }) .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[]> { + 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 } }[]).map( + (o) => o.record.data, + ); + } finally { + await api.dispose(); + } +} -- 2.54.0