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

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

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

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

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
eho
2026-07-30 08:27:53 +02:00
co-authored by Claude Sonnet 5
parent bea04549dd
commit 73172510ea
21 changed files with 418 additions and 110 deletions
@@ -1,4 +1,5 @@
using BigRegister.Api.Contracts;
using BigRegister.Domain.Authorization;
namespace BigRegister.Api.Data;
@@ -14,15 +15,18 @@ namespace BigRegister.Api.Data;
public interface IDocumentSource
{
/// <summary>Store an uploaded file (already validated by <c>DocumentRules</c>) and return the
/// existing <see cref="UploadResponse"/> DTO unchanged, whichever source is active.</summary>
/// existing <see cref="UploadResponse"/> DTO unchanged, whichever source is active.
/// <paramref name="caller"/> (WP-53) is both the document's owner (<c>DocumentStore</c>'s
/// ownership field) and, under the OpenZaak source, the identity minted into the ZGW JWT.</summary>
UploadResponse Upload(
string localId, string categoryId, string wizardId, string fileName, string contentType,
byte[] content, string owner);
byte[] content, CallerIdentity caller);
/// <summary>Finalise a set of already-uploaded documents against a just-submitted aanvraag
/// (WP-50/51): local behaviour is exactly today's <c>DocumentStore.Link</c>; the OpenZaak
/// source additionally links each document (that has a DRC url) to the zaak, once
/// <paramref name="zaakUrl"/> is known (null under the local <see cref="IZaakSource"/>, in
/// which case there is nothing extra to link).</summary>
void LinkToZaak(IReadOnlyList<string> documentIds, string? zaakUrl);
/// which case there is nothing extra to link) — minted with <paramref name="caller"/>'s
/// identity (WP-53).</summary>
void LinkToZaak(IReadOnlyList<string> documentIds, string? zaakUrl, CallerIdentity caller);
}
@@ -1,4 +1,5 @@
using BigRegister.Api.Contracts;
using BigRegister.Domain.Authorization;
namespace BigRegister.Api.Data;
@@ -16,9 +17,19 @@ namespace BigRegister.Api.Data;
/// </summary>
public interface IZaakSource
{
/// <summary>Every case, newest-first (the admin cross-owner list, WP-36).</summary>
/// <summary>Every case across every owner, newest-first (the admin cross-owner list,
/// WP-36) — cases:manage only, deliberately NOT citizen-scoped.</summary>
IReadOnlyList<ApplicationSummaryDto> ListCases(DateTimeOffset now);
/// <summary>
/// Only <paramref name="caller"/>'s own cases (WP-53) — the citizen-scoped counterpart of
/// <see cref="ListCases"/>, backing the citizen's own dashboard. The local source filters
/// <c>ApplicationStore</c> by owner (unchanged behaviour); the OpenZaak source adds ZGW's
/// <c>rol__betrokkeneIdentificatie__natuurlijkPersoon__inpBsn</c> query filter so a citizen
/// can never see another citizen's zaken.
/// </summary>
IReadOnlyList<ApplicationSummaryDto> ListMyCases(CallerIdentity caller, DateTimeOffset now);
/// <summary>
/// Register a just-submitted <paramref name="aanvraag"/> as a zaak (WP-50). The aanvraag is
/// already persisted locally (<c>ApplicationStore.Submit</c> already ran) — this is the
@@ -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 (<see cref="ApplicationStore.SetZaakUrl"/>, WP-51 needs it
/// to later link documents to this zaak).
/// to later link documents to this zaak). <paramref name="caller"/> (WP-53) is the acting
/// citizen — the ZGW JWT's audit claims reflect them, not a static config identity.
/// </summary>
(string Referentie, AanvraagStatusDto Status, string? ZaakUrl) CreateZaak(Aanvraag aanvraag, DateTimeOffset now);
(string Referentie, AanvraagStatusDto Status, string? ZaakUrl) CreateZaak(Aanvraag aanvraag, DateTimeOffset now, CallerIdentity caller);
}
@@ -1,4 +1,5 @@
using BigRegister.Api.Contracts;
using BigRegister.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<string> documentIds, string? zaakUrl) =>
public void LinkToZaak(IReadOnlyList<string> documentIds, string? zaakUrl, CallerIdentity caller) =>
DocumentStore.Link(documentIds);
}
@@ -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<ApplicationSummaryDto> ListCases(DateTimeOffset now) =>
ApplicationStore.ListAll().Select(a => a.ToAdminSummaryDto(now)).ToList();
/// <summary>Citizen-scoped (WP-53) — exactly what <c>GET /applications</c> used to compute
/// inline before it was routed through this seam.</summary>
public IReadOnlyList<ApplicationSummaryDto> ListMyCases(CallerIdentity caller, DateTimeOffset now) =>
ApplicationStore.List(caller.Bsn)
.OrderByDescending(a => a.UpdatedAt)
.Select(a => a.ToSummaryDto(now)).ToList();
/// <summary>No external zaak to create — the aanvraag's local submit already IS the record
/// of truth, exactly as before this seam existed (WP-50). Zero behaviour change.</summary>
public (string Referentie, AanvraagStatusDto Status, string? ZaakUrl) CreateZaak(Aanvraag aanvraag, DateTimeOffset now) =>
public (string Referentie, AanvraagStatusDto Status, string? ZaakUrl) CreateZaak(Aanvraag aanvraag, DateTimeOffset now, CallerIdentity caller) =>
(aanvraag.Referentie!, aanvraag.ToStatusDto(now), null);
}
@@ -24,12 +24,10 @@ public enum BriefAction { Approve, Reject, Send }
/// </summary>
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
{
@@ -0,0 +1,27 @@
namespace BigRegister.Domain.Authorization;
/// <summary>
/// The acting citizen for this request (WP-53) — subject BSN, display name, and role. Resolved
/// once per request by <see cref="IIdentityProvider"/> and stashed on <see cref="HttpContext.Items"/>
/// by the identity-resolution middleware (<c>Program.cs</c>, right after the correlation-id
/// middleware). Everything that used to hardcode <c>DocumentStore.DemoOwner</c> or the static
/// <c>ZgwOptions.UserId</c>/<c>UserRepresentation</c> claims now reads this instead — a production
/// <see cref="IIdentityProvider"/> (real OIDC/DigiD claims) swaps in without touching any consumer.
/// </summary>
public sealed record CallerIdentity(string Bsn, string DisplayName, PrincipalRole Role);
public static class CallerIdentityHttpContextExtensions
{
private const string ItemsKey = "CallerIdentity";
public static void SetCaller(this HttpContext ctx, CallerIdentity identity) => ctx.Items[ItemsKey] = identity;
/// <summary>Never null in practice — the identity-resolution middleware runs for every
/// request before any endpoint handler. Throws rather than silently falling back, so a
/// misordered middleware pipeline fails loudly instead of leaking a default identity.</summary>
public static CallerIdentity Caller(this HttpContext ctx) =>
ctx.Items.TryGetValue(ItemsKey, out var v) && v is CallerIdentity identity
? identity
: throw new InvalidOperationException(
"No CallerIdentity resolved for this request — the identity middleware didn't run.");
}
@@ -0,0 +1,11 @@
namespace BigRegister.Domain.Authorization;
/// <summary>
/// Resolves the acting <see cref="CallerIdentity"/> for a request (WP-53) — the seam a real
/// OIDC/DigiD-backed provider replaces in production. <see cref="StubIdentityProvider"/> is the
/// only implementation today.
/// </summary>
public interface IIdentityProvider
{
CallerIdentity Resolve(HttpContext ctx);
}
@@ -0,0 +1,32 @@
using BigRegister.Api.Data;
namespace BigRegister.Domain.Authorization;
/// <summary>
/// Dev stub (WP-53) — NOT a security boundary, same caveat as <see cref="Authz.ResolvePrincipal"/>
/// (which this provider now backs). Role comes from the existing client-asserted X-Role header
/// (mirrors the FE's <c>?role=</c> toggle); the subject BSN comes from a new X-Subject header,
/// defaulting to the single seeded citizen (<see cref="DocumentStore.DemoOwner"/>) so every
/// existing request — none of which send X-Subject — keeps behaving exactly as before this WP.
/// A real system builds this from verified AD/OIDC/DigiD claims; every consumer of
/// <see cref="CallerIdentity"/> carries over unchanged once that swap happens.
/// </summary>
public sealed class StubIdentityProvider : IIdentityProvider
{
public CallerIdentity Resolve(HttpContext ctx)
{
var role = ctx.Request.Headers["X-Role"].ToString() switch
{
"approver" => PrincipalRole.Approver,
"admin" => PrincipalRole.Admin,
_ => PrincipalRole.Drafter,
};
var bsn = ctx.Request.Headers.TryGetValue("X-Subject", out var v) && !string.IsNullOrEmpty(v)
? v.ToString()
: DocumentStore.DemoOwner;
// Only one seeded citizen exists in this POC — a real provider carries the display name in
// the verified claims themselves, so there's no "look up a name by BSN" step to stand in for.
var displayName = bsn == DocumentStore.DemoOwner ? SeedData.Registration.Naam : bsn;
return new CallerIdentity(bsn, displayName, role);
}
}
+50 -34
View File
@@ -43,6 +43,12 @@ builder.Services.AddCors(o => o.AddPolicy(SpaCors, p =>
// override it (ConnectionStrings:AppDb) without touching this file.
Db.ConnectionString = builder.Configuration.GetConnectionString("AppDb") ?? Db.ConnectionString;
// WP-53: the per-request acting citizen — resolved once (middleware, below) into
// HttpContext.Items, consumed by Authz.ResolvePrincipal, ZgwTokenProvider.Mint(caller), and
// every store call site that used to hardcode DocumentStore.DemoOwner. Stub today
// (X-Role/X-Subject headers); a real OIDC/DigiD provider swaps in without touching a consumer.
builder.Services.AddSingleton<IIdentityProvider, StubIdentityProvider>();
// WP-49: the cases (zaken) READ path goes through IZaakSource so a real ZGW backend
// (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<IIdentityProvider>();
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<ApplicationDetailDto>()
.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<BriefViewDto>();
@@ -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<BriefViewDto>()
.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")
@@ -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<UploadResponse> 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.</summary>
public void LinkToZaak(IReadOnlyList<string> documentIds, string? zaakUrl)
public void LinkToZaak(IReadOnlyList<string> 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<string> documentIds, string zaakUrl)
private async Task LinkToZaakAsync(IReadOnlyList<string> documentIds, string zaakUrl, CallerIdentity caller)
{
foreach (var documentId in documentIds)
{
var drcUrl = DocumentStore.Get(documentId)?.DrcUrl;
if (drcUrl is null) continue;
await zgw.PostAsync<JsonElement>($"{options.ZrcBaseUrl}/zaakinformatieobjecten",
new CreateZaakInformatieobjectRequest(zaakUrl, drcUrl));
new CreateZaakInformatieobjectRequest(zaakUrl, drcUrl), caller);
}
}
@@ -2,6 +2,7 @@ using System.Text.Json;
using System.Text.Json.Serialization;
using 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<ApplicationSummaryDto> ListCases(DateTimeOffset now) =>
ListCasesAsync().GetAwaiter().GetResult();
ListCasesAsync(bsn: null, caller: null).GetAwaiter().GetResult();
private async Task<IReadOnlyList<ApplicationSummaryDto>> ListCasesAsync()
/// <summary>WP-53: same read, filtered to one citizen's own zaken via ZGW's rol filter param
/// (see <see cref="ListCasesAsync"/>) — and minted with that citizen's identity, not the
/// system-level one <see cref="ListCases"/> uses.</summary>
public IReadOnlyList<ApplicationSummaryDto> ListMyCases(CallerIdentity caller, DateTimeOffset now) =>
ListCasesAsync(caller.Bsn, caller).GetAwaiter().GetResult();
private async Task<IReadOnlyList<ApplicationSummaryDto>> ListCasesAsync(string? bsn, CallerIdentity? caller)
{
var zaken = await GetAllAsync<ZgwZaak>($"{options.ZrcBaseUrl}/zaken");
var url = $"{options.ZrcBaseUrl}/zaken";
if (bsn is not null)
url += $"?rol__betrokkeneIdentificatie__natuurlijkPersoon__inpBsn={Uri.EscapeDataString(bsn)}";
var zaken = await GetAllAsync<ZgwZaak>(url, caller);
var labels = new Dictionary<string, string>();
var result = new List<ApplicationSummaryDto>(zaken.Count);
foreach (var z in zaken)
@@ -49,13 +59,13 @@ public sealed class OpenZaakZaakSource(HttpClient http, ZgwTokenProvider tokens,
}
/// <summary>Follow the <c>next</c> links, accumulating every page's results.</summary>
private async Task<IReadOnlyList<T>> GetAllAsync<T>(string url)
private async Task<IReadOnlyList<T>> GetAllAsync<T>(string url, CallerIdentity? caller = null)
{
var all = new List<T>();
string? next = url;
while (next is not null)
{
var page = await zgw.GetAsync<ZgwPage<T>>(next);
var page = await zgw.GetAsync<ZgwPage<T>>(next, caller);
all.AddRange(page.Results);
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<JsonElement>($"{options.ZrcBaseUrl}/statussen",
new CreateStatusRequest(zaak.Url, statustypeUrl, now));
new CreateStatusRequest(zaak.Url, statustypeUrl, now), caller);
var roltypeUrl = await FirstInitiatorRoltypeUrlAsync(zaaktypeUrl);
await zgw.PostAsync<JsonElement>($"{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);
}
@@ -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
/// <see cref="OpenZaakZaakSource"/> once <c>OpenZaakDocumentSource</c> (WP-51) needed the
/// identical auth + JSON + error-handling boilerplate — every ZGW call mints a fresh token
/// (<see cref="ZgwTokenProvider"/>) and expects/returns JSON.
/// (<see cref="ZgwTokenProvider"/>) and expects/returns JSON. <paramref name="caller"/> is
/// optional (WP-53): omitted for calls not tied to one citizen (metadata lookups, the admin
/// cross-owner list), which mint with the BFF's own system identity instead.
/// </summary>
internal sealed class ZgwHttpClient(HttpClient http, ZgwTokenProvider tokens)
{
public async Task<T> GetAsync<T>(string url)
public async Task<T> GetAsync<T>(string url, CallerIdentity? caller = null)
{
using var req = new HttpRequestMessage(HttpMethod.Get, url);
Authorize(req);
Authorize(req, caller);
using var res = await http.SendAsync(req);
res.EnsureSuccessStatusCode();
return (await res.Content.ReadFromJsonAsync<T>())
?? throw new InvalidOperationException($"ZGW GET {url} returned null body.");
}
public async Task<T> PostAsync<T>(string url, object body)
public async Task<T> PostAsync<T>(string url, object body, CallerIdentity? caller = null)
{
using var req = new HttpRequestMessage(HttpMethod.Post, url) { Content = JsonContent.Create(body) };
Authorize(req);
Authorize(req, caller);
using var res = await http.SendAsync(req);
res.EnsureSuccessStatusCode();
return (await res.Content.ReadFromJsonAsync<T>())
?? 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"));
}
}
@@ -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;
/// </summary>
public sealed class ZgwTokenProvider(ZgwOptions options)
{
public string Mint()
/// <summary>System-level identity (this BFF acting as itself) — for calls not tied to one
/// specific citizen (e.g. the admin cross-owner <c>ListCases</c>).</summary>
public string Mint() => MintCore(options.UserId, options.UserRepresentation);
/// <summary>Per-request variant (WP-53): the ZGW audit trail (<c>user_id</c>/
/// <c>user_representation</c>) reflects the acting citizen instead of this BFF's static
/// config identity, for any call made on a specific citizen's behalf (create zaak, upload,
/// link, citizen-scoped list).</summary>
public string Mint(CallerIdentity caller) => MintCore(caller.Bsn, caller.DisplayName);
private string MintCore(string userId, string userRepresentation)
{
var header = new { alg = "HS256", typ = "JWT" };
var 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)}";
@@ -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<ApplicationDetailDto>())!;
try
{
var listOther = new HttpRequestMessage(HttpMethod.Get, "/api/v1/applications") { Headers = { { "X-Subject", "999888777" } } };
var theirCases = (await (await _client.SendAsync(listOther)).Content.ReadFromJsonAsync<List<ApplicationSummaryDto>>())!;
Assert.Contains(theirCases, c => c.Id == other.Id);
Assert.DoesNotContain(theirCases, c => c.Id == mine.Id);
var myCases = (await List())!;
Assert.Contains(myCases, c => c.Id == mine.Id);
Assert.DoesNotContain(myCases, c => c.Id == other.Id);
}
finally
{
var deleteOther = new HttpRequestMessage(HttpMethod.Delete, $"/api/v1/applications/{other.Id}") { Headers = { { "X-Subject", "999888777" } } };
await _client.SendAsync(deleteOther);
await _client.DeleteAsync($"/api/v1/applications/{mine.Id}");
}
}
// --- Auto-approval is computed on read: exercise the window boundary without waiting. ---
private static Aanvraag Accepted(bool autoApprovable) => new()
@@ -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<InvalidOperationException>(() =>
source.Upload("local-1", "unknown-category", "registratie", "f.pdf", "application/pdf", [1, 2, 3], "111222333"));
source.Upload("local-1", "unknown-category", "registratie", "f.pdf", "application/pdf", [1, 2, 3], Caller));
}
[Fact]
@@ -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);
}
@@ -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<InvalidOperationException>(() => source.CreateZaak(aanvraag, DateTimeOffset.UtcNow));
Assert.Throws<InvalidOperationException>(() => source.CreateZaak(aanvraag, DateTimeOffset.UtcNow, caller));
}
}
@@ -0,0 +1,43 @@
using BigRegister.Api.Data;
using BigRegister.Domain.Authorization;
using Microsoft.AspNetCore.Http;
namespace BigRegister.Tests;
/// WP-53: the dev stub identity provider — role from X-Role (unchanged behaviour), subject BSN
/// from the new X-Subject header, defaulting to the single seeded citizen so every existing
/// request (none of which send X-Subject) resolves exactly as before this WP.
public class StubIdentityProviderTests
{
private static CallerIdentity Resolve(string? role, string? subject)
{
var ctx = new DefaultHttpContext();
if (role is not null) ctx.Request.Headers["X-Role"] = role;
if (subject is not null) ctx.Request.Headers["X-Subject"] = subject;
return new StubIdentityProvider().Resolve(ctx);
}
[Fact]
public void No_headers_resolves_to_the_seeded_citizen_as_a_drafter()
{
var caller = Resolve(role: null, subject: null);
Assert.Equal(DocumentStore.DemoOwner, caller.Bsn);
Assert.Equal(PrincipalRole.Drafter, caller.Role);
}
[Theory]
[InlineData("approver", PrincipalRole.Approver)]
[InlineData("admin", PrincipalRole.Admin)]
[InlineData("something-unknown", PrincipalRole.Drafter)]
public void X_role_maps_to_the_principal_role(string header, PrincipalRole expected)
{
Assert.Equal(expected, Resolve(header, subject: null).Role);
}
[Fact]
public void X_subject_overrides_the_default_bsn()
{
var caller = Resolve(role: null, subject: "999888777");
Assert.Equal("999888777", caller.Bsn);
}
}
@@ -2,6 +2,7 @@ using System.Security.Cryptography;
using System.Text;
using System.Text.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<JsonElement>(Decode(token.Split('.')[1]));
Assert.Equal("111222333", payload.GetProperty("user_id").GetString());
Assert.Equal("Dr. Citizen", payload.GetProperty("user_representation").GetString());
// iss/client_id stay the BFF's own registered client id either way.
Assert.Equal("big-register", payload.GetProperty("client_id").GetString());
}
[Fact]
public void Signature_verifies_with_the_shared_secret()
{