feat(acl): set and read the zaak identificatie as the citizen reference (refs #78)

The ACL now writes the domain registrationId as the zaak's identificatie on
POST /zaken, and exposes GET-through POST /zaken/reference to read a zaak's
identificatie back. Only the ACL touches ZGW (§8.1); the reference is the single
value shown on both the self-service confirmation and the openbaar register.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-07-14 14:46:32 +02:00
parent 1c185e6686
commit ffbd8072b2
11 changed files with 159 additions and 14 deletions

View File

@@ -20,7 +20,7 @@ app.MapGet("/health", () => "Healthy");
// The ACL's single operation, exposed as a service endpoint. // The ACL's single operation, exposed as a service endpoint.
app.MapPost("/zaken", async (OpenZaakRequest body, AclService acl, CancellationToken ct) => app.MapPost("/zaken", async (OpenZaakRequest body, AclService acl, CancellationToken ct) =>
{ {
var zaakUrl = await acl.OpenZaakAsync(new DomainRegistration(body.Bsn), ct); var zaakUrl = await acl.OpenZaakAsync(new DomainRegistration(body.Bsn, body.Reference), ct);
return Results.Ok(new { zaakUrl = zaakUrl.ToString() }); return Results.Ok(new { zaakUrl = zaakUrl.ToString() });
}); });
@@ -32,10 +32,20 @@ app.MapPost("/statussen", async (SetStatusRequest body, AclService acl, Cancella
return Results.NoContent(); return Results.NoContent();
}); });
// Read a zaak's public-safe reference (its identificatie). The Event Subscriber calls this to enrich
// the read projection without reading ZGW itself (§8.1, #78).
app.MapPost("/zaken/reference", async (ZaakReferenceRequest body, AclService acl, CancellationToken ct) =>
{
var reference = await acl.GetZaakReferenceAsync(new Uri(body.ZaakUrl), ct);
return Results.Ok(new { reference });
});
app.Run(); app.Run();
public sealed record OpenZaakRequest(string Bsn); public sealed record OpenZaakRequest(string Bsn, string Reference);
public sealed record SetStatusRequest(string ZaakUrl); public sealed record SetStatusRequest(string ZaakUrl);
public sealed record ZaakReferenceRequest(string ZaakUrl);
public partial class Program; public partial class Program;

View File

@@ -13,7 +13,8 @@ public sealed class AclService(IZaakGateway gateway, AclDefaults defaults, ICloc
defaults.VerantwoordelijkeOrganisatie, defaults.VerantwoordelijkeOrganisatie,
defaults.Vertrouwelijkheidaanduiding, defaults.Vertrouwelijkheidaanduiding,
defaults.ZaaktypeUrl, defaults.ZaaktypeUrl,
clock.Today); clock.Today,
registration.Reference);
return gateway.OpenZaakAsync(request, ct); return gateway.OpenZaakAsync(request, ct);
} }
@@ -28,4 +29,12 @@ public sealed class AclService(IZaakGateway gateway, AclDefaults defaults, ICloc
return gateway.SetZaakToEindstatusAsync(zaakUrl, defaults.ZaaktypeUrl, clock.Today, ct); return gateway.SetZaakToEindstatusAsync(zaakUrl, defaults.ZaaktypeUrl, clock.Today, ct);
} }
/// <summary>The zaak's reference (its ZGW identificatie), for the read projection (#78).</summary>
public Task<string> GetZaakReferenceAsync(Uri zaakUrl, CancellationToken ct = default)
{
ArgumentNullException.ThrowIfNull(zaakUrl);
return gateway.GetZaakIdentificatieAsync(zaakUrl, ct);
}
} }

View File

@@ -1,4 +1,5 @@
namespace Acl.Application; namespace Acl.Application;
/// <summary>Domain-language payload handed to the ACL. No ZGW concepts here.</summary> /// <summary>Domain-language payload handed to the ACL. No ZGW concepts here. <see cref="Reference"/>
public sealed record DomainRegistration(string Bsn); /// is the registration's own id; the ACL records it as the zaak identificatie (adr-proposal #78).</summary>
public sealed record DomainRegistration(string Bsn, string Reference);

View File

