From 73172510ea147e381527d6cfaec98b4136bb7256 Mon Sep 17 00:00:00 2001 From: Edwin van den Houdt Date: Thu, 30 Jul 2026 08:27:53 +0200 Subject: [PATCH] feat(zgw): real per-request identity seam + citizen-scoping (WP-53) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../BigRegister.Api/Data/IDocumentSource.cs | 12 ++- .../src/BigRegister.Api/Data/IZaakSource.cs | 18 +++- .../Data/LocalDocumentSource.cs | 7 +- .../BigRegister.Api/Data/LocalZaakSource.cs | 10 ++- .../Domain/Authorization/Authz.cs | 10 +-- .../Domain/Authorization/CallerIdentity.cs | 27 ++++++ .../Domain/Authorization/IIdentityProvider.cs | 11 +++ .../Authorization/StubIdentityProvider.cs | 32 +++++++ backend/src/BigRegister.Api/Program.cs | 84 +++++++++++-------- .../Zgw/OpenZaakDocumentSource.cs | 19 +++-- .../BigRegister.Api/Zgw/OpenZaakZaakSource.cs | 32 ++++--- .../src/BigRegister.Api/Zgw/ZgwHttpClient.cs | 17 ++-- .../BigRegister.Api/Zgw/ZgwTokenProvider.cs | 17 +++- .../BigRegister.Tests/ApplicationTests.cs | 35 ++++++++ .../OpenZaakDocumentSourceTests.cs | 15 ++-- .../OpenZaakZaakSourceTests.cs | 27 +++++- .../StubIdentityProviderTests.cs | 43 ++++++++++ .../ZgwTokenProviderTests.cs | 14 ++++ docs/project/backlog/README.md | 2 +- ...53-inbound-identity-and-citizen-scoping.md | 43 +++++++--- docs/reference/openzaak-integration.md | 53 +++++++++--- 21 files changed, 418 insertions(+), 110 deletions(-) create mode 100644 backend/src/BigRegister.Api/Domain/Authorization/CallerIdentity.cs create mode 100644 backend/src/BigRegister.Api/Domain/Authorization/IIdentityProvider.cs create mode 100644 backend/src/BigRegister.Api/Domain/Authorization/StubIdentityProvider.cs create mode 100644 backend/tests/BigRegister.Tests/StubIdentityProviderTests.cs 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).