feat(acl): write the RegisterRecord to Objecten on approval (refs #149)
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.
This commit is contained in:
@@ -338,6 +338,14 @@ services:
|
|||||||
Acl__Defaults__Vertrouwelijkheidaanduiding: openbaar
|
Acl__Defaults__Vertrouwelijkheidaanduiding: openbaar
|
||||||
Acl__Defaults__ZaaktypeIdentificatie: BIG-REGISTRATIE
|
Acl__Defaults__ZaaktypeIdentificatie: BIG-REGISTRATIE
|
||||||
Acl__Defaults__InformatieobjecttypeOmschrijving: Diploma
|
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:
|
ports:
|
||||||
- "8100:8080"
|
- "8100:8080"
|
||||||
volumes:
|
volumes:
|
||||||
|
|||||||
@@ -323,6 +323,14 @@ services:
|
|||||||
# so verify-domain still points the ACL at OpenZaak's container IP.
|
# so verify-domain still points the ACL at OpenZaak's container IP.
|
||||||
Acl__Defaults__ZaaktypeIdentificatie: BIG-REGISTRATIE
|
Acl__Defaults__ZaaktypeIdentificatie: BIG-REGISTRATIE
|
||||||
Acl__Defaults__InformatieobjecttypeOmschrijving: Diploma
|
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:
|
ports:
|
||||||
- "8100:8080"
|
- "8100:8080"
|
||||||
healthcheck:
|
healthcheck:
|
||||||
|
|||||||
@@ -42,7 +42,12 @@ builder.Services.AddSingleton<IDefaultFillStore>(sp =>
|
|||||||
return new InMemoryDefaultFillStore(
|
return new InMemoryDefaultFillStore(
|
||||||
new DefaultFillSettings(d.Bronorganisatie, d.VerantwoordelijkeOrganisatie, d.Vertrouwelijkheidaanduiding));
|
new DefaultFillSettings(d.Bronorganisatie, d.VerantwoordelijkeOrganisatie, d.Vertrouwelijkheidaanduiding));
|
||||||
});
|
});
|
||||||
|
builder.Services.AddSingleton(sp => sp.GetRequiredService<IConfiguration>()
|
||||||
|
.GetSection("Acl:Objecten").Get<ObjectenOptions>()
|
||||||
|
?? throw new InvalidOperationException("Missing configuration section 'Acl:Objecten'"));
|
||||||
builder.Services.AddHttpClient<IZaakGateway, OpenZaakGateway>();
|
builder.Services.AddHttpClient<IZaakGateway, OpenZaakGateway>();
|
||||||
|
// The Objecten hop that writes the register record on approval (S-19a, ADR-0028).
|
||||||
|
builder.Services.AddHttpClient<IRegisterRecordGateway, ObjectenGateway>();
|
||||||
// Singleton so the resolved zaaktype/informatieobjecttype URLs are cached across requests (S-27).
|
// Singleton so the resolved zaaktype/informatieobjecttype URLs are cached across requests (S-27).
|
||||||
builder.Services.AddSingleton<IZaaktypeCatalog, CachedZaaktypeCatalog>();
|
builder.Services.AddSingleton<IZaaktypeCatalog, CachedZaaktypeCatalog>();
|
||||||
builder.Services.AddScoped<AclService>();
|
builder.Services.AddScoped<AclService>();
|
||||||
|
|||||||
@@ -28,16 +28,33 @@ public sealed class AclService(
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Approve a zaak: set it to the eindstatus of the BIG zaaktype (resolved by identificatie, S-27).
|
/// 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).
|
/// 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).
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// 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.
|
||||||
|
/// </remarks>
|
||||||
public async Task ApproveZaakAsync(Uri zaakUrl, CancellationToken ct = default)
|
public async Task ApproveZaakAsync(Uri zaakUrl, CancellationToken ct = default)
|
||||||
{
|
{
|
||||||
ArgumentNullException.ThrowIfNull(zaakUrl);
|
ArgumentNullException.ThrowIfNull(zaakUrl);
|
||||||
|
|
||||||
await gateway.SetZaakToEindstatusAsync(zaakUrl, await catalog.GetZaaktypeUrlAsync(ct), clock.Today, ct);
|
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);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>The zaak's UUID — the key the register record and the read projection rows share.</summary>
|
||||||
|
private static string ZaakId(Uri zaakUrl) => zaakUrl.Segments[^1].TrimEnd('/');
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Cancel a zaak on document-timeout expiry (S-10c): set it to the BIG zaaktype's cancellation
|
/// 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
|
/// statustype + resultaat. The domain hands over only the zaak URL; the ACL owns which
|
||||||
|
|||||||
@@ -1,10 +1,143 @@
|
|||||||
|
using System.Net.Http.Headers;
|
||||||
|
using System.Net.Http.Json;
|
||||||
|
using System.Text.Json.Serialization;
|
||||||
using Acl.Application;
|
using Acl.Application;
|
||||||
|
|
||||||
namespace Acl.Infrastructure;
|
namespace Acl.Infrastructure;
|
||||||
|
|
||||||
/// <summary>The only code that talks to the Objecten API (ADR-0028).</summary>
|
/// <summary>
|
||||||
|
/// The only code that talks to the Objecten API (ADR-0028). Writes the register record as an object
|
||||||
|
/// of the <c>RegisterRecord</c> objecttype registered in S-18c.
|
||||||
|
/// </summary>
|
||||||
public sealed class ObjectenGateway(HttpClient http, ObjectenOptions options, IClock clock) : IRegisterRecordGateway
|
public sealed class ObjectenGateway(HttpClient http, ObjectenOptions options, IClock clock) : IRegisterRecordGateway
|
||||||
{
|
{
|
||||||
public Task UpsertAsync(RegisterRecord record, CancellationToken ct = default) =>
|
// The objecttype URL + version are assigned by Objecttypen at seed time, so they are resolved by
|
||||||
throw new NotImplementedException();
|
// 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"));
|
||||||
|
|
||||||
|
/// <summary>The URL + latest version number of the configured objecttype, read from Objecttypen.</summary>
|
||||||
|
private async Task<Objecttype> ResolveObjecttypeAsync(CancellationToken ct)
|
||||||
|
{
|
||||||
|
var page = await GetAsync<ObjecttypePage>(
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>The URL of the object already holding this registration's record, or null if there is none.</summary>
|
||||||
|
private async Task<Uri?> 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<ObjectPage>(query, options.Token, crs: true, "objects", ct);
|
||||||
|
var match = (page.Results ?? []).FirstOrDefault();
|
||||||
|
return match is null ? null : new Uri(match.Url);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<T> GetAsync<T>(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<T>(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<ObjecttypeDto>? Results);
|
||||||
|
|
||||||
|
private sealed record ObjecttypeDto(
|
||||||
|
[property: JsonPropertyName("url")] string Url,
|
||||||
|
[property: JsonPropertyName("name")] string? Name,
|
||||||
|
[property: JsonPropertyName("versions")] IReadOnlyList<string>? Versions);
|
||||||
|
|
||||||
|
private sealed record ObjectPage(
|
||||||
|
[property: JsonPropertyName("results")] IReadOnlyList<ObjectDto>? 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);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user