@@ -12,4 +12,8 @@ public interface IZaakGateway
/// the catalogus and POSTs a status against the zaak, dated <paramref name="datumStatusGezet"/>. /// the catalogus and POSTs a status against the zaak, dated <paramref name="datumStatusGezet"/>.
/// </summary> /// </summary>
Task SetZaakToEindstatusAsync(Uri zaakUrl, Uri zaaktypeUrl, DateOnly datumStatusGezet, CancellationToken ct = default); Task SetZaakToEindstatusAsync(Uri zaakUrl, Uri zaaktypeUrl, DateOnly datumStatusGezet, CancellationToken ct = default);
/// <summary>Read the zaak's <c>identificatie</c> — the public-safe reference the register shows.
/// The Event Subscriber calls this through the ACL rather than reading ZGW itself (§8.1, #78).</summary>
Task<string> GetZaakIdentificatieAsync(Uri zaakUrl, CancellationToken ct = default);
} }

View File

@@ -6,4 +6,5 @@ public sealed record ZaakRequest(
string VerantwoordelijkeOrganisatie, string VerantwoordelijkeOrganisatie,
string Vertrouwelijkheidaanduiding, string Vertrouwelijkheidaanduiding,
Uri Zaaktype, Uri Zaaktype,
DateOnly Startdatum); DateOnly Startdatum,
string Identificatie);

View File

