diff --git a/backend/src/BigRegister.Api/Data/IDocumentSource.cs b/backend/src/BigRegister.Api/Data/IDocumentSource.cs
index b19268a..2338836 100644
--- a/backend/src/BigRegister.Api/Data/IDocumentSource.cs
+++ b/backend/src/BigRegister.Api/Data/IDocumentSource.cs
@@ -1,4 +1,5 @@
using BigRegister.Api.Contracts;
+using BigRegister.Domain.Authorization;
namespace BigRegister.Api.Data;
@@ -14,15 +15,18 @@ namespace BigRegister.Api.Data;
public interface IDocumentSource
{
/// Store an uploaded file (already validated by DocumentRules) and return the
- /// existing DTO unchanged, whichever source is active.
+ /// existing DTO unchanged, whichever source is active.
+ /// (WP-53) is both the document's owner (DocumentStore's
+ /// ownership field) and, under the OpenZaak source, the identity minted into the ZGW JWT.
UploadResponse Upload(
string localId, string categoryId, string wizardId, string fileName, string contentType,
- byte[] content, string owner);
+ byte[] content, CallerIdentity caller);
/// Finalise a set of already-uploaded documents against a just-submitted aanvraag
/// (WP-50/51): local behaviour is exactly today's DocumentStore.Link; the OpenZaak
/// source additionally links each document (that has a DRC url) to the zaak, once
/// is known (null under the local , in
- /// which case there is nothing extra to link).
- void LinkToZaak(IReadOnlyList documentIds, string? zaakUrl);
+ /// which case there is nothing extra to link) — minted with 's
+ /// identity (WP-53).
+ void LinkToZaak(IReadOnlyList documentIds, string? zaakUrl, CallerIdentity caller);
}
diff --git a/backend/src/BigRegister.Api/Data/IZaakSource.cs b/backend/src/BigRegister.Api/Data/IZaakSource.cs
index 291a8db..ca64e37 100644
--- a/backend/src/BigRegister.Api/Data/IZaakSource.cs
+++ b/backend/src/BigRegister.Api/Data/IZaakSource.cs
@@ -1,4 +1,5 @@
using BigRegister.Api.Contracts;
+using BigRegister.Domain.Authorization;
namespace BigRegister.Api.Data;
@@ -16,9 +17,19 @@ namespace BigRegister.Api.Data;
///
public interface IZaakSource
{
- /// Every case, newest-first (the admin cross-owner list, WP-36).
+ /// Every case across every owner, newest-first (the admin cross-owner list,
+ /// WP-36) — cases:manage only, deliberately NOT citizen-scoped.
IReadOnlyList ListCases(DateTimeOffset now);
+ ///
+ /// Only 's own cases (WP-53) — the citizen-scoped counterpart of
+ /// , backing the citizen's own dashboard. The local source filters
+ /// ApplicationStore by owner (unchanged behaviour); the OpenZaak source adds ZGW's
+ /// rol__betrokkeneIdentificatie__natuurlijkPersoon__inpBsn query filter so a citizen
+ /// can never see another citizen's zaken.
+ ///
+ IReadOnlyList ListMyCases(CallerIdentity caller, DateTimeOffset now);
+
///
/// Register a just-submitted as a zaak (WP-50). The aanvraag is
/// already persisted locally (ApplicationStore.Submit 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
/// (+ status + rol) and maps the result back into the same shape, returning the zaak's URL
/// so the endpoint can persist it (, WP-51 needs it
- /// to later link documents to this zaak).
+ /// to later link documents to this zaak). (WP-53) is the acting
+ /// citizen — the ZGW JWT's audit claims reflect them, not a static config identity.
///
- (string Referentie, AanvraagStatusDto Status, string? ZaakUrl) CreateZaak(Aanvraag aanvraag, DateTimeOffset now);
+ (string Referentie, AanvraagStatusDto Status, string? ZaakUrl) CreateZaak(Aanvraag aanvraag, DateTimeOffset now, CallerIdentity caller);
}
diff --git a/backend/src/BigRegister.Api/Data/LocalDocumentSource.cs b/backend/src/BigRegister.Api/Data/LocalDocumentSource.cs
index 8356aae..c82c6a5 100644
--- a/backend/src/BigRegister.Api/Data/LocalDocumentSource.cs
+++ b/backend/src/BigRegister.Api/Data/LocalDocumentSource.cs
@@ -1,4 +1,5 @@
using BigRegister.Api.Contracts;
+using BigRegister.Domain.Authorization;
namespace BigRegister.Api.Data;
@@ -12,12 +13,12 @@ public sealed class LocalDocumentSource : IDocumentSource
{
public UploadResponse Upload(
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);
}
- public void LinkToZaak(IReadOnlyList documentIds, string? zaakUrl) =>
+ public void LinkToZaak(IReadOnlyList documentIds, string? zaakUrl, CallerIdentity caller) =>
DocumentStore.Link(documentIds);
}
diff --git a/backend/src/BigRegister.Api/Data/LocalZaakSource.cs b/backend/src/BigRegister.Api/Data/LocalZaakSource.cs
index c58b7b6..be4eadd 100644
--- a/backend/src/BigRegister.Api/Data/LocalZaakSource.cs
+++ b/backend/src/BigRegister.Api/Data/LocalZaakSource.cs
@@ -1,4 +1,5 @@
using BigRegister.Api.Contracts;
+using BigRegister.Domain.Authorization;
namespace BigRegister.Api.Data;
@@ -13,8 +14,15 @@ public sealed class LocalZaakSource : IZaakSource
public IReadOnlyList ListCases(DateTimeOffset now) =>
ApplicationStore.ListAll().Select(a => a.ToAdminSummaryDto(now)).ToList();
+ /// Citizen-scoped (WP-53) — exactly what GET /applications used to compute
+ /// inline before it was routed through this seam.
+ public IReadOnlyList ListMyCases(CallerIdentity caller, DateTimeOffset now) =>
+ ApplicationStore.List(caller.Bsn)
+ .OrderByDescending(a => a.UpdatedAt)
+ .Select(a => a.ToSummaryDto(now)).ToList();
+
/// 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.
- 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);
}
diff --git a/backend/src/BigRegister.Api/Domain/Authorization/Authz.cs b/backend/src/BigRegister.Api/Domain/Authorization/Authz.cs
index d5d9cd3..5dfbe8b 100644
--- a/backend/src/BigRegister.Api/Domain/Authorization/Authz.cs
+++ b/backend/src/BigRegister.Api/Domain/Authorization/Authz.cs
@@ -24,12 +24,10 @@ public enum BriefAction { Approve, Reject, Send }
///
public static class Authz
{
- public static Principal ResolvePrincipal(HttpContext ctx) => new(ctx.Request.Headers["X-Role"].ToString() switch
- {
- "approver" => PrincipalRole.Approver,
- "admin" => PrincipalRole.Admin,
- _ => PrincipalRole.Drafter,
- });
+ // 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) —
+ // one source of "who", so a real IIdentityProvider swap carries this over unchanged.
+ public static Principal ResolvePrincipal(HttpContext ctx) => new(ctx.Caller().Role);
public static string ActingId(Principal principal) => principal.Role switch
{
diff --git a/backend/src/BigRegister.Api/Domain/Authorization/CallerIdentity.cs b/backend/src/BigRegister.Api/Domain/Authorization/CallerIdentity.cs
new file mode 100644
index 0000000..9a459eb
--- /dev/null
+++ b/backend/src/BigRegister.Api/Domain/Authorization/CallerIdentity.cs
@@ -0,0 +1,27 @@
+namespace BigRegister.Domain.Authorization;
+
+///
+/// The acting citizen for this request (WP-53) — subject BSN, display name, and role. Resolved
+/// once per request by and stashed on
+/// by the identity-resolution middleware (Program.cs, right after the correlation-id
+/// middleware). Everything that used to hardcode DocumentStore.DemoOwner or the static
+/// ZgwOptions.UserId/UserRepresentation claims now reads this instead — a production
+/// (real OIDC/DigiD claims) swaps in without touching any consumer.
+///
+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;
+
+ /// 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.
+ 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.");
+}
diff --git a/backend/src/BigRegister.Api/Domain/Authorization/IIdentityProvider.cs b/backend/src/BigRegister.Api/Domain/Authorization/IIdentityProvider.cs
new file mode 100644
index 0000000..497176f
--- /dev/null
+++ b/backend/src/BigRegister.Api/Domain/Authorization/IIdentityProvider.cs
@@ -0,0 +1,11 @@
+namespace BigRegister.Domain.Authorization;
+
+///
+/// Resolves the acting for a request (WP-53) — the seam a real
+/// OIDC/DigiD-backed provider replaces in production. is the
+/// only implementation today.
+///
+public interface IIdentityProvider
+{
+ CallerIdentity Resolve(HttpContext ctx);
+}
diff --git a/backend/src/BigRegister.Api/Domain/Authorization/StubIdentityProvider.cs b/backend/src/BigRegister.Api/Domain/Authorization/StubIdentityProvider.cs
new file mode 100644
index 0000000..69f519c
--- /dev/null
+++ b/backend/src/BigRegister.Api/Domain/Authorization/StubIdentityProvider.cs
@@ -0,0 +1,32 @@
+using BigRegister.Api.Data;
+
+namespace BigRegister.Domain.Authorization;
+
+///
+/// Dev stub (WP-53) — NOT a security boundary, same caveat as
+/// (which this provider now backs). Role comes from the existing client-asserted X-Role header
+/// (mirrors the FE's ?role= toggle); the subject BSN comes from a new X-Subject header,
+/// defaulting to the single seeded citizen () 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
+/// carries over unchanged once that swap happens.
+///
+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);
+ }
+}
diff --git a/backend/src/BigRegister.Api/Program.cs b/backend/src/BigRegister.Api/Program.cs
index d161cda..fbda8bd 100644
--- a/backend/src/BigRegister.Api/Program.cs
+++ b/backend/src/BigRegister.Api/Program.cs
@@ -43,6 +43,12 @@ builder.Services.AddCors(o => o.AddPolicy(SpaCors, p =>
// override it (ConnectionStrings:AppDb) without touching this file.
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();
+
// 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
// changes (ADR-0001). Default = LocalZaakSource (offline). Zgw:Enabled=true swaps in the
@@ -87,6 +93,16 @@ app.Use(async (ctx, next) =>
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();
+app.Use(async (ctx, next) =>
+{
+ ctx.SetCaller(identityProvider.Resolve(ctx));
+ await next(ctx);
+});
+
app.UseSwagger();
app.UseSwaggerUI();
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
// 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).
-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);
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
// call this used to make inline; OpenZaakDocumentSource (Zgw:Enabled=true) also registers
// 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);
})
.ExcludeFromDescription();
@@ -229,8 +245,8 @@ api.MapGet("/uploads/status", (string? localIds) =>
});
// User delete: owner-scoped; 409 once linked to a finalised submission.
-api.MapDelete("/uploads/{documentId}", (string documentId) =>
- DocumentStore.DeleteOwned(documentId, DocumentStore.DemoOwner) switch
+api.MapDelete("/uploads/{documentId}", (string documentId, HttpContext ctx) =>
+ DocumentStore.DeleteOwned(documentId, ctx.Caller().Bsn) switch
{
DocumentStore.DeleteResult.Ok => Results.NoContent(),
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. ---
-api.MapGet("/applications", () =>
-{
- var now = DateTimeOffset.UtcNow;
- return ApplicationStore.List(DocumentStore.DemoOwner)
- .OrderByDescending(a => a.UpdatedAt)
- .Select(a => a.ToSummaryDto(now)).ToList();
-});
+// 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
+// OpenZaak (BSN-filtered) too, closing the last "reads a static store directly" gap
+// openzaak-integration.md's ACL caveat used to flag for this endpoint.
+api.MapGet("/applications", (HttpContext ctx, IZaakSource zaken) =>
+ zaken.ListMyCases(ctx.Caller(), DateTimeOffset.UtcNow));
-api.MapGet("/applications/{id}", (string id) =>
- ApplicationStore.Get(id, DocumentStore.DemoOwner) is { } a
+api.MapGet("/applications/{id}", (string id, HttpContext ctx) =>
+ ApplicationStore.Get(id, ctx.Caller().Bsn) is { } a
? Results.Ok(a.ToDetailDto(DateTimeOffset.UtcNow))
: Results.NotFound())
.Produces()
.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.
if (req.Type == "registratie" && !FeatureFlagStore.IsEnabled(FeatureFlags.InschrijvingOpen))
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)
return Results.Problem(
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);
// Draft sync per step — idempotent; keep it debounced on the client (it is chatty).
-api.MapPut("/applications/{id}", (string id, DraftSyncRequest req) =>
- ApplicationStore.SyncDraft(id, DocumentStore.DemoOwner, req.Draft, req.StepIndex, req.StepCount, req.DocumentIds)
+api.MapPut("/applications/{id}", (string id, DraftSyncRequest req, HttpContext ctx) =>
+ ApplicationStore.SyncDraft(id, ctx.Caller().Bsn, req.Draft, req.StepIndex, req.StepCount, req.DocumentIds)
? Results.NoContent() : Results.NotFound())
.Produces(StatusCodes.Status204NoContent)
.Produces(StatusCodes.Status404NotFound);
// Cancel a Concept (cascades to its unlinked documents). Submitted aanvragen cannot
// 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.Submitted)
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();
})
.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.
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.Submitted)
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 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();
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
// 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:
- // zero FE contract change either way).
- var (referentie, status, zaakUrl) = zaken.CreateZaak(submitted, DateTimeOffset.UtcNow);
+ // zero FE contract change either way). WP-53: the caller is threaded through so the minted
+ // 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);
// WP-51: link the submitted documents to the zaak — LocalDocumentSource is exactly the
// DocumentStore.Link call this used to make inline; OpenZaakDocumentSource additionally
// 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));
})
@@ -430,7 +446,7 @@ api.MapPut("/admin/flags/{key}", (string key, SetFeatureFlagRequest req, HttpCon
api.MapGet("/brief", (HttpContext ctx) =>
{
- var e = BriefStore.GetOrCreate(DocumentStore.DemoOwner);
+ var e = BriefStore.GetOrCreate(ctx.Caller().Bsn);
return ToView(ctx, e);
})
.Produces();
@@ -438,7 +454,7 @@ api.MapGet("/brief", (HttpContext ctx) =>
api.MapPut("/brief", (SaveBriefRequest req, HttpContext ctx) =>
{
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()
.ProducesProblem(StatusCodes.Status403Forbidden)
@@ -447,7 +463,7 @@ api.MapPut("/brief", (SaveBriefRequest req, HttpContext ctx) =>
api.MapPost("/brief/submit", (HttpContext ctx) =>
{
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);
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) =>
{
- var r = BriefStore.Approve(DocumentStore.DemoOwner, Authz.ResolvePrincipal(ctx), Now());
+ var r = BriefStore.Approve(ctx.Caller().Bsn, Authz.ResolvePrincipal(ctx), Now());
LogBrief("approve", r);
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) =>
{
- 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);
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
// port); the backend only guards the approved→sent transition (not role-gated
// 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);
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 steppedUp = ctx.Request.Headers["X-Step-Up"] == "true";
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)
return Results.Problem(
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.
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)
return Results.Content(archived, "text/html");
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) =>
{
// 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);
})
.WithName("briefReset")
diff --git a/backend/src/BigRegister.Api/Zgw/OpenZaakDocumentSource.cs b/backend/src/BigRegister.Api/Zgw/OpenZaakDocumentSource.cs
index 9ff9060..8cd6afe 100644
--- a/backend/src/BigRegister.Api/Zgw/OpenZaakDocumentSource.cs
+++ b/backend/src/BigRegister.Api/Zgw/OpenZaakDocumentSource.cs
@@ -2,6 +2,7 @@ using System.Text.Json;
using System.Text.Json.Serialization;
using BigRegister.Api.Contracts;
using BigRegister.Api.Data;
+using BigRegister.Domain.Authorization;
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.
public UploadResponse Upload(
string localId, string categoryId, string wizardId, string fileName, string contentType,
- byte[] content, string owner) =>
- UploadAsync(localId, categoryId, wizardId, fileName, contentType, content, owner)
+ byte[] content, CallerIdentity caller) =>
+ UploadAsync(localId, categoryId, wizardId, fileName, contentType, content, caller)
.GetAwaiter().GetResult();
private async Task UploadAsync(
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))
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
// 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.
- Vertrouwelijkheidaanduiding: "openbaar"));
+ Vertrouwelijkheidaanduiding: "openbaar"), caller);
DocumentStore.SetDrcUrl(doc.DocumentId, eio.Url);
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 —
/// 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.
- public void LinkToZaak(IReadOnlyList documentIds, string? zaakUrl)
+ public void LinkToZaak(IReadOnlyList documentIds, string? zaakUrl, CallerIdentity caller)
{
DocumentStore.Link(documentIds);
if (zaakUrl is null) return;
- LinkToZaakAsync(documentIds, zaakUrl).GetAwaiter().GetResult();
+ LinkToZaakAsync(documentIds, zaakUrl, caller).GetAwaiter().GetResult();
}
- private async Task LinkToZaakAsync(IReadOnlyList documentIds, string zaakUrl)
+ private async Task LinkToZaakAsync(IReadOnlyList documentIds, string zaakUrl, CallerIdentity caller)
{
foreach (var documentId in documentIds)
{
var drcUrl = DocumentStore.Get(documentId)?.DrcUrl;
if (drcUrl is null) continue;
await zgw.PostAsync($"{options.ZrcBaseUrl}/zaakinformatieobjecten",
- new CreateZaakInformatieobjectRequest(zaakUrl, drcUrl));
+ new CreateZaakInformatieobjectRequest(zaakUrl, drcUrl), caller);
}
}
diff --git a/backend/src/BigRegister.Api/Zgw/OpenZaakZaakSource.cs b/backend/src/BigRegister.Api/Zgw/OpenZaakZaakSource.cs
index 024773f..6b017b6 100644
--- a/backend/src/BigRegister.Api/Zgw/OpenZaakZaakSource.cs
+++ b/backend/src/BigRegister.Api/Zgw/OpenZaakZaakSource.cs
@@ -2,6 +2,7 @@ using System.Text.Json;
using System.Text.Json.Serialization;
using BigRegister.Api.Contracts;
using BigRegister.Api.Data;
+using BigRegister.Domain.Authorization;
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
// default and this blocking call shows up under load.
public IReadOnlyList ListCases(DateTimeOffset now) =>
- ListCasesAsync().GetAwaiter().GetResult();
+ ListCasesAsync(bsn: null, caller: null).GetAwaiter().GetResult();
- private async Task> ListCasesAsync()
+ /// WP-53: same read, filtered to one citizen's own zaken via ZGW's rol filter param
+ /// (see ) — and minted with that citizen's identity, not the
+ /// system-level one uses.
+ public IReadOnlyList ListMyCases(CallerIdentity caller, DateTimeOffset now) =>
+ ListCasesAsync(caller.Bsn, caller).GetAwaiter().GetResult();
+
+ private async Task> ListCasesAsync(string? bsn, CallerIdentity? caller)
{
- var zaken = await GetAllAsync($"{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(url, caller);
var labels = new Dictionary();
var result = new List(zaken.Count);
foreach (var z in zaken)
@@ -49,13 +59,13 @@ public sealed class OpenZaakZaakSource(HttpClient http, ZgwTokenProvider tokens,
}
/// Follow the next links, accumulating every page's results.
- private async Task> GetAllAsync(string url)
+ private async Task> GetAllAsync(string url, CallerIdentity? caller = null)
{
var all = new List();
string? next = url;
while (next is not null)
{
- var page = await zgw.GetAsync>(next);
+ var page = await zgw.GetAsync>(next, caller);
all.AddRange(page.Results);
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.
/// 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.
- public (string Referentie, AanvraagStatusDto Status, string? ZaakUrl) CreateZaak(Aanvraag aanvraag, DateTimeOffset now) =>
- CreateZaakAsync(aanvraag, now).GetAwaiter().GetResult();
+ public (string Referentie, AanvraagStatusDto Status, string? ZaakUrl) CreateZaak(Aanvraag aanvraag, DateTimeOffset now, CallerIdentity caller) =>
+ 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))
throw new InvalidOperationException(
@@ -95,11 +105,11 @@ public sealed class OpenZaakZaakSource(HttpClient http, ZgwTokenProvider tokens,
VerantwoordelijkeOrganisatie: options.VerantwoordelijkeOrganisatie,
Startdatum: DateOnly.FromDateTime(now.UtcDateTime),
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);
await zgw.PostAsync($"{options.ZrcBaseUrl}/statussen",
- new CreateStatusRequest(zaak.Url, statustypeUrl, now));
+ new CreateStatusRequest(zaak.Url, statustypeUrl, now), caller);
var roltypeUrl = await FirstInitiatorRoltypeUrlAsync(zaaktypeUrl);
await zgw.PostAsync($"{options.ZrcBaseUrl}/rollen", new CreateRolRequest(
@@ -107,7 +117,7 @@ public sealed class OpenZaakZaakSource(HttpClient http, ZgwTokenProvider tokens,
BetrokkeneType: "natuurlijk_persoon",
Roltype: roltypeUrl,
Roltoelichting: "Initiator",
- BetrokkeneIdentificatie: new BetrokkeneIdentificatie(aanvraag.Owner)));
+ BetrokkeneIdentificatie: new BetrokkeneIdentificatie(aanvraag.Owner)), caller);
return (zaak.Identificatie, ZgwZaakMapper.ToCreatedStatusDto(zaak.Identificatie), zaak.Url);
}
diff --git a/backend/src/BigRegister.Api/Zgw/ZgwHttpClient.cs b/backend/src/BigRegister.Api/Zgw/ZgwHttpClient.cs
index 0f0df07..7ae15df 100644
--- a/backend/src/BigRegister.Api/Zgw/ZgwHttpClient.cs
+++ b/backend/src/BigRegister.Api/Zgw/ZgwHttpClient.cs
@@ -1,5 +1,6 @@
using System.Net.Http.Headers;
using System.Net.Http.Json;
+using BigRegister.Domain.Authorization;
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
/// once OpenZaakDocumentSource (WP-51) needed the
/// identical auth + JSON + error-handling boilerplate — every ZGW call mints a fresh token
-/// () and expects/returns JSON.
+/// () and expects/returns JSON. 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.
///
internal sealed class ZgwHttpClient(HttpClient http, ZgwTokenProvider tokens)
{
- public async Task GetAsync(string url)
+ public async Task GetAsync(string url, CallerIdentity? caller = null)
{
using var req = new HttpRequestMessage(HttpMethod.Get, url);
- Authorize(req);
+ Authorize(req, caller);
using var res = await http.SendAsync(req);
res.EnsureSuccessStatusCode();
return (await res.Content.ReadFromJsonAsync())
?? throw new InvalidOperationException($"ZGW GET {url} returned null body.");
}
- public async Task PostAsync(string url, object body)
+ public async Task PostAsync(string url, object body, CallerIdentity? caller = null)
{
using var req = new HttpRequestMessage(HttpMethod.Post, url) { Content = JsonContent.Create(body) };
- Authorize(req);
+ Authorize(req, caller);
using var res = await http.SendAsync(req);
res.EnsureSuccessStatusCode();
return (await res.Content.ReadFromJsonAsync())
?? 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"));
}
}
diff --git a/backend/src/BigRegister.Api/Zgw/ZgwTokenProvider.cs b/backend/src/BigRegister.Api/Zgw/ZgwTokenProvider.cs
index b408581..b321f1e 100644
--- a/backend/src/BigRegister.Api/Zgw/ZgwTokenProvider.cs
+++ b/backend/src/BigRegister.Api/Zgw/ZgwTokenProvider.cs
@@ -1,6 +1,7 @@
using System.Security.Cryptography;
using System.Text;
using System.Text.Json;
+using BigRegister.Domain.Authorization;
namespace BigRegister.Api.Zgw;
@@ -18,7 +19,17 @@ namespace BigRegister.Api.Zgw;
///
public sealed class ZgwTokenProvider(ZgwOptions options)
{
- public string Mint()
+ /// System-level identity (this BFF acting as itself) — for calls not tied to one
+ /// specific citizen (e.g. the admin cross-owner ListCases).
+ public string Mint() => MintCore(options.UserId, options.UserRepresentation);
+
+ /// Per-request variant (WP-53): the ZGW audit trail (user_id/
+ /// user_representation) 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).
+ 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 payload = new
@@ -26,8 +37,8 @@ public sealed class ZgwTokenProvider(ZgwOptions options)
iss = options.ClientId,
iat = DateTimeOffset.UtcNow.ToUnixTimeSeconds(),
client_id = options.ClientId,
- user_id = options.UserId,
- user_representation = options.UserRepresentation,
+ user_id = userId,
+ user_representation = userRepresentation,
};
var signingInput = $"{Encode(header)}.{Encode(payload)}";
diff --git a/backend/tests/BigRegister.Tests/ApplicationTests.cs b/backend/tests/BigRegister.Tests/ApplicationTests.cs
index 30fc416..65638ac 100644
--- a/backend/tests/BigRegister.Tests/ApplicationTests.cs
+++ b/backend/tests/BigRegister.Tests/ApplicationTests.cs
@@ -137,6 +137,41 @@ public class ApplicationTests(TestWebApplicationFactory factory) : IClassFixture
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())!;
+
+ try
+ {
+ var listOther = new HttpRequestMessage(HttpMethod.Get, "/api/v1/applications") { Headers = { { "X-Subject", "999888777" } } };
+ var theirCases = (await (await _client.SendAsync(listOther)).Content.ReadFromJsonAsync>())!;
+ 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. ---
private static Aanvraag Accepted(bool autoApprovable) => new()
diff --git a/backend/tests/BigRegister.Tests/OpenZaakDocumentSourceTests.cs b/backend/tests/BigRegister.Tests/OpenZaakDocumentSourceTests.cs
index 5f95a5e..9e9614e 100644
--- a/backend/tests/BigRegister.Tests/OpenZaakDocumentSourceTests.cs
+++ b/backend/tests/BigRegister.Tests/OpenZaakDocumentSourceTests.cs
@@ -1,5 +1,6 @@
using BigRegister.Api.Data;
using BigRegister.Api.Zgw;
+using BigRegister.Domain.Authorization;
namespace BigRegister.Tests;
@@ -26,6 +27,8 @@ public class OpenZaakDocumentSourceTests
InformatieobjecttypeUrls = new() { ["identiteit"] = InformatieobjecttypeUrl },
};
+ private static readonly CallerIdentity Caller = new("111222333", "Dr. Test", PrincipalRole.Drafter);
+
[Fact]
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 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.NotEmpty(response.DocumentId);
@@ -65,7 +68,7 @@ public class OpenZaakDocumentSourceTests
var source = new OpenZaakDocumentSource(new HttpClient(handler), new ZgwTokenProvider(options), options);
Assert.Throws(() =>
- 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]
@@ -75,7 +78,7 @@ public class OpenZaakDocumentSourceTests
var uploadHandler = new ZgwStubHandler(url =>
"""{ "url": "https://oz.example/documenten/api/v1/enkelvoudiginformatieobjecten/eio-1" }""");
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
{
@@ -84,14 +87,14 @@ public class OpenZaakDocumentSourceTests
});
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");
Assert.Contains($"{ZrcBase}/zaken/uuid-1", body);
Assert.Contains("eio-1", body);
// 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]
@@ -101,7 +104,7 @@ public class OpenZaakDocumentSourceTests
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);
- source.LinkToZaak(["some-document-id"], zaakUrl: null);
+ source.LinkToZaak(["some-document-id"], zaakUrl: null, Caller);
Assert.Empty(handler.Requests);
}
diff --git a/backend/tests/BigRegister.Tests/OpenZaakZaakSourceTests.cs b/backend/tests/BigRegister.Tests/OpenZaakZaakSourceTests.cs
index ff15923..864ed70 100644
--- a/backend/tests/BigRegister.Tests/OpenZaakZaakSourceTests.cs
+++ b/backend/tests/BigRegister.Tests/OpenZaakZaakSourceTests.cs
@@ -1,5 +1,6 @@
using BigRegister.Api.Data;
using BigRegister.Api.Zgw;
+using BigRegister.Domain.Authorization;
namespace BigRegister.Tests;
@@ -58,6 +59,26 @@ public class OpenZaakZaakSourceTests
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]
public void CreateZaak_posts_zaak_status_and_rol_and_maps_the_result_back()
{
@@ -101,7 +122,8 @@ public class OpenZaakZaakSourceTests
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("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 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 caller = new CallerIdentity(aanvraag.Owner, "Dr. Test", PrincipalRole.Drafter);
- Assert.Throws(() => source.CreateZaak(aanvraag, DateTimeOffset.UtcNow));
+ Assert.Throws(() => source.CreateZaak(aanvraag, DateTimeOffset.UtcNow, caller));
}
}
diff --git a/backend/tests/BigRegister.Tests/StubIdentityProviderTests.cs b/backend/tests/BigRegister.Tests/StubIdentityProviderTests.cs
new file mode 100644
index 0000000..b12f08c
--- /dev/null
+++ b/backend/tests/BigRegister.Tests/StubIdentityProviderTests.cs
@@ -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);
+ }
+}
diff --git a/backend/tests/BigRegister.Tests/ZgwTokenProviderTests.cs b/backend/tests/BigRegister.Tests/ZgwTokenProviderTests.cs
index 2d1fe3e..30b4e0a 100644
--- a/backend/tests/BigRegister.Tests/ZgwTokenProviderTests.cs
+++ b/backend/tests/BigRegister.Tests/ZgwTokenProviderTests.cs
@@ -2,6 +2,7 @@ using System.Security.Cryptography;
using System.Text;
using System.Text.Json;
using BigRegister.Api.Zgw;
+using BigRegister.Domain.Authorization;
namespace BigRegister.Tests;
@@ -42,6 +43,19 @@ public class ZgwTokenProviderTests
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(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]
public void Signature_verifies_with_the_shared_secret()
{
diff --git a/docs/project/backlog/README.md b/docs/project/backlog/README.md
index b6d1eff..518296d 100644
--- a/docs/project/backlog/README.md
+++ b/docs/project/backlog/README.md
@@ -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-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-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 |
Sequencing dependencies (stated in the WPs too): 01 before 10–15 (axe covers story churn);
diff --git a/docs/project/backlog/WP-53-inbound-identity-and-citizen-scoping.md b/docs/project/backlog/WP-53-inbound-identity-and-citizen-scoping.md
index fca654a..488df9e 100644
--- a/docs/project/backlog/WP-53-inbound-identity-and-citizen-scoping.md
+++ b/docs/project/backlog/WP-53-inbound-identity-and-citizen-scoping.md
@@ -1,6 +1,6 @@
# WP-53 — Inbound identity + citizen-scoping (the ZGW auth seam)
-Status: todo
+Status: done
Phase: 9 — OpenZaak / ZGW integration
## Why
@@ -94,16 +94,18 @@ param `rol__betrokkeneIdentificatie__natuurlijkPersoon__inpBsn=` on `GET {Z
## 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.
-- [ ] 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.
-- [ ] `dotnet test` green; `npm run ci` green with **no api-client drift** (FE contract intact).
+- [x] ZGW JWT carries the acting citizen's `user_id`/`user_representation` (test-verified).
+- [x] A citizen read returns only that BSN's zaken (local + ZGW-stub tests); admin read unchanged.
+- [x] `dotnet test` green; `npm run ci` green with **no api-client drift** (FE contract intact).
## Verification
-`cd backend && dotnet test`; manual: `X-Role`/`X-Subject` (or `?role=`) still switches identity
-offline; with `Zgw:Enabled=true` (WP-54 harness) a citizen sees only their zaken.
+`cd backend && dotnet test` (159/159, incl. 8 new); `dotnet format --verify-no-changes` clean;
+`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
@@ -112,6 +114,27 @@ session sync (CLAUDE.md out-of-scope list).
## Risks
-- Missing a `DemoOwner` call site → a citizen sees another's data. Mitigate: grep gate in the
- acceptance criteria + a test that two identities don't see each other's cases.
-- ZGW rol filter param name is exact and version-sensitive; assert it in the stub-handler test.
+- Missing a `DemoOwner` call site → a citizen sees another's data. Mitigated: grep gate (clean)
+ + `ApplicationTests.Applications_are_scoped_to_the_caller_bsn` (two `X-Subject` identities,
+ 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.
diff --git a/docs/reference/openzaak-integration.md b/docs/reference/openzaak-integration.md
index ca4a2a8..bb9e6c5 100644
--- a/docs/reference/openzaak-integration.md
+++ b/docs/reference/openzaak-integration.md
@@ -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.
3. **POST rol** (`{ZrcBaseUrl}/rollen`) — `roltype` resolved via a Catalogi GET
(`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
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=` 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)
| 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
complete on day one, but its shortcuts should be visible.
-Caveat: `IZaakSource` covers the cases **read + create** path (WP-49/50), `IDocumentSource`
-covers **upload + zaak-link** (WP-51), and the inbound `POST /zgw/notificaties` webhook
-(WP-52) closes the read/write/document/notify arc. Other BFF endpoints still read
-`SeedData`/static stores directly — ACL-ready (the DTO seam exists) but not yet swappable.
-What's left in this arc is the two cross-cutting WPs production needs: **WP-53** (a real
-per-request identity seam + citizen-scoping — today the owner/BSN is stubbed) and **WP-54** (a
-docker OpenZaak harness + opt-in integration test — today everything is fixture/mock-tested
-against no live instance).
+Caveat: `IZaakSource` covers the cases **read (admin + citizen-scoped) + create** path
+(WP-49/50/53), `IDocumentSource` covers **upload + zaak-link** (WP-51), the inbound
+`POST /zgw/notificaties` webhook (WP-52) closes the read/write/document/notify arc, and WP-53
+threaded a real per-request `CallerIdentity` through all of it (ownership + the ZGW audit
+claims). Other BFF endpoints (reference data like `SeedData`'s BRP/DUO mimics) still read static
+stores directly — ACL-ready (the DTO seam exists) but not yet swappable, and not part of this
+arc. What's left is **WP-54**: a docker OpenZaak harness + opt-in integration test — today
+everything is fixture/mock-tested against no live instance.
## See also
- [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.
-- [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.
- [ZGW standard (VNG)](https://vng-realisatie.github.io/gemma-zaken/) · [OpenZaak auth docs](https://open-zaak.readthedocs.io/en/stable/client-development/authentication.html).