feat(zgw): real per-request identity seam + citizen-scoping (WP-53)
CI / frontend (push) Failing after 1m19s
CI / backend (push) Successful in 2m0s
CI / e2e (push) Successful in 3m57s
CI / storybook-a11y (push) Successful in 7m45s
CI / semgrep (push) Successful in 1m6s
CI / api-client-drift (push) Successful in 1m55s

Replaces the hardcoded DocumentStore.DemoOwner and the static ZgwOptions
UserId/UserRepresentation with one per-request CallerIdentity, resolved by a
pluggable IIdentityProvider (StubIdentityProvider reads X-Role/X-Subject
today; a real OIDC/DigiD provider swaps in without touching any consumer).

- Domain/Authorization/{CallerIdentity,IIdentityProvider,StubIdentityProvider}.cs
  + a resolution middleware in Program.cs, right after correlation-id.
- Authz.ResolvePrincipal(ctx) keeps its signature (now reads ctx.Caller().Role),
  so its ~15 call sites needed no changes.
- Every endpoint that passed DocumentStore.DemoOwner to a store now passes
  ctx.Caller().Bsn.
- ZgwTokenProvider gains Mint(CallerIdentity) alongside the original Mint()
  (kept for calls not tied to one citizen); ZgwHttpClient threads an optional
  caller through to pick the right overload.
- IZaakSource gains ListMyCases(caller, now) — the citizen-scoped read
  OpenZaakZaakSource backs with ZGW's rol__...__inpBsn filter. GET /applications
  now routes through it instead of ApplicationStore directly, closing the last
  "reads a static store" gap for a citizen-facing endpoint.