@@ -20,7 +20,8 @@ public sealed class OpenZaakGateway(HttpClient http, OpenZaakOptions options) :
request.Zaaktype.ToString(), request.Zaaktype.ToString(),
request.VerantwoordelijkeOrganisatie, request.VerantwoordelijkeOrganisatie,
request.Startdatum.ToString("yyyy-MM-dd"), request.Startdatum.ToString("yyyy-MM-dd"),
request.Vertrouwelijkheidaanduiding)), request.Vertrouwelijkheidaanduiding,
request.Identificatie)),
}; };
message.Headers.Authorization = message.Headers.Authorization =
new AuthenticationHeaderValue("Bearer", ZgwToken.Mint(options.ClientId, options.Secret)); new AuthenticationHeaderValue("Bearer", ZgwToken.Mint(options.ClientId, options.Secret));
@@ -61,6 +62,24 @@ public sealed class OpenZaakGateway(HttpClient http, OpenZaakOptions options) :
"Setting the zaak status", ct); "Setting the zaak status", ct);
} }
public async Task<string> GetZaakIdentificatieAsync(Uri zaakUrl, CancellationToken ct = default)
{
ArgumentNullException.ThrowIfNull(zaakUrl);
using var message = new HttpRequestMessage(HttpMethod.Get, zaakUrl);
message.Headers.Authorization =
new AuthenticationHeaderValue("Bearer", ZgwToken.Mint(options.ClientId, options.Secret));
// The zaak is a geo resource; the Zaken API requires the CRS header even on GET.
message.Headers.Add("Accept-Crs", "EPSG:4326");
using var response = await http.SendAsync(message, ct);
await EnsureSuccessAsync(response, "Reading the zaak", ct);
var zaak = await response.Content.ReadFromJsonAsync<ZaakReadDto>(ct)
?? throw new InvalidOperationException("OpenZaak returned an empty zaak response");
return zaak.Identificatie;
}
// POSTs a non-geo ZGW resource (resultaat/status — no CRS headers). Buffers the body so uwsgi gets // POSTs a non-geo ZGW resource (resultaat/status — no CRS headers). Buffers the body so uwsgi gets
// a Content-Length instead of a chunked body (as with zaak-create). // a Content-Length instead of a chunked body (as with zaak-create).
private async Task PostAsync(string path, object dto, string action, CancellationToken ct) private async Task PostAsync(string path, object dto, string action, CancellationToken ct)
@@ -132,11 +151,15 @@ public sealed class OpenZaakGateway(HttpClient http, OpenZaakOptions options) :
[property: JsonPropertyName("zaaktype")] string Zaaktype, [property: JsonPropertyName("zaaktype")] string Zaaktype,
[property: JsonPropertyName("verantwoordelijkeOrganisatie")] string VerantwoordelijkeOrganisatie, [property: JsonPropertyName("verantwoordelijkeOrganisatie")] string VerantwoordelijkeOrganisatie,
[property: JsonPropertyName("startdatum")] string Startdatum, [property: JsonPropertyName("startdatum")] string Startdatum,
[property: JsonPropertyName("vertrouwelijkheidaanduiding")] string Vertrouwelijkheidaanduiding); [property: JsonPropertyName("vertrouwelijkheidaanduiding")] string Vertrouwelijkheidaanduiding,
[property: JsonPropertyName("identificatie")] string Identificatie);
private sealed record ZaakCreatedDto( private sealed record ZaakCreatedDto(
[property: JsonPropertyName("url")] string Url); [property: JsonPropertyName("url")] string Url);
private sealed record ZaakReadDto(
[property: JsonPropertyName("identificatie")] string Identificatie);
private sealed record StatusDto( private sealed record StatusDto(
[property: JsonPropertyName("zaak")] string Zaak, [property: JsonPropertyName("zaak")] string Zaak,
[property: JsonPropertyName("statustype")] string Statustype, [property: JsonPropertyName("statustype")] string Statustype,

View File

@@ -22,12 +22,14 @@ public sealed class OpenZaakGatewayIntegrationTests(OpenZaakFixture stack)
"seed it with OZ_PUBLISH=1 (`make integration` does this)."); "seed it with OZ_PUBLISH=1 (`make integration` does this).");
var gateway = new OpenZaakGateway(stack.Http, stack.Options); var gateway = new OpenZaakGateway(stack.Http, stack.Options);
var reference = Guid.NewGuid().ToString(); // zaak identificatie must be unique per bronorganisatie
var request = new ZaakRequest( var request = new ZaakRequest(
Bronorganisatie: "517439943", Bronorganisatie: "517439943",
VerantwoordelijkeOrganisatie: "517439943", VerantwoordelijkeOrganisatie: "517439943",
Vertrouwelijkheidaanduiding: "openbaar", Vertrouwelijkheidaanduiding: "openbaar",
Zaaktype: zaaktype!, Zaaktype: zaaktype!,
Startdatum: DateOnly.FromDateTime(DateTime.UtcNow)); Startdatum: DateOnly.FromDateTime(DateTime.UtcNow),
Identificatie: reference);
var zaakUrl = await gateway.OpenZaakAsync(request); var zaakUrl = await gateway.OpenZaakAsync(request);
@@ -36,11 +38,12 @@ public sealed class OpenZaakGatewayIntegrationTests(OpenZaakFixture stack)
new Uri(stack.BaseUrl, "/zaken/api/v1/zaken/").ToString(), new Uri(stack.BaseUrl, "/zaken/api/v1/zaken/").ToString(),
zaakUrl.ToString()); zaakUrl.ToString());
// ...and that zaak is really persisted with the default-filled fields. // ...and that zaak is really persisted with the default-filled fields + the reference identificatie.
var zaak = await stack.GetZaakAsync(zaakUrl); var zaak = await stack.GetZaakAsync(zaakUrl);
Assert.Equal(zaaktype.ToString(), zaak.GetProperty("zaaktype").GetString()); Assert.Equal(zaaktype.ToString(), zaak.GetProperty("zaaktype").GetString());
Assert.Equal("517439943", zaak.GetProperty("bronorganisatie").GetString()); Assert.Equal("517439943", zaak.GetProperty("bronorganisatie").GetString());
Assert.Equal("openbaar", zaak.GetProperty("vertrouwelijkheidaanduiding").GetString()); Assert.Equal("openbaar", zaak.GetProperty("vertrouwelijkheidaanduiding").GetString());
Assert.Equal(reference, zaak.GetProperty("identificatie").GetString());
} }
[Fact] [Fact]
@@ -57,7 +60,8 @@ public sealed class OpenZaakGatewayIntegrationTests(OpenZaakFixture stack)
VerantwoordelijkeOrganisatie: "517439943", VerantwoordelijkeOrganisatie: "517439943",
Vertrouwelijkheidaanduiding: "openbaar", Vertrouwelijkheidaanduiding: "openbaar",
Zaaktype: zaaktype!, Zaaktype: zaaktype!,
Startdatum: DateOnly.FromDateTime(DateTime.UtcNow))); Startdatum: DateOnly.FromDateTime(DateTime.UtcNow),
Identificatie: Guid.NewGuid().ToString()));
await gateway.SetZaakToEindstatusAsync(zaakUrl, zaaktype!, DateOnly.FromDateTime(DateTime.UtcNow)); await gateway.SetZaakToEindstatusAsync(zaakUrl, zaaktype!, DateOnly.FromDateTime(DateTime.UtcNow));

