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:
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user