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.
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() });
});
@@ -32,10 +32,20 @@ app.MapPost("/statussen", async (SetStatusRequest body, AclService acl, Cancella
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();
public sealed record OpenZaakRequest(string Bsn);
public sealed record OpenZaakRequest(string Bsn, string Reference);
public sealed record SetStatusRequest(string ZaakUrl);
public sealed record ZaakReferenceRequest(string ZaakUrl);
public partial class Program;

View File

@@ -13,7 +13,8 @@ public sealed class AclService(IZaakGateway gateway, AclDefaults defaults, ICloc
defaults.VerantwoordelijkeOrganisatie,
defaults.Vertrouwelijkheidaanduiding,
defaults.ZaaktypeUrl,
clock.Today);
clock.Today,
registration.Reference);
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);
}
/// <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;
/// <summary>Domain-language payload handed to the ACL. No ZGW concepts here.</summary>
public sealed record DomainRegistration(string Bsn);
/// <summary>Domain-language payload handed to the ACL. No ZGW concepts here. <see cref="Reference"/>
/// 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"/>.
/// </summary>
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 Vertrouwelijkheidaanduiding,
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.VerantwoordelijkeOrganisatie,
request.Startdatum.ToString("yyyy-MM-dd"),
request.Vertrouwelijkheidaanduiding)),
request.Vertrouwelijkheidaanduiding,
request.Identificatie)),
};
message.Headers.Authorization =
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);
}
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
// a Content-Length instead of a chunked body (as with zaak-create).
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("verantwoordelijkeOrganisatie")] string VerantwoordelijkeOrganisatie,
[property: JsonPropertyName("startdatum")] string Startdatum,
[property: JsonPropertyName("vertrouwelijkheidaanduiding")] string Vertrouwelijkheidaanduiding);
[property: JsonPropertyName("vertrouwelijkheidaanduiding")] string Vertrouwelijkheidaanduiding,
[property: JsonPropertyName("identificatie")] string Identificatie);
private sealed record ZaakCreatedDto(
[property: JsonPropertyName("url")] string Url);
private sealed record ZaakReadDto(
[property: JsonPropertyName("identificatie")] string Identificatie);
private sealed record StatusDto(
[property: JsonPropertyName("zaak")] string Zaak,
[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).");
var gateway = new OpenZaakGateway(stack.Http, stack.Options);
var reference = Guid.NewGuid().ToString(); // zaak identificatie must be unique per bronorganisatie
var request = new ZaakRequest(
Bronorganisatie: "517439943",
VerantwoordelijkeOrganisatie: "517439943",
Vertrouwelijkheidaanduiding: "openbaar",
Zaaktype: zaaktype!,
Startdatum: DateOnly.FromDateTime(DateTime.UtcNow));
Startdatum: DateOnly.FromDateTime(DateTime.UtcNow),
Identificatie: reference);
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(),
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);
Assert.Equal(zaaktype.ToString(), zaak.GetProperty("zaaktype").GetString());
Assert.Equal("517439943", zaak.GetProperty("bronorganisatie").GetString());
Assert.Equal("openbaar", zaak.GetProperty("vertrouwelijkheidaanduiding").GetString());
Assert.Equal(reference, zaak.GetProperty("identificatie").GetString());
}
[Fact]
@@ -57,7 +60,8 @@ public sealed class OpenZaakGatewayIntegrationTests(OpenZaakFixture stack)
VerantwoordelijkeOrganisatie: "517439943",
Vertrouwelijkheidaanduiding: "openbaar",
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));

View File

@@ -22,6 +22,14 @@ public class AclServiceTests
Approved = (zaakUrl, zaaktypeUrl, datumStatusGezet);
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()
@@ -50,7 +58,7 @@ public class AclServiceTests
};
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);
var req = Assert.IsType<ZaakRequest>(gateway.Captured);
@@ -59,6 +67,8 @@ public class AclServiceTests
Assert.Equal("openbaar", req.Vertrouwelijkheidaanduiding);
Assert.Equal(defaults.ZaaktypeUrl, req.Zaaktype);
Assert.Equal(new DateOnly(2026, 6, 4), req.Startdatum);
// The registration reference becomes the zaak identificatie (#78).
Assert.Equal("reg-77", req.Identificatie);
}
[Fact]
@@ -103,4 +113,27 @@ public class AclServiceTests
await Assert.ThrowsAsync<ArgumentNullException>(() => service.ApproveZaakAsync(null!));
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(
"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)
{
@@ -67,6 +67,8 @@ public class OpenZaakGatewayTests
Assert.Contains("\"vertrouwelijkheidaanduiding\":\"openbaar\"", capture.Body);
Assert.Contains("\"startdatum\":\"2026-06-04\"", 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]
@@ -357,6 +359,61 @@ public class OpenZaakGatewayTests
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]
public async Task Approving_rejects_a_null_zaak_or_zaaktype()
{