View File

@@ -22,6 +22,14 @@ public class AclServiceTests
Approved = (zaakUrl, zaaktypeUrl, datumStatusGezet); Approved = (zaakUrl, zaaktypeUrl, datumStatusGezet);
return Task.CompletedTask; return Task.CompletedTask;
} }
public Uri? ReadReferenceFor;
public Task<string> GetZaakIdentificatieAsync(Uri zaakUrl, CancellationToken ct = default)
{
ReadReferenceFor = zaakUrl;
return Task.FromResult("REG-FROM-ZAAK");
}
} }
private static AclDefaults Defaults() => new() private static AclDefaults Defaults() => new()
@@ -50,7 +58,7 @@ public class AclServiceTests
}; };
var service = new AclService(gateway, defaults, new FixedClock(new DateOnly(2026, 6, 4))); var service = new AclService(gateway, defaults, new FixedClock(new DateOnly(2026, 6, 4)));
var url = await service.OpenZaakAsync(new DomainRegistration("123456782")); var url = await service.OpenZaakAsync(new DomainRegistration("123456782", "reg-77"));
Assert.Equal(gateway.Result, url); Assert.Equal(gateway.Result, url);
var req = Assert.IsType<ZaakRequest>(gateway.Captured); var req = Assert.IsType<ZaakRequest>(gateway.Captured);
@@ -59,6 +67,8 @@ public class AclServiceTests
Assert.Equal("openbaar", req.Vertrouwelijkheidaanduiding); Assert.Equal("openbaar", req.Vertrouwelijkheidaanduiding);
Assert.Equal(defaults.ZaaktypeUrl, req.Zaaktype); Assert.Equal(defaults.ZaaktypeUrl, req.Zaaktype);
Assert.Equal(new DateOnly(2026, 6, 4), req.Startdatum); Assert.Equal(new DateOnly(2026, 6, 4), req.Startdatum);
// The registration reference becomes the zaak identificatie (#78).
Assert.Equal("reg-77", req.Identificatie);
} }
[Fact] [Fact]
@@ -103,4 +113,27 @@ public class AclServiceTests
await Assert.ThrowsAsync<ArgumentNullException>(() => service.ApproveZaakAsync(null!)); await Assert.ThrowsAsync<ArgumentNullException>(() => service.ApproveZaakAsync(null!));
Assert.Null(gateway.Approved); Assert.Null(gateway.Approved);
} }
[Fact]
public async Task Reading_a_zaak_reference_returns_the_zaaks_identificatie()
{
var gateway = new FakeGateway();
var service = new AclService(gateway, Defaults(), new FixedClock(new DateOnly(2026, 6, 4)));
var zaak = new Uri("http://openzaak/zaken/api/v1/zaken/abc");
var reference = await service.GetZaakReferenceAsync(zaak);
Assert.Equal("REG-FROM-ZAAK", reference);
Assert.Equal(zaak, gateway.ReadReferenceFor);
}
[Fact]
public async Task Reading_a_null_zaak_reference_is_rejected()
{
var gateway = new FakeGateway();
var service = new AclService(gateway, Defaults(), new FixedClock(new DateOnly(2026, 6, 4)));
await Assert.ThrowsAsync<ArgumentNullException>(() => service.GetZaakReferenceAsync(null!));
Assert.Null(gateway.ReadReferenceFor);
}
} }

View File

