feat(zgw): real per-request identity seam + citizen-scoping (WP-53)
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:
@@ -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")
|
||||
|
||||
Reference in New Issue
Block a user