Backend 159/159 tests (+8, incl. an HTTP-level two-identity scoping proof),
npm run ci green, no api-client drift.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
eho
2026-07-30 08:27:53 +02:00
co-authored by Claude Sonnet 5
parent bea04549dd
commit 73172510ea
21 changed files with 418 additions and 110 deletions
@@ -1,4 +1,5 @@
using BigRegister.Api.Contracts; using BigRegister.Api.Contracts;
using BigRegister.Domain.Authorization;
namespace BigRegister.Api.Data; namespace BigRegister.Api.Data;
@@ -14,15 +15,18 @@ namespace BigRegister.Api.Data;
public interface IDocumentSource public interface IDocumentSource
{ {
/// <summary>Store an uploaded file (already validated by <c>DocumentRules</c>) and return the /// <summary>Store an uploaded file (already validated by <c>DocumentRules</c>) and return the
/// existing <see cref="UploadResponse"/> DTO unchanged, whichever source is active.</summary> /// existing <see cref="UploadResponse"/> DTO unchanged, whichever source is active.
/// <paramref name="caller"/> (WP-53) is both the document's owner (<c>DocumentStore</c>'s
/// ownership field) and, under the OpenZaak source, the identity minted into the ZGW JWT.</summary>
UploadResponse Upload( UploadResponse Upload(
string localId, string categoryId, string wizardId, string fileName, string contentType, string localId, string categoryId, string wizardId, string fileName, string contentType,
byte[] content, string owner); byte[] content, CallerIdentity caller);
/// <summary>Finalise a set of already-uploaded documents against a just-submitted aanvraag /// <summary>Finalise a set of already-uploaded documents against a just-submitted aanvraag
/// (WP-50/51): local behaviour is exactly today's <c>DocumentStore.Link</c>; the OpenZaak /// (WP-50/51): local behaviour is exactly today's <c>DocumentStore.Link</c>; the OpenZaak
/// source additionally links each document (that has a DRC url) to the zaak, once /// source additionally links each document (that has a DRC url) to the zaak, once
/// <paramref name="zaakUrl"/> is known (null under the local <see cref="IZaakSource"/>, in /// <paramref name="zaakUrl"/> is known (null under the local <see cref="IZaakSource"/>, in
/// which case there is nothing extra to link).</summary> /// which case there is nothing extra to link) — minted with <paramref name="caller"/>'s
void LinkToZaak(IReadOnlyList<string> documentIds, string? zaakUrl); /// identity (WP-53).</summary>
void LinkToZaak(IReadOnlyList<string> documentIds, string? zaakUrl, CallerIdentity caller);
} }
@@ -1,4 +1,5 @@
using BigRegister.Api.Contracts; using BigRegister.Api.Contracts;
using BigRegister.Domain.Authorization;
namespace BigRegister.Api.Data; namespace BigRegister.Api.Data;
@@ -16,9 +17,19 @@ namespace BigRegister.Api.Data;
/// </summary> /// </summary>
public interface IZaakSource public interface IZaakSource
{ {
/// <summary>Every case, newest-first (the admin cross-owner list, WP-36).</summary> /// <summary>Every case across every owner, newest-first (the admin cross-owner list,
/// WP-36) — cases:manage only, deliberately NOT citizen-scoped.</summary>
IReadOnlyList<ApplicationSummaryDto> ListCases(DateTimeOffset now); IReadOnlyList<ApplicationSummaryDto> ListCases(DateTimeOffset now);
/// <summary>
/// Only <paramref name="caller"/>'s own cases (WP-53) — the citizen-scoped counterpart of
/// <see cref="ListCases"/>, backing the citizen's own dashboard. The local source filters
/// <c>ApplicationStore</c> by owner (unchanged behaviour); the OpenZaak source adds ZGW's
/// <c>rol__betrokkeneIdentificatie__natuurlijkPersoon__inpBsn</c> query filter so a citizen
/// can never see another citizen's zaken.
/// </summary>
IReadOnlyList<ApplicationSummaryDto> ListMyCases(CallerIdentity caller, DateTimeOffset now);
/// <summary> /// <summary>
/// Register a just-submitted <paramref name="aanvraag"/> as a zaak (WP-50). The aanvraag is /// Register a just-submitted <paramref name="aanvraag"/> as a zaak (WP-50). The aanvraag is
/// already persisted locally (<c>ApplicationStore.Submit</c> already ran) — this is the /// already persisted locally (<c>ApplicationStore.Submit</c> already ran) — this is the
@@ -28,7 +39,8 @@ public interface IZaakSource
/// reference/status (ZaakUrl null — nothing to persist); the OpenZaak source creates a Zaak /// reference/status (ZaakUrl null — nothing to persist); the OpenZaak source creates a Zaak
/// (+ status + rol) and maps the result back into the same shape, returning the zaak's URL /// (+ status + rol) and maps the result back into the same shape, returning the zaak's URL
/// so the endpoint can persist it (<see cref="ApplicationStore.SetZaakUrl"/>, WP-51 needs it /// so the endpoint can persist it (<see cref="ApplicationStore.SetZaakUrl"/>, WP-51 needs it
/// to later link documents to this zaak). /// to later link documents to this zaak). <paramref name="caller"/> (WP-53) is the acting
/// citizen — the ZGW JWT's audit claims reflect them, not a static config identity.
/// </summary> /// </summary>
(string Referentie, AanvraagStatusDto Status, string? ZaakUrl) CreateZaak(Aanvraag aanvraag, DateTimeOffset now); (string Referentie, AanvraagStatusDto Status, string? ZaakUrl) CreateZaak(Aanvraag aanvraag, DateTimeOffset now, CallerIdentity caller);
} }
@@ -1,4 +1,5 @@
using BigRegister.Api.Contracts; using BigRegister.Api.Contracts;
using BigRegister.Domain.Authorization;
namespace BigRegister.Api.Data; namespace BigRegister.Api.Data;
@@ -12,12 +13,12 @@ public sealed class LocalDocumentSource : IDocumentSource
{ {
public UploadResponse Upload( public UploadResponse Upload(
string localId, string categoryId, string wizardId, string fileName, string contentType, string localId, string categoryId, string wizardId, string fileName, string contentType,
byte[] content, string owner) byte[] content, CallerIdentity caller)
{ {
var doc = DocumentStore.Add(localId, categoryId, wizardId, fileName, contentType, content, owner); var doc = DocumentStore.Add(localId, categoryId, wizardId, fileName, contentType, content, caller.Bsn);
return new UploadResponse(doc.DocumentId, doc.LocalId); return new UploadResponse(doc.DocumentId, doc.LocalId);
} }
public void LinkToZaak(IReadOnlyList<string> documentIds, string? zaakUrl) => public void LinkToZaak(IReadOnlyList<string> documentIds, string? zaakUrl, CallerIdentity caller) =>
DocumentStore.Link(documentIds); DocumentStore.Link(documentIds);
} }
@@ -1,4 +1,5 @@
using BigRegister.Api.Contracts; using BigRegister.Api.Contracts;
using BigRegister.Domain.Authorization;
namespace BigRegister.Api.Data; namespace BigRegister.Api.Data;
@@ -13,8 +14,15 @@ public sealed class LocalZaakSource : IZaakSource
public IReadOnlyList<ApplicationSummaryDto> ListCases(DateTimeOffset now) => public IReadOnlyList<ApplicationSummaryDto> ListCases(DateTimeOffset now) =>
ApplicationStore.ListAll().Select(a => a.ToAdminSummaryDto(now)).ToList(); ApplicationStore.ListAll().Select(a => a.ToAdminSummaryDto(now)).ToList();
/// <summary>Citizen-scoped (WP-53) — exactly what <c>GET /applications</c> used to compute
/// inline before it was routed through this seam.</summary>
public IReadOnlyList<ApplicationSummaryDto> ListMyCases(CallerIdentity caller, DateTimeOffset now) =>
ApplicationStore.List(caller.Bsn)
.OrderByDescending(a => a.UpdatedAt)
.Select(a => a.ToSummaryDto(now)).ToList();
/// <summary>No external zaak to create — the aanvraag's local submit already IS the record /// <summary>No external zaak to create — the aanvraag's local submit already IS the record
/// of truth, exactly as before this seam existed (WP-50). Zero behaviour change.</summary> /// of truth, exactly as before this seam existed (WP-50). Zero behaviour change.</summary>
public (string Referentie, AanvraagStatusDto Status, string? ZaakUrl) CreateZaak(Aanvraag aanvraag, DateTimeOffset now) => public (string Referentie, AanvraagStatusDto Status, string? ZaakUrl) CreateZaak(Aanvraag aanvraag, DateTimeOffset now, CallerIdentity caller) =>
(aanvraag.Referentie!, aanvraag.ToStatusDto(now), null); (aanvraag.Referentie!, aanvraag.ToStatusDto(now), null);
} }
@@ -24,12 +24,10 @@ public enum BriefAction { Approve, Reject, Send }
/// </summary> /// </summary>
public static class Authz public static class Authz
{ {
public static Principal ResolvePrincipal(HttpContext ctx) => new(ctx.Request.Headers["X-Role"].ToString() switch // WP-53: role now comes from the per-request CallerIdentity the identity middleware
{ // resolved (StubIdentityProvider reads the same X-Role header this used to read directly) —
"approver" => PrincipalRole.Approver, // one source of "who", so a real IIdentityProvider swap carries this over unchanged.
"admin" => PrincipalRole.Admin, public static Principal ResolvePrincipal(HttpContext ctx) => new(ctx.Caller().Role);
_ => PrincipalRole.Drafter,
});
public static string ActingId(Principal principal) => principal.Role switch public static string ActingId(Principal principal) => principal.Role switch
{ {
@@ -0,0 +1,27 @@
namespace BigRegister.Domain.Authorization;
/// <summary>
/// The acting citizen for this request (WP-53) — subject BSN, display name, and role. Resolved
/// once per request by <see cref="IIdentityProvider"/> and stashed on <see cref="HttpContext.Items"/>
/// by the identity-resolution middleware (<c>Program.cs</c>, right after the correlation-id
/// middleware). Everything that used to hardcode <c>DocumentStore.DemoOwner</c> or the static
/// <c>ZgwOptions.UserId</c>/<c>UserRepresentation</c> claims now reads this instead — a production
/// <see cref="IIdentityProvider"/> (real OIDC/DigiD claims) swaps in without touching any consumer.
/// </summary>
public sealed record CallerIdentity(string Bsn, string DisplayName, PrincipalRole Role);
public static class CallerIdentityHttpContextExtensions
{
private const string ItemsKey = "CallerIdentity";
public static void SetCaller(this HttpContext ctx, CallerIdentity identity) => ctx.Items[ItemsKey] = identity;
/// <summary>Never null in practice — the identity-resolution middleware runs for every
/// request before any endpoint handler. Throws rather than silently falling back, so a
/// misordered middleware pipeline fails loudly instead of leaking a default identity.</summary>
public static CallerIdentity Caller(this HttpContext ctx) =>
ctx.Items.TryGetValue(ItemsKey, out var v) && v is CallerIdentity identity
? identity
: throw new InvalidOperationException(
"No CallerIdentity resolved for this request — the identity middleware didn't run.");
}
@@ -0,0 +1,11 @@
namespace BigRegister.Domain.Authorization;
/// <summary>
/// Resolves the acting <see cref="CallerIdentity"/> for a request (WP-53) — the seam a real
/// OIDC/DigiD-backed provider replaces in production. <see cref="StubIdentityProvider"/> is the
/// only implementation today.
/// </summary>
public interface IIdentityProvider
{
CallerIdentity Resolve(HttpContext ctx);
}
@@ -0,0 +1,32 @@
using BigRegister.Api.Data;
namespace BigRegister.Domain.Authorization;
/// <summary>
/// Dev stub (WP-53) — NOT a security boundary, same caveat as <see cref="Authz.ResolvePrincipal"/>
/// (which this provider now backs). Role comes from the existing client-asserted X-Role header
/// (mirrors the FE's <c>?role=</c> toggle); the subject BSN comes from a new X-Subject header,
/// defaulting to the single seeded citizen (<see cref="DocumentStore.DemoOwner"/>) so every
/// existing request — none of which send X-Subject — keeps behaving exactly as before this WP.
/// A real system builds this from verified AD/OIDC/DigiD claims; every consumer of
/// <see cref="CallerIdentity"/> carries over unchanged once that swap happens.
/// </summary>
public sealed class StubIdentityProvider : IIdentityProvider
{
public CallerIdentity Resolve(HttpContext ctx)
{
var role = ctx.Request.Headers["X-Role"].ToString() switch
{
"approver" => PrincipalRole.Approver,
"admin" => PrincipalRole.Admin,
_ => PrincipalRole.Drafter,
};
var bsn = ctx.Request.Headers.TryGetValue("X-Subject", out var v) && !string.IsNullOrEmpty(v)
? v.ToString()
: DocumentStore.DemoOwner;
// Only one seeded citizen exists in this POC — a real provider carries the display name in
// the verified claims themselves, so there's no "look up a name by BSN" step to stand in for.
var displayName = bsn == DocumentStore.DemoOwner ? SeedData.Registration.Naam : bsn;
return new CallerIdentity(bsn, displayName, role);
}
}
+50 -34
View File
@@ -43,6 +43,12 @@ builder.Services.AddCors(o => o.AddPolicy(SpaCors, p =>
// override it (ConnectionStrings:AppDb) without touching this file. // override it (ConnectionStrings:AppDb) without touching this file.
Db.ConnectionString = builder.Configuration.GetConnectionString("AppDb") ?? Db.ConnectionString; Db.ConnectionString = builder.Configuration.GetConnectionString("AppDb") ?? Db.ConnectionString;
// WP-53: the per-request acting citizen — resolved once (middleware, below) into
// HttpContext.Items, consumed by Authz.ResolvePrincipal, ZgwTokenProvider.Mint(caller), and
// every store call site that used to hardcode DocumentStore.DemoOwner. Stub today
// (X-Role/X-Subject headers); a real OIDC/DigiD provider swaps in without touching a consumer.
builder.Services.AddSingleton<IIdentityProvider, StubIdentityProvider>();
// WP-49: the cases (zaken) READ path goes through IZaakSource so a real ZGW backend // WP-49: the cases (zaken) READ path goes through IZaakSource so a real ZGW backend
// (OpenZaak) can replace the local SQLite store behind the same DTO contract — the FE never // (OpenZaak) can replace the local SQLite store behind the same DTO contract — the FE never
// changes (ADR-0001). Default = LocalZaakSource (offline). Zgw:Enabled=true swaps in the // changes (ADR-0001). Default = LocalZaakSource (offline). Zgw:Enabled=true swaps in the
@@ -87,6 +93,16 @@ app.Use(async (ctx, next) =>
await next(ctx); await next(ctx);
}); });
// WP-53: resolve the acting citizen once per request, right after correlation — everything
// downstream (Authz.ResolvePrincipal, the endpoints below) reads it via ctx.Caller() instead of
// re-deriving "who" itself.
var identityProvider = app.Services.GetRequiredService<IIdentityProvider>();
app.Use(async (ctx, next) =>
{
ctx.SetCaller(identityProvider.Resolve(ctx));
await next(ctx);
});
app.UseSwagger(); app.UseSwagger();
app.UseSwaggerUI(); app.UseSwaggerUI();
app.UseCors(SpaCors); app.UseCors(SpaCors);
@@ -182,7 +198,7 @@ api.MapGet("/uploads/categories", (string wizardId, string? diplomaHerkomst, str
// Multipart upload. Hand-written on the FE (XHR for progress), so it is excluded // Multipart upload. Hand-written on the FE (XHR for progress), so it is excluded
// from the OpenAPI doc to keep the NSwag-generated client JSON-only. Validates type // from the OpenAPI doc to keep the NSwag-generated client JSON-only. Validates type
// and size authoritatively; stores metadata only (no file bytes / PII held). // and size authoritatively; stores metadata only (no file bytes / PII held).
api.MapPost("/uploads", async (HttpRequest request, IDocumentSource documents) => api.MapPost("/uploads", async (HttpRequest request, HttpContext ctx, IDocumentSource documents) =>
{ {
if (!request.HasFormContentType) return Results.Problem(detail: "Verwacht multipart/form-data.", statusCode: 400); if (!request.HasFormContentType) return Results.Problem(detail: "Verwacht multipart/form-data.", statusCode: 400);
var form = await request.ReadFormAsync(); var form = await request.ReadFormAsync();
@@ -200,7 +216,7 @@ api.MapPost("/uploads", async (HttpRequest request, IDocumentSource documents) =
// WP-51: route through IDocumentSource — LocalDocumentSource is the same DocumentStore.Add // WP-51: route through IDocumentSource — LocalDocumentSource is the same DocumentStore.Add
// call this used to make inline; OpenZaakDocumentSource (Zgw:Enabled=true) also registers // call this used to make inline; OpenZaakDocumentSource (Zgw:Enabled=true) also registers
// the file as a DRC enkelvoudiginformatieobject. Response DTO unchanged either way. // the file as a DRC enkelvoudiginformatieobject. Response DTO unchanged either way.
var response = documents.Upload(localId, categoryId, wizardId, file.FileName, file.ContentType, ms.ToArray(), DocumentStore.DemoOwner); var response = documents.Upload(localId, categoryId, wizardId, file.FileName, file.ContentType, ms.ToArray(), ctx.Caller());
return Results.Created($"/api/v1/uploads/{response.DocumentId}", response); return Results.Created($"/api/v1/uploads/{response.DocumentId}", response);
}) })
.ExcludeFromDescription(); .ExcludeFromDescription();
@@ -229,8 +245,8 @@ api.MapGet("/uploads/status", (string? localIds) =>
}); });
// User delete: owner-scoped; 409 once linked to a finalised submission. // User delete: owner-scoped; 409 once linked to a finalised submission.
api.MapDelete("/uploads/{documentId}", (string documentId) => api.MapDelete("/uploads/{documentId}", (string documentId, HttpContext ctx) =>
DocumentStore.DeleteOwned(documentId, DocumentStore.DemoOwner) switch DocumentStore.DeleteOwned(documentId, ctx.Caller().Bsn) switch
{ {
DocumentStore.DeleteResult.Ok => Results.NoContent(), DocumentStore.DeleteResult.Ok => Results.NoContent(),
DocumentStore.DeleteResult.Linked => Results.Problem( DocumentStore.DeleteResult.Linked => Results.Problem(
@@ -253,27 +269,26 @@ api.MapDelete("/admin/uploads/{documentId}", (string documentId, HttpContext ctx
// --- Applications (aanvragen): the system of record the dashboard reads. --- // --- Applications (aanvragen): the system of record the dashboard reads. ---
api.MapGet("/applications", () => // WP-53: routed through IZaakSource (like /admin/cases already was) rather than calling
{ // ApplicationStore directly — under Zgw:Enabled=true a citizen's own dashboard list comes from
var now = DateTimeOffset.UtcNow; // OpenZaak (BSN-filtered) too, closing the last "reads a static store directly" gap
return ApplicationStore.List(DocumentStore.DemoOwner) // openzaak-integration.md's ACL caveat used to flag for this endpoint.
.OrderByDescending(a => a.UpdatedAt) api.MapGet("/applications", (HttpContext ctx, IZaakSource zaken) =>
.Select(a => a.ToSummaryDto(now)).ToList(); zaken.ListMyCases(ctx.Caller(), DateTimeOffset.UtcNow));
});
api.MapGet("/applications/{id}", (string id) => api.MapGet("/applications/{id}", (string id, HttpContext ctx) =>
ApplicationStore.Get(id, DocumentStore.DemoOwner) is { } a ApplicationStore.Get(id, ctx.Caller().Bsn) is { } a
? Results.Ok(a.ToDetailDto(DateTimeOffset.UtcNow)) ? Results.Ok(a.ToDetailDto(DateTimeOffset.UtcNow))
: Results.NotFound()) : Results.NotFound())
.Produces<ApplicationDetailDto>() .Produces<ApplicationDetailDto>()
.Produces(StatusCodes.Status404NotFound); .Produces(StatusCodes.Status404NotFound);
api.MapPost("/applications", (CreateApplicationRequest req) => api.MapPost("/applications", (CreateApplicationRequest req, HttpContext ctx) =>
{ {
// Feature flag (WP-47): self-service registration can be closed by an admin. // Feature flag (WP-47): self-service registration can be closed by an admin.
if (req.Type == "registratie" && !FeatureFlagStore.IsEnabled(FeatureFlags.InschrijvingOpen)) if (req.Type == "registratie" && !FeatureFlagStore.IsEnabled(FeatureFlags.InschrijvingOpen))
return Results.Problem(detail: "Inschrijving is momenteel gesloten.", statusCode: StatusCodes.Status403Forbidden); return Results.Problem(detail: "Inschrijving is momenteel gesloten.", statusCode: StatusCodes.Status403Forbidden);
var a = ApplicationStore.CreateConcept(req.Type, DocumentStore.DemoOwner); var a = ApplicationStore.CreateConcept(req.Type, ctx.Caller().Bsn);
if (a is null) if (a is null)
return Results.Problem( return Results.Problem(
detail: "U hebt al een concept van dit type. Rond dat eerst af of verwijder het.", detail: "U hebt al een concept van dit type. Rond dat eerst af of verwijder het.",
@@ -284,21 +299,21 @@ api.MapPost("/applications", (CreateApplicationRequest req) =>
.ProducesProblem(StatusCodes.Status409Conflict); .ProducesProblem(StatusCodes.Status409Conflict);
// Draft sync per step — idempotent; keep it debounced on the client (it is chatty). // Draft sync per step — idempotent; keep it debounced on the client (it is chatty).
api.MapPut("/applications/{id}", (string id, DraftSyncRequest req) => api.MapPut("/applications/{id}", (string id, DraftSyncRequest req, HttpContext ctx) =>
ApplicationStore.SyncDraft(id, DocumentStore.DemoOwner, req.Draft, req.StepIndex, req.StepCount, req.DocumentIds) ApplicationStore.SyncDraft(id, ctx.Caller().Bsn, req.Draft, req.StepIndex, req.StepCount, req.DocumentIds)
? Results.NoContent() : Results.NotFound()) ? Results.NoContent() : Results.NotFound())
.Produces(StatusCodes.Status204NoContent) .Produces(StatusCodes.Status204NoContent)
.Produces(StatusCodes.Status404NotFound); .Produces(StatusCodes.Status404NotFound);
// Cancel a Concept (cascades to its unlinked documents). Submitted aanvragen cannot // Cancel a Concept (cascades to its unlinked documents). Submitted aanvragen cannot
// be withdrawn (out of scope — no "intrekken"). // be withdrawn (out of scope — no "intrekken").
api.MapDelete("/applications/{id}", (string id) => api.MapDelete("/applications/{id}", (string id, HttpContext ctx) =>
{ {
var a = ApplicationStore.Get(id, DocumentStore.DemoOwner); var a = ApplicationStore.Get(id, ctx.Caller().Bsn);
if (a is null) return Results.NotFound(); if (a is null) return Results.NotFound();
if (a.Submitted) if (a.Submitted)
return Results.Problem(detail: "Een ingediende aanvraag kan niet worden geannuleerd.", statusCode: StatusCodes.Status409Conflict); return Results.Problem(detail: "Een ingediende aanvraag kan niet worden geannuleerd.", statusCode: StatusCodes.Status409Conflict);
ApplicationStore.Delete(id, DocumentStore.DemoOwner); ApplicationStore.Delete(id, ctx.Caller().Bsn);
return Results.NoContent(); return Results.NoContent();
}) })
.Produces(StatusCodes.Status204NoContent) .Produces(StatusCodes.Status204NoContent)
@@ -309,7 +324,7 @@ api.MapDelete("/applications/{id}", (string id) =>
// aanvraag. handmatig no longer 422s (ADR-0002): it becomes a manual (pending) case. // aanvraag. handmatig no longer 422s (ADR-0002): it becomes a manual (pending) case.
api.MapPost("/applications/{id}/submit", (string id, SubmitApplicationRequest req, HttpContext ctx, IZaakSource zaken, IDocumentSource documents) => api.MapPost("/applications/{id}/submit", (string id, SubmitApplicationRequest req, HttpContext ctx, IZaakSource zaken, IDocumentSource documents) =>
{ {
var existing = ApplicationStore.Get(id, DocumentStore.DemoOwner); var existing = ApplicationStore.Get(id, ctx.Caller().Bsn);
if (existing is null) return Results.NotFound(); if (existing is null) return Results.NotFound();
if (existing.Submitted) if (existing.Submitted)
return Results.Problem(detail: "Aanvraag is al ingediend.", statusCode: StatusCodes.Status409Conflict); return Results.Problem(detail: "Aanvraag is al ingediend.", statusCode: StatusCodes.Status409Conflict);
@@ -324,7 +339,7 @@ api.MapPost("/applications/{id}/submit", (string id, SubmitApplicationRequest re
var docs = req.Documents; var docs = req.Documents;
var documentIds = docs?.Where(d => d.Channel == "digital" && d.DocumentId is not null).Select(d => d.DocumentId!).ToList(); var documentIds = docs?.Where(d => d.Channel == "digital" && d.DocumentId is not null).Select(d => d.DocumentId!).ToList();
var submitted = ApplicationStore.Submit(id, DocumentStore.DemoOwner, reject, autoApprovable, documentIds); var submitted = ApplicationStore.Submit(id, ctx.Caller().Bsn, reject, autoApprovable, documentIds);
if (submitted is null) return Results.Conflict(); if (submitted is null) return Results.Conflict();
app.Logger.LogInformation( app.Logger.LogInformation(
@@ -334,14 +349,15 @@ api.MapPost("/applications/{id}/submit", (string id, SubmitApplicationRequest re
// WP-50: route the create through the IZaakSource seam — LocalZaakSource is a passthrough // WP-50: route the create through the IZaakSource seam — LocalZaakSource is a passthrough
// of what was computed above; OpenZaakZaakSource (Zgw:Enabled=true) also registers a zaak // of what was computed above; OpenZaakZaakSource (Zgw:Enabled=true) also registers a zaak
// in OpenZaak and maps its result back into this same response shape (ADR-0001/ADR-0005: // in OpenZaak and maps its result back into this same response shape (ADR-0001/ADR-0005:
// zero FE contract change either way). // zero FE contract change either way). WP-53: the caller is threaded through so the minted
var (referentie, status, zaakUrl) = zaken.CreateZaak(submitted, DateTimeOffset.UtcNow); // ZGW JWT's user_id/user_representation reflect the acting citizen, not a static config value.
var (referentie, status, zaakUrl) = zaken.CreateZaak(submitted, DateTimeOffset.UtcNow, ctx.Caller());
if (zaakUrl is not null) ApplicationStore.SetZaakUrl(id, zaakUrl); if (zaakUrl is not null) ApplicationStore.SetZaakUrl(id, zaakUrl);
// WP-51: link the submitted documents to the zaak — LocalDocumentSource is exactly the // WP-51: link the submitted documents to the zaak — LocalDocumentSource is exactly the
// DocumentStore.Link call this used to make inline; OpenZaakDocumentSource additionally // DocumentStore.Link call this used to make inline; OpenZaakDocumentSource additionally
// POSTs a zaakinformatieobject per document, now that the zaak (zaakUrl) exists. // POSTs a zaakinformatieobject per document, now that the zaak (zaakUrl) exists.
if (documentIds is not null) documents.LinkToZaak(documentIds, zaakUrl); if (documentIds is not null) documents.LinkToZaak(documentIds, zaakUrl, ctx.Caller());
return Results.Ok(new SubmitApplicationResponse(referentie, status)); return Results.Ok(new SubmitApplicationResponse(referentie, status));
}) })
@@ -430,7 +446,7 @@ api.MapPut("/admin/flags/{key}", (string key, SetFeatureFlagRequest req, HttpCon
api.MapGet("/brief", (HttpContext ctx) => api.MapGet("/brief", (HttpContext ctx) =>
{ {
var e = BriefStore.GetOrCreate(DocumentStore.DemoOwner); var e = BriefStore.GetOrCreate(ctx.Caller().Bsn);
return ToView(ctx, e); return ToView(ctx, e);
}) })
.Produces<BriefViewDto>(); .Produces<BriefViewDto>();
@@ -438,7 +454,7 @@ api.MapGet("/brief", (HttpContext ctx) =>
api.MapPut("/brief", (SaveBriefRequest req, HttpContext ctx) => api.MapPut("/brief", (SaveBriefRequest req, HttpContext ctx) =>
{ {
var isDrafter = Authz.ResolvePrincipal(ctx).Role == PrincipalRole.Drafter; var isDrafter = Authz.ResolvePrincipal(ctx).Role == PrincipalRole.Drafter;
return BriefResult(ctx, BriefStore.Save(DocumentStore.DemoOwner, req.Sections, isDrafter), "Alleen de opsteller mag de brief bewerken."); return BriefResult(ctx, BriefStore.Save(ctx.Caller().Bsn, req.Sections, isDrafter), "Alleen de opsteller mag de brief bewerken.");
}) })
.Produces<BriefViewDto>() .Produces<BriefViewDto>()
.ProducesProblem(StatusCodes.Status403Forbidden) .ProducesProblem(StatusCodes.Status403Forbidden)
@@ -447,7 +463,7 @@ api.MapPut("/brief", (SaveBriefRequest req, HttpContext ctx) =>
api.MapPost("/brief/submit", (HttpContext ctx) => api.MapPost("/brief/submit", (HttpContext ctx) =>
{ {
var isDrafter = Authz.ResolvePrincipal(ctx).Role == PrincipalRole.Drafter; var isDrafter = Authz.ResolvePrincipal(ctx).Role == PrincipalRole.Drafter;
var r = BriefStore.Submit(DocumentStore.DemoOwner, isDrafter, Now()); var r = BriefStore.Submit(ctx.Caller().Bsn, isDrafter, Now());
LogBrief("submit", r); LogBrief("submit", r);
return BriefResult(ctx, r, "Alleen de opsteller mag indienen."); return BriefResult(ctx, r, "Alleen de opsteller mag indienen.");
}) })
@@ -458,7 +474,7 @@ api.MapPost("/brief/submit", (HttpContext ctx) =>
api.MapPost("/brief/approve", (HttpContext ctx) => api.MapPost("/brief/approve", (HttpContext ctx) =>
{ {
var r = BriefStore.Approve(DocumentStore.DemoOwner, Authz.ResolvePrincipal(ctx), Now()); var r = BriefStore.Approve(ctx.Caller().Bsn, Authz.ResolvePrincipal(ctx), Now());
LogBrief("approve", r); LogBrief("approve", r);
return BriefResult(ctx, r, "De beoordelaar mag niet de opsteller zijn."); return BriefResult(ctx, r, "De beoordelaar mag niet de opsteller zijn.");
}) })
@@ -468,7 +484,7 @@ api.MapPost("/brief/approve", (HttpContext ctx) =>
api.MapPost("/brief/reject", (RejectBriefRequest req, HttpContext ctx) => api.MapPost("/brief/reject", (RejectBriefRequest req, HttpContext ctx) =>
{ {
var r = BriefStore.Reject(DocumentStore.DemoOwner, Authz.ResolvePrincipal(ctx), req.Comments, Now()); var r = BriefStore.Reject(ctx.Caller().Bsn, Authz.ResolvePrincipal(ctx), req.Comments, Now());
LogBrief("reject", r); LogBrief("reject", r);
return BriefResult(ctx, r, "De beoordelaar mag niet de opsteller zijn."); return BriefResult(ctx, r, "De beoordelaar mag niet de opsteller zijn.");
}) })
@@ -481,7 +497,7 @@ api.MapPost("/brief/send", (HttpContext ctx) =>
// Send-time placeholder linting is FE-authoritative in this slice (no C# parity // Send-time placeholder linting is FE-authoritative in this slice (no C# parity
// port); the backend only guards the approved→sent transition (not role-gated // port); the backend only guards the approved→sent transition (not role-gated
// today — see Authz.CanActOn(Send, …), a mechanical dispatch step). // today — see Authz.CanActOn(Send, …), a mechanical dispatch step).
var r = BriefStore.Send(DocumentStore.DemoOwner, Now()); var r = BriefStore.Send(ctx.Caller().Bsn, Now());
LogBrief("send", r); LogBrief("send", r);
return BriefResult(ctx, r, "Versturen kan niet in deze status."); return BriefResult(ctx, r, "Versturen kan niet in deze status.");
}) })
@@ -499,7 +515,7 @@ api.MapPost("/brief/reveal-bignummer", (HttpContext ctx) =>
var canReveal = Authz.CanRevealBigNummer(principal); var canReveal = Authz.CanRevealBigNummer(principal);
var steppedUp = ctx.Request.Headers["X-Step-Up"] == "true"; var steppedUp = ctx.Request.Headers["X-Step-Up"] == "true";
var allowed = canReveal && steppedUp; var allowed = canReveal && steppedUp;
AuditAuthz(ctx, "brief:reveal-bignummer", "brief/" + DocumentStore.DemoOwner, allowed, principal); AuditAuthz(ctx, "brief:reveal-bignummer", "brief/" + ctx.Caller().Bsn, allowed, principal);
if (!allowed) if (!allowed)
return Results.Problem( return Results.Problem(
detail: canReveal detail: canReveal
@@ -518,7 +534,7 @@ api.MapPost("/brief/reveal-bignummer", (HttpContext ctx) =>
// letters serve their frozen archive; anything else renders live with a watermark. // letters serve their frozen archive; anything else renders live with a watermark.
api.MapGet("/brief/preview", (HttpContext ctx) => api.MapGet("/brief/preview", (HttpContext ctx) =>
{ {
var e = BriefStore.GetOrCreate(DocumentStore.DemoOwner); var e = BriefStore.GetOrCreate(ctx.Caller().Bsn);
if (e.Status.Tag == "sent" && e.ArchivedHtml is { } archived) if (e.Status.Tag == "sent" && e.ArchivedHtml is { } archived)
return Results.Content(archived, "text/html"); return Results.Content(archived, "text/html");
var template = OrgTemplateStore.TemplateForBrief(e.SubOrgId, null); var template = OrgTemplateStore.TemplateForBrief(e.SubOrgId, null);
@@ -540,7 +556,7 @@ api.MapGet("/admin/org-template/{subOrgId}/preview", (string subOrgId, HttpConte
api.MapPost("/brief/reset", (HttpContext ctx) => api.MapPost("/brief/reset", (HttpContext ctx) =>
{ {
// Demo "start over": recreate a fresh draft. No guards — showcase affordance only. // Demo "start over": recreate a fresh draft. No guards — showcase affordance only.
var e = BriefStore.ResetAndCreate(DocumentStore.DemoOwner); var e = BriefStore.ResetAndCreate(ctx.Caller().Bsn);
return ToView(ctx, e); return ToView(ctx, e);
}) })
.WithName("briefReset") .WithName("briefReset")
@@ -2,6 +2,7 @@ using System.Text.Json;
using System.Text.Json.Serialization; using System.Text.Json.Serialization;
using BigRegister.Api.Contracts; using BigRegister.Api.Contracts;
using BigRegister.Api.Data; using BigRegister.Api.Data;
using BigRegister.Domain.Authorization;
namespace BigRegister.Api.Zgw; namespace BigRegister.Api.Zgw;
@@ -26,15 +27,15 @@ public sealed class OpenZaakDocumentSource(HttpClient http, ZgwTokenProvider tok
// existing sync upload/submit endpoints, same reasoning as OpenZaakZaakSource. // existing sync upload/submit endpoints, same reasoning as OpenZaakZaakSource.
public UploadResponse Upload( public UploadResponse Upload(
string localId, string categoryId, string wizardId, string fileName, string contentType, string localId, string categoryId, string wizardId, string fileName, string contentType,
byte[] content, string owner) => byte[] content, CallerIdentity caller) =>
UploadAsync(localId, categoryId, wizardId, fileName, contentType, content, owner) UploadAsync(localId, categoryId, wizardId, fileName, contentType, content, caller)
.GetAwaiter().GetResult(); .GetAwaiter().GetResult();
private async Task<UploadResponse> UploadAsync( private async Task<UploadResponse> UploadAsync(
string localId, string categoryId, string wizardId, string fileName, string contentType, string localId, string categoryId, string wizardId, string fileName, string contentType,
byte[] content, string owner) byte[] content, CallerIdentity caller)
{ {
var doc = DocumentStore.Add(localId, categoryId, wizardId, fileName, contentType, content, owner); var doc = DocumentStore.Add(localId, categoryId, wizardId, fileName, contentType, content, caller.Bsn);
if (!options.InformatieobjecttypeUrls.TryGetValue(categoryId, out var informatieobjecttypeUrl)) if (!options.InformatieobjecttypeUrls.TryGetValue(categoryId, out var informatieobjecttypeUrl))
throw new InvalidOperationException( throw new InvalidOperationException(
@@ -54,7 +55,7 @@ public sealed class OpenZaakDocumentSource(HttpClient http, ZgwTokenProvider tok
// ponytail: hardcoded "openbaar" (public) — real usage would likely vary the // ponytail: hardcoded "openbaar" (public) — real usage would likely vary the
// confidentiality level per category (e.g. an identity document is more sensitive // confidentiality level per category (e.g. an identity document is more sensitive
// than a diploma); a fixed value is enough to prove the seam end-to-end. // than a diploma); a fixed value is enough to prove the seam end-to-end.
Vertrouwelijkheidaanduiding: "openbaar")); Vertrouwelijkheidaanduiding: "openbaar"), caller);
DocumentStore.SetDrcUrl(doc.DocumentId, eio.Url); DocumentStore.SetDrcUrl(doc.DocumentId, eio.Url);
return new UploadResponse(doc.DocumentId, doc.LocalId); return new UploadResponse(doc.DocumentId, doc.LocalId);
@@ -64,21 +65,21 @@ public sealed class OpenZaakDocumentSource(HttpClient http, ZgwTokenProvider tok
/// once a zaak exists, POST a zaakinformatieobject for every document that has a DRC url — /// once a zaak exists, POST a zaakinformatieobject for every document that has a DRC url —
/// documents uploaded before Zgw:Enabled was ever true (or under a config gap) simply have /// documents uploaded before Zgw:Enabled was ever true (or under a config gap) simply have
/// no DrcUrl yet and are skipped, matching "nothing extra to link" for the local case.</summary> /// no DrcUrl yet and are skipped, matching "nothing extra to link" for the local case.</summary>
public void LinkToZaak(IReadOnlyList<string> documentIds, string? zaakUrl) public void LinkToZaak(IReadOnlyList<string> documentIds, string? zaakUrl, CallerIdentity caller)
{ {
DocumentStore.Link(documentIds); DocumentStore.Link(documentIds);
if (zaakUrl is null) return; if (zaakUrl is null) return;
LinkToZaakAsync(documentIds, zaakUrl).GetAwaiter().GetResult(); LinkToZaakAsync(documentIds, zaakUrl, caller).GetAwaiter().GetResult();
} }
private async Task LinkToZaakAsync(IReadOnlyList<string> documentIds, string zaakUrl) private async Task LinkToZaakAsync(IReadOnlyList<string> documentIds, string zaakUrl, CallerIdentity caller)
{ {
foreach (var documentId in documentIds) foreach (var documentId in documentIds)
{ {
var drcUrl = DocumentStore.Get(documentId)?.DrcUrl; var drcUrl = DocumentStore.Get(documentId)?.DrcUrl;
if (drcUrl is null) continue; if (drcUrl is null) continue;
await zgw.PostAsync<JsonElement>($"{options.ZrcBaseUrl}/zaakinformatieobjecten", await zgw.PostAsync<JsonElement>($"{options.ZrcBaseUrl}/zaakinformatieobjecten",
new CreateZaakInformatieobjectRequest(zaakUrl, drcUrl)); new CreateZaakInformatieobjectRequest(zaakUrl, drcUrl), caller);
} }
} }
@@ -2,6 +2,7 @@ using System.Text.Json;
using System.Text.Json.Serialization; using System.Text.Json.Serialization;
using BigRegister.Api.Contracts; using BigRegister.Api.Contracts;
using BigRegister.Api.Data; using BigRegister.Api.Data;
using BigRegister.Domain.Authorization;
namespace BigRegister.Api.Zgw; namespace BigRegister.Api.Zgw;
@@ -32,11 +33,20 @@ public sealed class OpenZaakZaakSource(HttpClient http, ZgwTokenProvider tokens,
// whole cases read path async (endpoint + CasesAdmin + interface) if OpenZaak becomes the // whole cases read path async (endpoint + CasesAdmin + interface) if OpenZaak becomes the
// default and this blocking call shows up under load. // default and this blocking call shows up under load.
public IReadOnlyList<ApplicationSummaryDto> ListCases(DateTimeOffset now) => public IReadOnlyList<ApplicationSummaryDto> ListCases(DateTimeOffset now) =>
ListCasesAsync().GetAwaiter().GetResult(); ListCasesAsync(bsn: null, caller: null).GetAwaiter().GetResult();
private async Task<IReadOnlyList<ApplicationSummaryDto>> ListCasesAsync() /// <summary>WP-53: same read, filtered to one citizen's own zaken via ZGW's rol filter param
/// (see <see cref="ListCasesAsync"/>) — and minted with that citizen's identity, not the
/// system-level one <see cref="ListCases"/> uses.</summary>
public IReadOnlyList<ApplicationSummaryDto> ListMyCases(CallerIdentity caller, DateTimeOffset now) =>
ListCasesAsync(caller.Bsn, caller).GetAwaiter().GetResult();
private async Task<IReadOnlyList<ApplicationSummaryDto>> ListCasesAsync(string? bsn, CallerIdentity? caller)
{ {
var zaken = await GetAllAsync<ZgwZaak>($"{options.ZrcBaseUrl}/zaken"); var url = $"{options.ZrcBaseUrl}/zaken";
if (bsn is not null)
url += $"?rol__betrokkeneIdentificatie__natuurlijkPersoon__inpBsn={Uri.EscapeDataString(bsn)}";
var zaken = await GetAllAsync<ZgwZaak>(url, caller);
var labels = new Dictionary<string, string>(); var labels = new Dictionary<string, string>();
var result = new List<ApplicationSummaryDto>(zaken.Count); var result = new List<ApplicationSummaryDto>(zaken.Count);
foreach (var z in zaken) foreach (var z in zaken)
@@ -49,13 +59,13 @@ public sealed class OpenZaakZaakSource(HttpClient http, ZgwTokenProvider tokens,
} }
/// <summary>Follow the <c>next</c> links, accumulating every page's results.</summary> /// <summary>Follow the <c>next</c> links, accumulating every page's results.</summary>
private async Task<IReadOnlyList<T>> GetAllAsync<T>(string url) private async Task<IReadOnlyList<T>> GetAllAsync<T>(string url, CallerIdentity? caller = null)
{ {
var all = new List<T>(); var all = new List<T>();
string? next = url; string? next = url;
while (next is not null) while (next is not null)
{ {
var page = await zgw.GetAsync<ZgwPage<T>>(next); var page = await zgw.GetAsync<ZgwPage<T>>(next, caller);
all.AddRange(page.Results); all.AddRange(page.Results);
next = page.Next; next = page.Next;
} }
@@ -80,10 +90,10 @@ public sealed class OpenZaakZaakSource(HttpClient http, ZgwTokenProvider tokens,
/// already marked Submitted locally (ApplicationStore.Submit already ran) but has no zaak. /// already marked Submitted locally (ApplicationStore.Submit already ran) but has no zaak.
/// Acceptable for a first write slice against a demo backend; a production arc would need a /// Acceptable for a first write slice against a demo backend; a production arc would need a
/// retry/reconciliation story (or an outbox) before this dual-write can be trusted. /// retry/reconciliation story (or an outbox) before this dual-write can be trusted.
public (string Referentie, AanvraagStatusDto Status, string? ZaakUrl) CreateZaak(Aanvraag aanvraag, DateTimeOffset now) => public (string Referentie, AanvraagStatusDto Status, string? ZaakUrl) CreateZaak(Aanvraag aanvraag, DateTimeOffset now, CallerIdentity caller) =>
CreateZaakAsync(aanvraag, now).GetAwaiter().GetResult(); CreateZaakAsync(aanvraag, now, caller).GetAwaiter().GetResult();
private async Task<(string Referentie, AanvraagStatusDto Status, string? ZaakUrl)> CreateZaakAsync(Aanvraag aanvraag, DateTimeOffset now) private async Task<(string Referentie, AanvraagStatusDto Status, string? ZaakUrl)> CreateZaakAsync(Aanvraag aanvraag, DateTimeOffset now, CallerIdentity caller)
{ {
if (!options.ZaaktypeUrls.TryGetValue(aanvraag.Type, out var zaaktypeUrl)) if (!options.ZaaktypeUrls.TryGetValue(aanvraag.Type, out var zaaktypeUrl))
throw new InvalidOperationException( throw new InvalidOperationException(
@@ -95,11 +105,11 @@ public sealed class OpenZaakZaakSource(HttpClient http, ZgwTokenProvider tokens,
VerantwoordelijkeOrganisatie: options.VerantwoordelijkeOrganisatie, VerantwoordelijkeOrganisatie: options.VerantwoordelijkeOrganisatie,
Startdatum: DateOnly.FromDateTime(now.UtcDateTime), Startdatum: DateOnly.FromDateTime(now.UtcDateTime),
Identificatie: aanvraag.Referentie Identificatie: aanvraag.Referentie
?? throw new InvalidOperationException("Aanvraag has no Referentie yet — submit it locally first."))); ?? throw new InvalidOperationException("Aanvraag has no Referentie yet — submit it locally first.")), caller);
var statustypeUrl = await FirstStatustypeUrlAsync(zaaktypeUrl); var statustypeUrl = await FirstStatustypeUrlAsync(zaaktypeUrl);
await zgw.PostAsync<JsonElement>($"{options.ZrcBaseUrl}/statussen", await zgw.PostAsync<JsonElement>($"{options.ZrcBaseUrl}/statussen",
new CreateStatusRequest(zaak.Url, statustypeUrl, now)); new CreateStatusRequest(zaak.Url, statustypeUrl, now), caller);
var roltypeUrl = await FirstInitiatorRoltypeUrlAsync(zaaktypeUrl); var roltypeUrl = await FirstInitiatorRoltypeUrlAsync(zaaktypeUrl);
await zgw.PostAsync<JsonElement>($"{options.ZrcBaseUrl}/rollen", new CreateRolRequest( await zgw.PostAsync<JsonElement>($"{options.ZrcBaseUrl}/rollen", new CreateRolRequest(
@@ -107,7 +117,7 @@ public sealed class OpenZaakZaakSource(HttpClient http, ZgwTokenProvider tokens,
BetrokkeneType: "natuurlijk_persoon", BetrokkeneType: "natuurlijk_persoon",
Roltype: roltypeUrl, Roltype: roltypeUrl,
Roltoelichting: "Initiator", Roltoelichting: "Initiator",
BetrokkeneIdentificatie: new BetrokkeneIdentificatie(aanvraag.Owner))); BetrokkeneIdentificatie: new BetrokkeneIdentificatie(aanvraag.Owner)), caller);
return (zaak.Identificatie, ZgwZaakMapper.ToCreatedStatusDto(zaak.Identificatie), zaak.Url); return (zaak.Identificatie, ZgwZaakMapper.ToCreatedStatusDto(zaak.Identificatie), zaak.Url);
} }
@@ -1,5 +1,6 @@
using System.Net.Http.Headers; using System.Net.Http.Headers;
using System.Net.Http.Json; using System.Net.Http.Json;
using BigRegister.Domain.Authorization;
namespace BigRegister.Api.Zgw; namespace BigRegister.Api.Zgw;
@@ -7,33 +8,35 @@ namespace BigRegister.Api.Zgw;
/// Shared GET/POST-with-Bearer-JWT plumbing for the ZGW source classes. Factored out of /// Shared GET/POST-with-Bearer-JWT plumbing for the ZGW source classes. Factored out of
/// <see cref="OpenZaakZaakSource"/> once <c>OpenZaakDocumentSource</c> (WP-51) needed the /// <see cref="OpenZaakZaakSource"/> once <c>OpenZaakDocumentSource</c> (WP-51) needed the
/// identical auth + JSON + error-handling boilerplate — every ZGW call mints a fresh token /// identical auth + JSON + error-handling boilerplate — every ZGW call mints a fresh token
/// (<see cref="ZgwTokenProvider"/>) and expects/returns JSON. /// (<see cref="ZgwTokenProvider"/>) and expects/returns JSON. <paramref name="caller"/> is
/// optional (WP-53): omitted for calls not tied to one citizen (metadata lookups, the admin
/// cross-owner list), which mint with the BFF's own system identity instead.
/// </summary> /// </summary>
internal sealed class ZgwHttpClient(HttpClient http, ZgwTokenProvider tokens) internal sealed class ZgwHttpClient(HttpClient http, ZgwTokenProvider tokens)
{ {
public async Task<T> GetAsync<T>(string url) public async Task<T> GetAsync<T>(string url, CallerIdentity? caller = null)
{ {
using var req = new HttpRequestMessage(HttpMethod.Get, url); using var req = new HttpRequestMessage(HttpMethod.Get, url);
Authorize(req); Authorize(req, caller);
using var res = await http.SendAsync(req); using var res = await http.SendAsync(req);
res.EnsureSuccessStatusCode(); res.EnsureSuccessStatusCode();
return (await res.Content.ReadFromJsonAsync<T>()) return (await res.Content.ReadFromJsonAsync<T>())
?? throw new InvalidOperationException($"ZGW GET {url} returned null body."); ?? throw new InvalidOperationException($"ZGW GET {url} returned null body.");
} }
public async Task<T> PostAsync<T>(string url, object body) public async Task<T> PostAsync<T>(string url, object body, CallerIdentity? caller = null)
{ {
using var req = new HttpRequestMessage(HttpMethod.Post, url) { Content = JsonContent.Create(body) }; using var req = new HttpRequestMessage(HttpMethod.Post, url) { Content = JsonContent.Create(body) };
Authorize(req); Authorize(req, caller);
using var res = await http.SendAsync(req); using var res = await http.SendAsync(req);
res.EnsureSuccessStatusCode(); res.EnsureSuccessStatusCode();
return (await res.Content.ReadFromJsonAsync<T>()) return (await res.Content.ReadFromJsonAsync<T>())
?? throw new InvalidOperationException($"ZGW POST {url} returned null body."); ?? throw new InvalidOperationException($"ZGW POST {url} returned null body.");
} }
private void Authorize(HttpRequestMessage req) private void Authorize(HttpRequestMessage req, CallerIdentity? caller)
{ {
req.Headers.Authorization = new AuthenticationHeaderValue("Bearer", tokens.Mint()); req.Headers.Authorization = new AuthenticationHeaderValue("Bearer", caller is null ? tokens.Mint() : tokens.Mint(caller));
req.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json")); req.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json"));
} }
} }
@@ -1,6 +1,7 @@
using System.Security.Cryptography; using System.Security.Cryptography;
using System.Text; using System.Text;
using System.Text.Json; using System.Text.Json;
using BigRegister.Domain.Authorization;
namespace BigRegister.Api.Zgw; namespace BigRegister.Api.Zgw;
@@ -18,7 +19,17 @@ namespace BigRegister.Api.Zgw;
/// </summary> /// </summary>
public sealed class ZgwTokenProvider(ZgwOptions options) public sealed class ZgwTokenProvider(ZgwOptions options)
{ {
public string Mint() /// <summary>System-level identity (this BFF acting as itself) — for calls not tied to one
/// specific citizen (e.g. the admin cross-owner <c>ListCases</c>).</summary>
public string Mint() => MintCore(options.UserId, options.UserRepresentation);
/// <summary>Per-request variant (WP-53): the ZGW audit trail (<c>user_id</c>/
/// <c>user_representation</c>) reflects the acting citizen instead of this BFF's static
/// config identity, for any call made on a specific citizen's behalf (create zaak, upload,
/// link, citizen-scoped list).</summary>
public string Mint(CallerIdentity caller) => MintCore(caller.Bsn, caller.DisplayName);
private string MintCore(string userId, string userRepresentation)
{ {
var header = new { alg = "HS256", typ = "JWT" }; var header = new { alg = "HS256", typ = "JWT" };
var payload = new var payload = new
@@ -26,8 +37,8 @@ public sealed class ZgwTokenProvider(ZgwOptions options)
iss = options.ClientId, iss = options.ClientId,
iat = DateTimeOffset.UtcNow.ToUnixTimeSeconds(), iat = DateTimeOffset.UtcNow.ToUnixTimeSeconds(),
client_id = options.ClientId, client_id = options.ClientId,
user_id = options.UserId, user_id = userId,
user_representation = options.UserRepresentation, user_representation = userRepresentation,
}; };
var signingInput = $"{Encode(header)}.{Encode(payload)}"; var signingInput = $"{Encode(header)}.{Encode(payload)}";
@@ -137,6 +137,41 @@ public class ApplicationTests(TestWebApplicationFactory factory) : IClassFixture
Assert.Equal(HttpStatusCode.Conflict, (await _client.DeleteAsync($"/api/v1/applications/{a.Id}")).StatusCode); Assert.Equal(HttpStatusCode.Conflict, (await _client.DeleteAsync($"/api/v1/applications/{a.Id}")).StatusCode);
} }
// --- WP-53: citizen-scoping — GET /applications must never leak across identities. ---
[Fact]
public async Task Applications_are_scoped_to_the_caller_bsn()
{
var mine = await Create("intake");
var createOther = new HttpRequestMessage(HttpMethod.Post, "/api/v1/applications")
{
Content = JsonContent.Create(new { type = "intake" }),
Headers = { { "X-Subject", "999888777" } },
};
var otherRes = await _client.SendAsync(createOther);
Assert.Equal(HttpStatusCode.Created, otherRes.StatusCode);
var other = (await otherRes.Content.ReadFromJsonAsync<ApplicationDetailDto>())!;
try
{
var listOther = new HttpRequestMessage(HttpMethod.Get, "/api/v1/applications") { Headers = { { "X-Subject", "999888777" } } };
var theirCases = (await (await _client.SendAsync(listOther)).Content.ReadFromJsonAsync<List<ApplicationSummaryDto>>())!;
Assert.Contains(theirCases, c => c.Id == other.Id);
Assert.DoesNotContain(theirCases, c => c.Id == mine.Id);
var myCases = (await List())!;
Assert.Contains(myCases, c => c.Id == mine.Id);
Assert.DoesNotContain(myCases, c => c.Id == other.Id);
}
finally
{
var deleteOther = new HttpRequestMessage(HttpMethod.Delete, $"/api/v1/applications/{other.Id}") { Headers = { { "X-Subject", "999888777" } } };
await _client.SendAsync(deleteOther);
await _client.DeleteAsync($"/api/v1/applications/{mine.Id}");
}
}
// --- Auto-approval is computed on read: exercise the window boundary without waiting. --- // --- Auto-approval is computed on read: exercise the window boundary without waiting. ---
private static Aanvraag Accepted(bool autoApprovable) => new() private static Aanvraag Accepted(bool autoApprovable) => new()
@@ -1,5 +1,6 @@
using BigRegister.Api.Data; using BigRegister.Api.Data;
using BigRegister.Api.Zgw; using BigRegister.Api.Zgw;
using BigRegister.Domain.Authorization;
namespace BigRegister.Tests; namespace BigRegister.Tests;
@@ -26,6 +27,8 @@ public class OpenZaakDocumentSourceTests
InformatieobjecttypeUrls = new() { ["identiteit"] = InformatieobjecttypeUrl }, InformatieobjecttypeUrls = new() { ["identiteit"] = InformatieobjecttypeUrl },
}; };
private static readonly CallerIdentity Caller = new("111222333", "Dr. Test", PrincipalRole.Drafter);
[Fact] [Fact]
public void Upload_registers_an_eio_in_drc_and_persists_its_url_locally() public void Upload_registers_an_eio_in_drc_and_persists_its_url_locally()
{ {
@@ -39,7 +42,7 @@ public class OpenZaakDocumentSourceTests
var source = new OpenZaakDocumentSource(new HttpClient(handler), new ZgwTokenProvider(options), options); var source = new OpenZaakDocumentSource(new HttpClient(handler), new ZgwTokenProvider(options), options);
var response = source.Upload("local-1", "identiteit", "registratie", "paspoort.pdf", "application/pdf", var response = source.Upload("local-1", "identiteit", "registratie", "paspoort.pdf", "application/pdf",
"%PDF-1.4 fake"u8.ToArray(), "111222333"); "%PDF-1.4 fake"u8.ToArray(), Caller);
Assert.Equal("local-1", response.LocalId); Assert.Equal("local-1", response.LocalId);
Assert.NotEmpty(response.DocumentId); Assert.NotEmpty(response.DocumentId);
@@ -65,7 +68,7 @@ public class OpenZaakDocumentSourceTests
var source = new OpenZaakDocumentSource(new HttpClient(handler), new ZgwTokenProvider(options), options); var source = new OpenZaakDocumentSource(new HttpClient(handler), new ZgwTokenProvider(options), options);
Assert.Throws<InvalidOperationException>(() => Assert.Throws<InvalidOperationException>(() =>
source.Upload("local-1", "unknown-category", "registratie", "f.pdf", "application/pdf", [1, 2, 3], "111222333")); source.Upload("local-1", "unknown-category", "registratie", "f.pdf", "application/pdf", [1, 2, 3], Caller));
} }
[Fact] [Fact]
@@ -75,7 +78,7 @@ public class OpenZaakDocumentSourceTests
var uploadHandler = new ZgwStubHandler(url => var uploadHandler = new ZgwStubHandler(url =>
"""{ "url": "https://oz.example/documenten/api/v1/enkelvoudiginformatieobjecten/eio-1" }"""); """{ "url": "https://oz.example/documenten/api/v1/enkelvoudiginformatieobjecten/eio-1" }""");
var uploader = new OpenZaakDocumentSource(new HttpClient(uploadHandler), new ZgwTokenProvider(options), options); var uploader = new OpenZaakDocumentSource(new HttpClient(uploadHandler), new ZgwTokenProvider(options), options);
var doc = uploader.Upload("local-1", "identiteit", "registratie", "paspoort.pdf", "application/pdf", [1, 2, 3], "111222333"); var doc = uploader.Upload("local-1", "identiteit", "registratie", "paspoort.pdf", "application/pdf", [1, 2, 3], Caller);
var linkHandler = new ZgwStubHandler(url => url switch var linkHandler = new ZgwStubHandler(url => url switch
{ {
@@ -84,14 +87,14 @@ public class OpenZaakDocumentSourceTests
}); });
var linker = new OpenZaakDocumentSource(new HttpClient(linkHandler), new ZgwTokenProvider(options), options); var linker = new OpenZaakDocumentSource(new HttpClient(linkHandler), new ZgwTokenProvider(options), options);
linker.LinkToZaak([doc.DocumentId], $"{ZrcBase}/zaken/uuid-1"); linker.LinkToZaak([doc.DocumentId], $"{ZrcBase}/zaken/uuid-1", Caller);
var body = linkHandler.BodyOf($"{ZrcBase}/zaakinformatieobjecten"); var body = linkHandler.BodyOf($"{ZrcBase}/zaakinformatieobjecten");
Assert.Contains($"{ZrcBase}/zaken/uuid-1", body); Assert.Contains($"{ZrcBase}/zaken/uuid-1", body);
Assert.Contains("eio-1", body); Assert.Contains("eio-1", body);
// Local link also happened (dual-write) — the document is now Linked (delete blocked). // Local link also happened (dual-write) — the document is now Linked (delete blocked).
Assert.Equal(DocumentStore.DeleteResult.Linked, DocumentStore.DeleteOwned(doc.DocumentId, "111222333")); Assert.Equal(DocumentStore.DeleteResult.Linked, DocumentStore.DeleteOwned(doc.DocumentId, Caller.Bsn));
} }
[Fact] [Fact]
@@ -101,7 +104,7 @@ public class OpenZaakDocumentSourceTests
var handler = new ZgwStubHandler(url => throw new InvalidOperationException($"no HTTP call expected, got {url}")); var handler = new ZgwStubHandler(url => throw new InvalidOperationException($"no HTTP call expected, got {url}"));
var source = new OpenZaakDocumentSource(new HttpClient(handler), new ZgwTokenProvider(options), options); var source = new OpenZaakDocumentSource(new HttpClient(handler), new ZgwTokenProvider(options), options);
source.LinkToZaak(["some-document-id"], zaakUrl: null); source.LinkToZaak(["some-document-id"], zaakUrl: null, Caller);
Assert.Empty(handler.Requests); Assert.Empty(handler.Requests);
} }
@@ -1,5 +1,6 @@
using BigRegister.Api.Data; using BigRegister.Api.Data;
using BigRegister.Api.Zgw; using BigRegister.Api.Zgw;
using BigRegister.Domain.Authorization;
namespace BigRegister.Tests; namespace BigRegister.Tests;
@@ -58,6 +59,26 @@ public class OpenZaakZaakSourceTests
Assert.All(handler.AuthSchemes, s => Assert.Equal("Bearer", s)); Assert.All(handler.AuthSchemes, s => Assert.Equal("Bearer", s));
} }
[Fact]
public void ListMyCases_filters_by_the_callers_bsn()
{
var handler = new ZgwStubHandler(url => url switch
{
_ when url.StartsWith($"{ZrcBase}/zaken") => """{ "count": 0, "next": null, "results": [] }""",
_ => throw new InvalidOperationException($"unexpected ZGW GET {url}"),
});
var options = new ZgwOptions { ZrcBaseUrl = ZrcBase, ZtcBaseUrl = ZtBase, ClientId = "c", Secret = "s" };
var source = new OpenZaakZaakSource(new HttpClient(handler), new ZgwTokenProvider(options), options);
var caller = new CallerIdentity("111222333", "Dr. Test", PrincipalRole.Drafter);
source.ListMyCases(caller, DateTimeOffset.UtcNow);
Assert.Single(handler.Requests, r =>
r.StartsWith($"{ZrcBase}/zaken?") &&
r.Contains("rol__betrokkeneIdentificatie__natuurlijkPersoon__inpBsn=111222333"));
}
[Fact] [Fact]
public void CreateZaak_posts_zaak_status_and_rol_and_maps_the_result_back() public void CreateZaak_posts_zaak_status_and_rol_and_maps_the_result_back()
{ {
@@ -101,7 +122,8 @@ public class OpenZaakZaakSourceTests
Referentie = "BIG-2026-000123", Referentie = "BIG-2026-000123",
}; };
var (referentie, status, zaakUrl) = source.CreateZaak(aanvraag, new DateTimeOffset(2026, 7, 28, 12, 0, 0, TimeSpan.Zero)); var caller = new CallerIdentity(aanvraag.Owner, "Dr. Test", PrincipalRole.Drafter);
var (referentie, status, zaakUrl) = source.CreateZaak(aanvraag, new DateTimeOffset(2026, 7, 28, 12, 0, 0, TimeSpan.Zero), caller);
Assert.Equal("BIG-2026-000123", referentie); Assert.Equal("BIG-2026-000123", referentie);
Assert.Equal("InBehandeling", status.Tag); Assert.Equal("InBehandeling", status.Tag);
@@ -133,7 +155,8 @@ public class OpenZaakZaakSourceTests
var handler = new ZgwStubHandler(url => throw new InvalidOperationException($"no HTTP call expected, got {url}")); var handler = new ZgwStubHandler(url => throw new InvalidOperationException($"no HTTP call expected, got {url}"));
var source = new OpenZaakZaakSource(new HttpClient(handler), new ZgwTokenProvider(options), options); var source = new OpenZaakZaakSource(new HttpClient(handler), new ZgwTokenProvider(options), options);
var aanvraag = new Aanvraag { Id = "a1", Type = "unknown-type", Owner = "111222333", Referentie = "BIG-2026-000123" }; var aanvraag = new Aanvraag { Id = "a1", Type = "unknown-type", Owner = "111222333", Referentie = "BIG-2026-000123" };
var caller = new CallerIdentity(aanvraag.Owner, "Dr. Test", PrincipalRole.Drafter);
Assert.Throws<InvalidOperationException>(() => source.CreateZaak(aanvraag, DateTimeOffset.UtcNow)); Assert.Throws<InvalidOperationException>(() => source.CreateZaak(aanvraag, DateTimeOffset.UtcNow, caller));
} }
} }
@@ -0,0 +1,43 @@
using BigRegister.Api.Data;
using BigRegister.Domain.Authorization;
using Microsoft.AspNetCore.Http;
namespace BigRegister.Tests;
/// WP-53: the dev stub identity provider — role from X-Role (unchanged behaviour), subject BSN
/// from the new X-Subject header, defaulting to the single seeded citizen so every existing
/// request (none of which send X-Subject) resolves exactly as before this WP.
public class StubIdentityProviderTests
{
private static CallerIdentity Resolve(string? role, string? subject)
{
var ctx = new DefaultHttpContext();
if (role is not null) ctx.Request.Headers["X-Role"] = role;
if (subject is not null) ctx.Request.Headers["X-Subject"] = subject;
return new StubIdentityProvider().Resolve(ctx);
}
[Fact]
public void No_headers_resolves_to_the_seeded_citizen_as_a_drafter()
{
var caller = Resolve(role: null, subject: null);
Assert.Equal(DocumentStore.DemoOwner, caller.Bsn);
Assert.Equal(PrincipalRole.Drafter, caller.Role);
}
[Theory]
[InlineData("approver", PrincipalRole.Approver)]
[InlineData("admin", PrincipalRole.Admin)]
[InlineData("something-unknown", PrincipalRole.Drafter)]
public void X_role_maps_to_the_principal_role(string header, PrincipalRole expected)
{
Assert.Equal(expected, Resolve(header, subject: null).Role);
}
[Fact]
public void X_subject_overrides_the_default_bsn()
{
var caller = Resolve(role: null, subject: "999888777");
Assert.Equal("999888777", caller.Bsn);
}
}
@@ -2,6 +2,7 @@ using System.Security.Cryptography;
using System.Text; using System.Text;
using System.Text.Json; using System.Text.Json;
using BigRegister.Api.Zgw; using BigRegister.Api.Zgw;
using BigRegister.Domain.Authorization;
namespace BigRegister.Tests; namespace BigRegister.Tests;
@@ -42,6 +43,19 @@ public class ZgwTokenProviderTests
Assert.InRange(iat, DateTimeOffset.UtcNow.ToUnixTimeSeconds() - 5, DateTimeOffset.UtcNow.ToUnixTimeSeconds() + 5); Assert.InRange(iat, DateTimeOffset.UtcNow.ToUnixTimeSeconds() - 5, DateTimeOffset.UtcNow.ToUnixTimeSeconds() + 5);
} }
[Fact]
public void Mint_with_a_caller_carries_that_citizen_not_the_static_config_identity()
{
var caller = new CallerIdentity("111222333", "Dr. Citizen", PrincipalRole.Drafter);
var token = new ZgwTokenProvider(Options).Mint(caller);
var payload = JsonSerializer.Deserialize<JsonElement>(Decode(token.Split('.')[1]));
Assert.Equal("111222333", payload.GetProperty("user_id").GetString());
Assert.Equal("Dr. Citizen", payload.GetProperty("user_representation").GetString());
// iss/client_id stay the BFF's own registered client id either way.
Assert.Equal("big-register", payload.GetProperty("client_id").GetString());
}
[Fact] [Fact]
public void Signature_verifies_with_the_shared_secret() public void Signature_verifies_with_the_shared_secret()
{ {
+1 -1
View File
@@ -103,7 +103,7 @@ for its existing violations, so every WP ends green.
| [WP-50](WP-50-openzaak-create-zaak.md) | OpenZaak create-zaak (first write slice) | 9 · OpenZaak/ZGW | done | | [WP-50](WP-50-openzaak-create-zaak.md) | OpenZaak create-zaak (first write slice) | 9 · OpenZaak/ZGW | done |
| [WP-51](WP-51-openzaak-documenten.md) | OpenZaak Documenten (DRC) upload + zaak link | 9 · OpenZaak/ZGW | done | | [WP-51](WP-51-openzaak-documenten.md) | OpenZaak Documenten (DRC) upload + zaak link | 9 · OpenZaak/ZGW | done |
| [WP-52](WP-52-openzaak-notificaties.md) | OpenZaak Notificaties (NRC) live status via webhook | 9 · OpenZaak/ZGW | done | | [WP-52](WP-52-openzaak-notificaties.md) | OpenZaak Notificaties (NRC) live status via webhook | 9 · OpenZaak/ZGW | done |
| [WP-53](WP-53-inbound-identity-and-citizen-scoping.md) | Inbound identity seam + citizen-scoping (per-request BSN, ZGW audit claims) | 9 · OpenZaak/ZGW | todo | | [WP-53](WP-53-inbound-identity-and-citizen-scoping.md) | Inbound identity seam + citizen-scoping (per-request BSN, ZGW audit claims) | 9 · OpenZaak/ZGW | done |
| [WP-54](WP-54-openzaak-integration-harness.md) | Docker OpenZaak integration-test harness (opt-in, live round-trip) | 9 · OpenZaak/ZGW | todo | | [WP-54](WP-54-openzaak-integration-harness.md) | Docker OpenZaak integration-test harness (opt-in, live round-trip) | 9 · OpenZaak/ZGW | todo |
Sequencing dependencies (stated in the WPs too): 01 before 1015 (axe covers story churn); Sequencing dependencies (stated in the WPs too): 01 before 1015 (axe covers story churn);
@@ -1,6 +1,6 @@
# WP-53 — Inbound identity + citizen-scoping (the ZGW auth seam) # WP-53 — Inbound identity + citizen-scoping (the ZGW auth seam)
Status: todo Status: done
Phase: 9 — OpenZaak / ZGW integration Phase: 9 — OpenZaak / ZGW integration
## Why ## Why
@@ -94,16 +94,18 @@ param `rol__betrokkeneIdentificatie__natuurlijkPersoon__inpBsn=<bsn>` on `GET {Z
## Acceptance criteria ## Acceptance criteria
- [ ] No `DocumentStore.DemoOwner` reference remains in request handling (grep clean); ownership - [x] No `DocumentStore.DemoOwner` reference remains in request handling (grep clean); ownership
comes from the resolved identity. comes from the resolved identity.
- [ ] ZGW JWT carries the acting citizen's `user_id`/`user_representation` (test-verified). - [x] ZGW JWT carries the acting citizen's `user_id`/`user_representation` (test-verified).
- [ ] A citizen read returns only that BSN's zaken (local + ZGW-stub tests); admin read unchanged. - [x] A citizen read returns only that BSN's zaken (local + ZGW-stub tests); admin read unchanged.
- [ ] `dotnet test` green; `npm run ci` green with **no api-client drift** (FE contract intact). - [x] `dotnet test` green; `npm run ci` green with **no api-client drift** (FE contract intact).
## Verification ## Verification
`cd backend && dotnet test`; manual: `X-Role`/`X-Subject` (or `?role=`) still switches identity `cd backend && dotnet test` (159/159, incl. 8 new); `dotnet format --verify-no-changes` clean;
offline; with `Zgw:Enabled=true` (WP-54 harness) a citizen sees only their zaken. `npm run ci` green (no api-client drift). Manual: `X-Role`/`X-Subject` still switch identity
offline (no header → the seeded citizen, drafter); with `Zgw:Enabled=true` (WP-54 harness) a
citizen would see only their zaken via the new `rol__…__inpBsn` filter.
## Out of scope ## Out of scope
@@ -112,6 +114,27 @@ session sync (CLAUDE.md out-of-scope list).
## Risks ## Risks
- Missing a `DemoOwner` call site → a citizen sees another's data. Mitigate: grep gate in the - Missing a `DemoOwner` call site → a citizen sees another's data. Mitigated: grep gate (clean)
acceptance criteria + a test that two identities don't see each other's cases. + `ApplicationTests.Applications_are_scoped_to_the_caller_bsn` (two `X-Subject` identities,
- ZGW rol filter param name is exact and version-sensitive; assert it in the stub-handler test. HTTP end-to-end) proving neither sees the other's cases.
- ZGW rol filter param name is exact and version-sensitive; asserted in
`OpenZaakZaakSourceTests.ListMyCases_filters_by_the_callers_bsn`.
## Session notes
Built as designed — no premise in the Decisions/Context block turned out stale. One
implementation choice not spelled out in the WP: `Authz.ResolvePrincipal(HttpContext ctx)` kept
its exact signature (now `new(ctx.Caller().Role)` instead of re-reading `X-Role` itself), so
none of its ~15 call sites needed touching — "flow it to Authz.ResolvePrincipal" didn't require
threading `CallerIdentity` through every endpoint that resolves a `Principal`. `ZgwTokenProvider`
grew a `Mint(CallerIdentity)` overload alongside the existing parameterless `Mint()` (kept for
calls not tied to one citizen — the admin cross-owner `ListCases`, and Catalogi metadata lookups)
rather than replacing it outright, so `ZgwOptions.UserId`/`UserRepresentation` stay meaningful as
the BFF's own system identity. `IZaakSource`/`IDocumentSource` gained an explicit `CallerIdentity`
parameter on every citizen-scoped method (`ListMyCases`, `CreateZaak`, `Upload`, `LinkToZaak`)
rather than resolving it ambiently via `IHttpContextAccessor` — kept it unit-testable without any
DI/HttpContext ceremony (see `StubIdentityProviderTests`, the `ZgwTokenProviderTests` addition).
`GET /applications` (the citizen's own dashboard list) is now routed through
`IZaakSource.ListMyCases` instead of calling `ApplicationStore` directly — closing the exact gap
`openzaak-integration.md`'s ACL caveat used to flag for that endpoint; under `Zgw:Enabled=true` it
would now source from OpenZaak (BSN-filtered) like `/admin/cases` already did.
+43 -10
View File
@@ -55,7 +55,7 @@ precisely what was just computed; under OpenZaak, three calls happen in order:
(`statustypen?zaaktype=...`, lowest `volgnummer`); marks the zaak as freshly opened. (`statustypen?zaaktype=...`, lowest `volgnummer`); marks the zaak as freshly opened.
3. **POST rol** (`{ZrcBaseUrl}/rollen`) — `roltype` resolved via a Catalogi GET 3. **POST rol** (`{ZrcBaseUrl}/rollen`) — `roltype` resolved via a Catalogi GET
(`roltypen?zaaktype=...&omschrijvingGeneriek=initiator`); `betrokkeneIdentificatie.inpBsn` (`roltypen?zaaktype=...&omschrijvingGeneriek=initiator`); `betrokkeneIdentificatie.inpBsn`
set to the aanvraag's owner (BSN) — the current stand-in for real identity (WP-53). set to the aanvraag's owner (BSN) — the acting citizen resolved by the identity seam (WP-53).
The created zaak's `identificatie` becomes the returned `Referentie`; its status maps to the The created zaak's `identificatie` becomes the returned `Referentie`; its status maps to the
same coarse `InBehandeling` shape `ZgwZaakMapper` already uses for a freshly-opened zaak same coarse `InBehandeling` shape `ZgwZaakMapper` already uses for a freshly-opened zaak
@@ -140,6 +140,39 @@ pointing at this BFF's public URL:
} }
``` ```
## Identity — the acting citizen (WP-53)
Everything above used to hardcode a single owner (`DocumentStore.DemoOwner`) and a single static
ZGW audit identity (`ZgwOptions.UserId`/`UserRepresentation`). WP-53 replaced both with one
per-request `CallerIdentity` (subject BSN + display name + role, `Domain/Authorization/
CallerIdentity.cs`):
- **Resolution**: an `IIdentityProvider` runs once per request (middleware in `Program.cs`,
right after the correlation-id middleware) into `HttpContext.Items`, read back everywhere via
`ctx.Caller()`. `StubIdentityProvider` (the only implementation today, **not a security
boundary**) reads the existing `X-Role` header (unchanged — mirrors the FE's `?role=` toggle)
plus a new `X-Subject` header for the BSN, defaulting to the single seeded citizen — so every
request that doesn't send `X-Subject` (which is every request today; the FE never sends it)
behaves exactly as before this WP. A production provider swaps in real OIDC/DigiD claims
without touching a single consumer.
- **`Authz.ResolvePrincipal(ctx)` kept its exact signature** — it now reads `ctx.Caller().Role`
instead of the header directly, so its ~15 call sites across `Program.cs` needed no changes.
- **Ownership**: every endpoint that used to pass `DocumentStore.DemoOwner` to a store
(`ApplicationStore`, `DocumentStore`, `BriefStore`) now passes `ctx.Caller().Bsn`.
- **The ZGW JWT** (`ZgwTokenProvider`) grew a `Mint(CallerIdentity)` overload alongside the
original parameterless `Mint()`: citizen-scoped calls (create-zaak, upload, zaak-link, the
citizen's own case list) mint with the caller's BSN/name as `user_id`/`user_representation`;
calls not tied to one citizen (the admin cross-owner list, Catalogi metadata lookups) keep
minting with the BFF's own system identity from `ZgwOptions`. `ZgwHttpClient.GetAsync`/
`PostAsync` take an optional `CallerIdentity?` that picks which `Mint` overload runs.
- **Citizen-scoped reads**: `IZaakSource` gained `ListMyCases(CallerIdentity, now)` alongside the
existing admin-only `ListCases(now)`. `LocalZaakSource` filters `ApplicationStore.List(bsn)`
(unchanged local behaviour); `OpenZaakZaakSource` appends ZGW's
`rol__betrokkeneIdentificatie__natuurlijkPersoon__inpBsn=<bsn>` query filter to `GET
{ZrcBaseUrl}/zaken`. `GET /applications` (the citizen's own dashboard) now routes through this
instead of calling `ApplicationStore` directly — the last "reads a static store directly" gap
the ACL caveat below used to flag for a citizen-facing endpoint.
## The five ZGW APIs (context for later slices) ## The five ZGW APIs (context for later slices)
| API | Component | Used by | | API | Component | Used by |
@@ -233,19 +266,19 @@ Principles this demonstrates:
comment in `ZgwZaakMapper` show where the ACL is deliberately thin — an ACL need not be comment in `ZgwZaakMapper` show where the ACL is deliberately thin — an ACL need not be
complete on day one, but its shortcuts should be visible. complete on day one, but its shortcuts should be visible.
Caveat: `IZaakSource` covers the cases **read + create** path (WP-49/50), `IDocumentSource` Caveat: `IZaakSource` covers the cases **read (admin + citizen-scoped) + create** path
covers **upload + zaak-link** (WP-51), and the inbound `POST /zgw/notificaties` webhook (WP-49/50/53), `IDocumentSource` covers **upload + zaak-link** (WP-51), the inbound
(WP-52) closes the read/write/document/notify arc. Other BFF endpoints still read `POST /zgw/notificaties` webhook (WP-52) closes the read/write/document/notify arc, and WP-53
`SeedData`/static stores directly — ACL-ready (the DTO seam exists) but not yet swappable. threaded a real per-request `CallerIdentity` through all of it (ownership + the ZGW audit
What's left in this arc is the two cross-cutting WPs production needs: **WP-53** (a real claims). Other BFF endpoints (reference data like `SeedData`'s BRP/DUO mimics) still read static
per-request identity seam + citizen-scoping — today the owner/BSN is stubbed) and **WP-54** (a stores directly — ACL-ready (the DTO seam exists) but not yet swappable, and not part of this
docker OpenZaak harness + opt-in integration test — today everything is fixture/mock-tested arc. What's left is **WP-54**: a docker OpenZaak harness + opt-in integration test — today
against no live instance). everything is fixture/mock-tested against no live instance.
## See also ## See also
- [ADR-0005 — OpenZaak behind the BFF](architecture/0005-openzaak-behind-bff.md) — the decision. - [ADR-0005 — OpenZaak behind the BFF](architecture/0005-openzaak-behind-bff.md) — the decision.
- [ADR-0001 — BFF-lite + decision DTOs](architecture/0001-bff-lite-decision-dtos.md) — why the FE doesn't change. - [ADR-0001 — BFF-lite + decision DTOs](architecture/0001-bff-lite-decision-dtos.md) — why the FE doesn't change.
- [WP-49](../project/backlog/WP-49-openzaak-zaken-read-seam.md) (this), WP-50/51 (CRUD arc so far), WP-52 (notificaties), WP-53/54 (identity seam + integration harness). - [WP-49](../project/backlog/WP-49-openzaak-zaken-read-seam.md) (this), WP-50/51 (CRUD arc so far), WP-52 (notificaties), WP-53 (identity seam + citizen-scoping), WP-54 (integration harness, open).
- `backend/src/BigRegister.Api/Zgw/` — the client; `Data/IZaakSource.cs`/`Data/IDocumentSource.cs` — the seams. - `backend/src/BigRegister.Api/Zgw/` — the client; `Data/IZaakSource.cs`/`Data/IDocumentSource.cs` — the seams.
- [ZGW standard (VNG)](https://vng-realisatie.github.io/gemma-zaken/) · [OpenZaak auth docs](https://open-zaak.readthedocs.io/en/stable/client-development/authentication.html). - [ZGW standard (VNG)](https://vng-realisatie.github.io/gemma-zaken/) · [OpenZaak auth docs](https://open-zaak.readthedocs.io/en/stable/client-development/authentication.html).