@@ -22,7 +22,7 @@ public class OpenZaakGatewayTests
private static ZaakRequest SampleRequest() => new( private static ZaakRequest SampleRequest() => new(
"517439943", "517439943", "openbaar", "517439943", "517439943", "openbaar",
new("http://openzaak/catalogi/api/v1/zaaktypen/big"), new DateOnly(2026, 6, 4)); new("http://openzaak/catalogi/api/v1/zaaktypen/big"), new DateOnly(2026, 6, 4), "REG-REF-1");
private static StubHandler Created(out RequestCapture capture) private static StubHandler Created(out RequestCapture capture)
{ {
@@ -67,6 +67,8 @@ public class OpenZaakGatewayTests
Assert.Contains("\"vertrouwelijkheidaanduiding\":\"openbaar\"", capture.Body); Assert.Contains("\"vertrouwelijkheidaanduiding\":\"openbaar\"", capture.Body);
Assert.Contains("\"startdatum\":\"2026-06-04\"", capture.Body); Assert.Contains("\"startdatum\":\"2026-06-04\"", capture.Body);
Assert.Contains("\"zaaktype\":\"http://openzaak/catalogi/api/v1/zaaktypen/big\"", capture.Body); Assert.Contains("\"zaaktype\":\"http://openzaak/catalogi/api/v1/zaaktypen/big\"", capture.Body);
// The registration reference is set as the zaak identificatie (#78).
Assert.Contains("\"identificatie\":\"REG-REF-1\"", capture.Body);
} }
[Fact] [Fact]
@@ -357,6 +359,61 @@ public class OpenZaakGatewayTests
Assert.Equal(4, rec.Requests.Count); Assert.Equal(4, rec.Requests.Count);
} }
[Fact]
public async Task Reading_a_zaak_returns_its_identificatie_with_bearer_and_crs()
{
RequestCapture? capture = null;
var handler = new StubHandler(req =>
{
capture = new RequestCapture { Seen = req };
return Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK)
{
Content = JsonContent.Create(new { identificatie = "REG-XYZ", url = ZaakUrl }),
});
});
var reference = await Gateway(handler).GetZaakIdentificatieAsync(new Uri(ZaakUrl));
Assert.Equal("REG-XYZ", reference);
Assert.Equal(HttpMethod.Get, capture!.Seen!.Method);
Assert.Equal(ZaakUrl, capture.Seen.RequestUri!.ToString());
Assert.Equal("Bearer", capture.Seen.Headers.Authorization!.Scheme);
Assert.Equal("EPSG:4326", Assert.Single(capture.Seen.Headers.GetValues("Accept-Crs")));
}
[Fact]
public async Task Reading_a_zaak_throws_when_openzaak_rejects_it()
{
var handler = new StubHandler(_ =>
Task.FromResult(new HttpResponseMessage(HttpStatusCode.NotFound) { Content = new StringContent("nope") }));
var ex = await Assert.ThrowsAsync<HttpRequestException>(
() => Gateway(handler).GetZaakIdentificatieAsync(new Uri(ZaakUrl)));
Assert.Contains("Reading the zaak", ex.Message);
}
[Fact]
public async Task Reading_a_zaak_throws_when_openzaak_returns_an_empty_body()
{
var handler = new StubHandler(_ =>
Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK)
{
Content = new StringContent("null", System.Text.Encoding.UTF8, "application/json"),
}));
var ex = await Assert.ThrowsAsync<InvalidOperationException>(
() => Gateway(handler).GetZaakIdentificatieAsync(new Uri(ZaakUrl)));
Assert.Contains("empty zaak response", ex.Message);
}
[Fact]
public async Task Reading_a_null_zaak_is_rejected()
{
var handler = new StubHandler(_ => throw new InvalidOperationException("should not be sent"));
await Assert.ThrowsAsync<ArgumentNullException>(() => Gateway(handler).GetZaakIdentificatieAsync(null!));
}
[Fact] [Fact]
public async Task Approving_rejects_a_null_zaak_or_zaaktype() public async Task Approving_rejects_a_null_zaak_or_zaaktype()
{ {

View File

@@ -18,7 +18,7 @@ public sealed class EenZaakOpenenSteps
[Given("a domain registration for BSN \"(.*)\"")] [Given("a domain registration for BSN \"(.*)\"")]
public void GivenADomainRegistrationForBsn(string bsn) public void GivenADomainRegistrationForBsn(string bsn)
=> _registration = new DomainRegistration(bsn); => _registration = new DomainRegistration(bsn, "ACC-REF-1");
[Given("the ACL is configured with these defaults:")] [Given("the ACL is configured with these defaults:")]
public void GivenTheAclIsConfiguredWithTheseDefaults(DataTable defaults) public void GivenTheAclIsConfiguredWithTheseDefaults(DataTable defaults)

View File

@@ -24,4 +24,7 @@ public sealed class InMemoryZaakGateway : IZaakGateway
Approved = (zaakUrl, zaaktypeUrl, datumStatusGezet); Approved = (zaakUrl, zaaktypeUrl, datumStatusGezet);
return Task.CompletedTask; return Task.CompletedTask;
} }
public Task<string> GetZaakIdentificatieAsync(Uri zaakUrl, CancellationToken ct = default)
=> Task.FromResult("ACC-REF-1");
} }