using System.Security.Cryptography; using System.Text; using System.Text.Json; using System.Text.Json.Serialization; using BigRegister.Api.Contracts; using BigRegister.Api.Data; using BigRegister.Domain.Applications; using BigRegister.Domain.Authorization; using BigRegister.Domain.Beoordeling; using BigRegister.Domain.Diplomas; using BigRegister.Domain.Documents; using BigRegister.Domain.Features; using BigRegister.Domain.Intake; using BigRegister.Domain.Letters; using BigRegister.Domain.People; using BigRegister.Domain.Registrations; using BigRegister.Domain.Submissions; using BigRegister.Api.Zgw; using BigRegister.Stamdata; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Logging.Console; var builder = WebApplication.CreateBuilder(args); builder.Services.AddEndpointsApiExplorer(); builder.Services.AddSwaggerGen(c => c.SwaggerDoc("v1", new() { Title = "BIG-register BFF", Version = "v1" })); builder.Services.AddProblemDetails(); builder.Services.ConfigureHttpJsonOptions(o => { o.SerializerOptions.PropertyNamingPolicy = JsonNamingPolicy.CamelCase; o.SerializerOptions.DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull; }); // So the correlation-id scope pushed by the middleware below actually shows up in // the console, not just in memory for a formatter that never renders it. builder.Logging.AddSimpleConsole(o => o.IncludeScopes = true); const string SpaCors = "spa"; builder.Services.AddCors(o => o.AddPolicy(SpaCors, p => p.WithOrigins("http://localhost:4200").AllowAnyHeader().AllowAnyMethod())); // WP-22: the three stores (Applications/Documents/Briefs — Data/*.cs) are static // classes that open their own short-lived AppDbContext per call (see Db.Create), // not DI-injected, so there's no builder.Services.AddDbContext here. Configuring // the connection string still goes through IConfiguration so tests/deployments can // override it (ConnectionStrings:AppDb) without touching this file. Db.ConnectionString = builder.Configuration.GetConnectionString("AppDb") ?? Db.ConnectionString; // WP-53 (extended WP-62): the per-request acting caller — 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 for a zorgverlener, X-Medewerker/X-Rollen for a medewerker); a real // DigiD/employee-SSO provider swaps in without touching a consumer. // // RB-09/BIO-002: StubIdentityProvider invents a citizen identity for any request with no // credential at all — a production behandelportal build sends no X-Medewerker header, so it // used to authenticate every request as the seeded citizen (open on that citizen's own rights, // including CanRevealBigNummer). Registering the stub only in Development, and failing to // start in Production rather than falling through to a per-request 401, means a misconfigured // deploy never serves a single request. The real DigiD/employee-SSO provider is out of scope // for this POC (BIO-002's remediation says so explicitly) — until one exists, Production simply // cannot start, which is the correct fail-closed behaviour for "no identity provider available". if (builder.Environment.IsDevelopment()) builder.Services.AddSingleton(); else if (builder.Environment.IsProduction()) throw new InvalidOperationException( "No IIdentityProvider is registered for a Production environment. StubIdentityProvider " + "is Development-only (RB-09/BIO-002); there is no real DigiD/employee-SSO provider in " + "this POC yet. Register one before deploying to Production."); // 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 // OpenZaak client (needs the base URLs + credentials in the Zgw config section). var zgw = builder.Configuration.GetSection("Zgw").Get() ?? new ZgwOptions(); if (zgw.Enabled) { builder.Services.AddSingleton(zgw); builder.Services.AddSingleton(); // WP-60: a bounded client timeout matters once ZgwHttpClient retries — without one, the // sources' sync-over-async call (no CancellationToken threaded through) could block a // thread-pool thread for HttpClient's 100s default times 3 attempts. var zaakClientBuilder = builder.Services.AddHttpClient(c => c.Timeout = TimeSpan.FromSeconds(15)); // WP-51: the documents (Documenten API / DRC) seam — same pattern as IZaakSource above. var documentClientBuilder = builder.Services.AddHttpClient(c => c.Timeout = TimeSpan.FromSeconds(15)); // Opt-in diagnostic for the still-unexplained per-container flake (see // scripts/openzaak-ui-up.sh's header comment) — off by default, zero cost unless set. if (Environment.GetEnvironmentVariable("ZGW_DEBUG_HTTP") == "1") { builder.Services.AddTransient(); zaakClientBuilder.AddHttpMessageHandler(); documentClientBuilder.AddHttpMessageHandler(); } } else { builder.Services.AddSingleton(); builder.Services.AddSingleton(); } var app = builder.Build(); // Migrate on every startup, seed nothing (WP-22): unlike SeedData's read-only // reference fixtures (registration/diplomas/notes — untouched by this WP, still // static in-memory), Applications/Documents/Briefs never had seed data — they // started empty and accumulated through normal use before this WP too. A fresh // SQLite file just starts empty again, same as the old in-memory dictionaries did. using (var db = Db.Create()) db.Database.Migrate(); // Every request gets a correlation id (client-supplied X-Correlation-Id if present, // else generated), pushed into the logging scope for every log line the request // produces (not just the Submit helper's) and echoed back as a response header for // support/debugging correlation. Runs first so nothing downstream logs without it. app.Use(async (ctx, next) => { var cid = ctx.Request.Headers.TryGetValue("X-Correlation-Id", out var v) && !string.IsNullOrEmpty(v) ? v.ToString() : Guid.NewGuid().ToString(); ctx.Items["CorrelationId"] = cid; ctx.Response.Headers["X-Correlation-Id"] = cid; using (app.Logger.BeginScope("CorrelationId:{CorrelationId}", cid)) 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. RB-09/BIO-002: a null resolution is "no identity", not "the seeded // citizen" — this is the one place that turns it into a response (401) rather than letting it // flow downstream as a silent identity substitution. var identityProvider = app.Services.GetRequiredService(); app.Use(async (ctx, next) => { var identity = identityProvider.Resolve(ctx); if (identity is null) { ctx.Response.StatusCode = StatusCodes.Status401Unauthorized; return; } ctx.SetCaller(identity); await next(ctx); }); // RB-15/BIO-015: the OpenAPI document + its UI are a genuine attack-surface reduction to // gate — they enumerate every route, request/response shape and (via SwaggerUI's "Try it // out") let a caller fire requests straight from the browser. Development-only, like the // dev-role/scenario-toggle hatches this POC already keeps out of production builds // (docker-compose.prod.yml runs Production; only docker-compose.yml's dev image runs // Development). `dotnet swagger tofile` (npm run gen:api) is unaffected: Swashbuckle's CLI // resolves ISwaggerProvider straight out of the DI container to build swagger.json — it // never sends an HTTP request through this pipeline, so it never touches this middleware at // all, gated or not. Verified empirically (see rb-15.md) rather than assumed, per RB-09's // note that this exact file has already broken that tool once. if (app.Environment.IsDevelopment()) { app.UseSwagger(); app.UseSwaggerUI(); } app.UseCors(SpaCors); // Liveness/readiness for orchestrators (k8s probes, load balancers). No data, no PII. app.MapGet("/health", () => Results.Ok(new { status = "ok" })); app.MapGet("/health/ready", () => Results.Ok(new { status = "ready" })); // Versioned prefix: additive changes (new fields) stay on v1 — the generated client // + FE parse* boundary absorb them; a breaking change introduces /api/v2 alongside. var api = app.MapGroup("/api/v1"); // --- GET: screen-shaped reads. Decisions are computed here, never on the client. --- api.MapGet("/dashboard-view", () => { var reg = SeedData.Registration; var (eligible, reason) = HerregistratieRule.Evaluate(reg, DateOnly.FromDateTime(DateTime.Today)); return new DashboardViewDto(reg.ToDto(), SeedData.Person.ToDto(), new HerregistratieDecisionsDto(eligible, reason)); }); api.MapGet("/notes", () => SeedData.Notes.Select(n => new AantekeningDto(n.Type, n.Omschrijving, n.Datum)).ToList()); // BRP "no address" fallback would be `new BrpAddressDto(false, null)` — the seeded // citizen has one. api.MapGet("/brp/address", () => new BrpAddressDto(true, SeedData.BrpAddress.ToDto())); api.MapGet("/duo/diplomas", () => new DuoLookupDto( SeedData.Diplomas.Select(d => d.ToDto()).ToList(), new ManualDiplomaPolicyDto( DiplomaRules.ManualProfessions(), DiplomaRules.ManualQuestions().Select(q => q.ToDto()).ToList()))); api.MapGet("/intake/policy", () => new IntakePolicyDto(IntakePolicy.ScholingThreshold)); // --- Stamdata maintenance (ADR-0004): generic, schema-driven reads for the admin editor. // One pair of endpoints serves every business-editable table; the editor renders from the // reflected column schema and produces an edited JSON file the admin drops into the repo // (the existing CI build + StamdataValidationTests stay the authority — no write endpoint). // Admin-gated, mirroring OrgAdmin. --- api.MapGet("/stamdata", (HttpContext ctx) => StamdataAdmin(ctx, () => Results.Ok(StamdataCatalog.All.Select(t => new StamdataTableSummaryDto(t.Id, t.Label, t.Columns.Select(ToColumnDto).ToList(), t.Temporal)).ToList()))) .Gate("StamdataAdmin") .WithName("stamdataTables") .Produces>() .ProducesProblem(StatusCodes.Status403Forbidden); // peildatum (optional): omitted = all rows (edit view); given = only rows valid on that // date (the temporal preview — "which mappings applied on date X"). api.MapGet("/stamdata/{table}", (string table, string? peildatum, HttpContext ctx) => StamdataAdmin(ctx, () => { var t = StamdataCatalog.Find(table); if (t is null) return Results.NotFound(); DateOnly? peildatumWaarde = null; // RB-16/BIO-019: DateOnly.Parse threw FormatException on unparseable input, surfacing as // an unhandled 500 (and, in Development, an exception detail leaked to the caller) — an // admin-gated but still user-supplied string needs the same 400 path every other bad-input // check in this file uses, not a crash. if (peildatum is { Length: > 0 } p) { if (!DateOnly.TryParse(p, out var parsed)) return Results.Problem(detail: $"Ongeldige peildatum '{p}'.", statusCode: StatusCodes.Status400BadRequest); peildatumWaarde = parsed; } var rows = peildatumWaarde is { } d ? t.RowsOn(d) : t.Rows(); return Results.Ok(new StamdataTableDto(t.Id, t.Label, t.Columns.Select(ToColumnDto).ToList(), t.Temporal, rows)); })) .Gate("StamdataAdmin") .WithName("stamdataTable") .Produces() .ProducesProblem(StatusCodes.Status400BadRequest) .ProducesProblem(StatusCodes.Status403Forbidden) .Produces(StatusCodes.Status404NotFound); // --- POST: submits. The server is the authority; it re-validates and decides. --- api.MapPost("/change-requests", (ChangeRequestRequest req, HttpContext ctx) => Submit(ctx, "telefoonwijziging", SubmissionRules.RejectPhoneChange(req.Telefoon))) .Produces() .ProducesProblem(StatusCodes.Status422UnprocessableEntity); // --- Document upload --- // --- reads --- // Server-owned category config per wizard. The FE renders these; it never hardcodes. api.MapGet("/uploads/categories", (string wizardId, string? diplomaHerkomst, string? taalvaardigheid) => new UploadCategoriesDto(DocumentRules.CategoriesFor(wizardId, diplomaHerkomst, taalvaardigheid).Select(c => c.ToDto()).ToList())); // Serve stored bytes so a re-opened wizard can preview/download an upload. Inline // for pdf/image (browser renders it), attachment otherwise (download). // Scoped like DELETE on the same resource (RB-01/BIO-004): the owning citizen, or a // behandelaar reading an aanvraag's linked documents. A foreign id 404s rather than // 403s, so the endpoint never confirms that a document id exists. api.MapGet("/uploads/{documentId}/content", (string documentId, HttpContext ctx) => { var doc = DocumentStore.Get(documentId); var allowed = ctx.Caller() switch { ZorgverlenerCaller z => doc?.Owner == z.Bsn, var caller => Authz.CanBeoordelen(caller), }; if (doc is null || !allowed) return Results.NotFound(); var inline = doc.ContentType == "application/pdf" || doc.ContentType.StartsWith("image/"); return Results.File(doc.Content, doc.ContentType, fileDownloadName: inline ? null : doc.FileName); }) .Produces(StatusCodes.Status200OK) .Produces(StatusCodes.Status404NotFound); // Poll-on-return: which of these client localIds have arrived at the BFF. api.MapGet("/uploads/status", (string? localIds, HttpContext ctx) => { var ids = (localIds ?? "").Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries); // Owner-scoped (RB-01/BIO-004): someone else's localId reads back as "unknown", the // same answer an id that never existed gets. var found = DocumentStore.ByLocalIds(ids, ctx.Zorgverlener().Bsn).ToDictionary(d => d.LocalId); var results = ids.Select(id => found.TryGetValue(id, out var d) ? new UploadStatusItemDto(id, "complete", d.DocumentId) : new UploadStatusItemDto(id, "unknown", null)).ToList(); return new UploadStatusDto(results); }); // --- writes --- // 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, HttpContext ctx, IDocumentSource documents) => { if (!request.HasFormContentType) return Results.Problem(detail: "Verwacht multipart/form-data.", statusCode: 400); var form = await request.ReadFormAsync(); var file = form.Files.GetFile("file"); string categoryId = form["categoryId"].ToString(), localId = form["localId"].ToString(), wizardId = form["wizardId"].ToString(); if (file is null || categoryId == "" || localId == "" || wizardId == "") return Results.Problem(detail: "Onvolledige upload.", statusCode: 400); var category = DocumentRules.Find(wizardId, categoryId); var reject = DocumentRules.RejectUpload(category, file.ContentType, file.Length); if (reject is not null) return Results.Problem(detail: reject, statusCode: 400); using var ms = new MemoryStream(); await file.CopyToAsync(ms); // 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(), ctx.Zorgverlener()); return Results.Created($"/api/v1/uploads/{response.DocumentId}", response); }) .ExcludeFromDescription(); // User delete: owner-scoped; 409 once linked to a finalised submission. api.MapDelete("/uploads/{documentId}", (string documentId, HttpContext ctx) => DocumentStore.DeleteOwned(documentId, ctx.Zorgverlener().Bsn) switch { DocumentStore.DeleteResult.Ok => Results.NoContent(), DocumentStore.DeleteResult.Linked => Results.Problem( detail: "Dit document is al gekoppeld aan een ingediende aanvraag en kan niet meer worden verwijderd.", statusCode: StatusCodes.Status409Conflict), _ => Results.NotFound(), }) .Produces(StatusCodes.Status204NoContent) .ProducesProblem(StatusCodes.Status409Conflict) .Produces(StatusCodes.Status404NotFound); // Admin delete: bypasses ownership, unlinks, and flags the submission for review. Gated // by the same CasesAdmin wrapper (cases:manage) the other admin-cases endpoints use // (RB-08/BIO-003) — it used to be gated by a standalone X-Admin header, outside Authz and // unaudited; CasesAdmin gives it the missing AuthzAuditStore row for free (RB-07). api.MapDelete("/admin/uploads/{documentId}", (string documentId, HttpContext ctx) => CasesAdmin(ctx, () => DocumentStore.AdminDelete(documentId, "admin") ? Results.NoContent() : Results.NotFound())) .Gate("CasesAdmin") .Produces(StatusCodes.Status204NoContent) .ProducesProblem(StatusCodes.Status403Forbidden) .Produces(StatusCodes.Status404NotFound); // --- Applications (aanvragen): the system of record the dashboard reads. --- // --- reads --- // 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("/aanvragen", (HttpContext ctx, IZaakSource zaken) => zaken.ListMyCases(ctx.Zorgverlener(), DateTimeOffset.UtcNow)); api.MapGet("/aanvragen/{id}", (string id, HttpContext ctx) => ApplicationStore.Get(id, ctx.Zorgverlener().Bsn) is { } a ? Results.Ok(a.ToDetailDto(DateTimeOffset.UtcNow)) : Results.NotFound()) .Produces() .Produces(StatusCodes.Status404NotFound); // --- writes --- api.MapPost("/aanvragen", (CreateAanvraagRequest 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, ctx.Zorgverlener().Bsn); if (a is null) return Results.Problem( detail: "U hebt al een concept van dit type. Rond dat eerst af of verwijder het.", statusCode: StatusCodes.Status409Conflict); return Results.Created($"/api/v1/aanvragen/{a.Id}", a.ToDetailDto(DateTimeOffset.UtcNow)); }) .Produces(StatusCodes.Status201Created) .ProducesProblem(StatusCodes.Status409Conflict); // Draft sync per step — idempotent; keep it debounced on the client (it is chatty). api.MapPut("/aanvragen/{id}", (string id, DraftSyncRequest req, HttpContext ctx) => { var owner = ctx.Zorgverlener().Bsn; // A citizen may only reference their own uploads in a draft — reject before the sync // writes a foreign document id into the aanvraag (ADR-0001: the FE holds no authority). if (req.DocumentIds is { } ids && DocumentStore.ForeignIds(ids, owner) is { Count: > 0 } foreign) return Results.Problem( detail: $"Onbekend of niet-eigen document(en): {string.Join(", ", foreign)}.", statusCode: StatusCodes.Status400BadRequest); return ApplicationStore.SyncDraft(id, owner, req.Draft, req.StepIndex, req.StepCount, req.DocumentIds) ? Results.NoContent() : Results.NotFound(); }) .Produces(StatusCodes.Status204NoContent) .ProducesProblem(StatusCodes.Status400BadRequest) .Produces(StatusCodes.Status404NotFound); // Cancel a Concept (cascades to its unlinked documents). Submitted aanvragen cannot // be withdrawn (out of scope — no "intrekken"). api.MapDelete("/aanvragen/{id}", (string id, HttpContext ctx) => { var a = ApplicationStore.Get(id, ctx.Zorgverlener().Bsn); if (a is null) return Results.NotFound(); if (a is not Aanvraag.Concept) return Results.Problem(detail: "Een ingediende aanvraag kan niet worden geannuleerd.", statusCode: StatusCodes.Status409Conflict); ApplicationStore.Delete(id, ctx.Zorgverlener().Bsn); return Results.NoContent(); }) .Produces(StatusCodes.Status204NoContent) .ProducesProblem(StatusCodes.Status409Conflict) .Produces(StatusCodes.Status404NotFound); // Submit runs the server-owned rules, sets autoApprovable, and transitions the // aanvraag. handmatig no longer 422s (ADR-0002): it becomes a manual (pending) case. api.MapPost("/aanvragen/{id}/submit", (string id, AanvraagIndienenRequest req, HttpContext ctx, IZaakSource zaken, IDocumentSource documents) => { var existing = ApplicationStore.Get(id, ctx.Zorgverlener().Bsn); if (existing is null) return Results.NotFound(); if (existing is not Aanvraag.Concept) return Results.Problem(detail: "Aanvraag is al ingediend.", statusCode: StatusCodes.Status409Conflict); // Per wizard type: what rejects the submission (→ Afgewezen) and whether it auto-approves. (string? reject, bool autoApprovable) = existing.Type switch { "registratie" => (null, req.DiplomaHerkomst == "duo"), _ /* herregistratie | intake */ => (SubmissionRules.RejectZeroUren(req.Uren ?? 0), true), }; // WP-69: intake-only (herregistratie has no scholing question) — guarded by `reject is // null` so a { uren: 0 } submission is still decided on merit (RejectZeroUren) and // completeness is moot; placed before the document-ownership check and // ApplicationStore.Submit so a rejected submit leaves the aanvraag a Concept (retryable). if (existing.Type == "intake" && reject is null && IntakePolicy.RejectIncompleteScholing(req.Uren ?? 0, req.AanvullendeScholing, req.ScholingPunten) is { } incompleteScholing) return Results.Problem(detail: incompleteScholing, statusCode: StatusCodes.Status400BadRequest); var docs = req.Documents; var documentIds = docs?.Where(d => d.Channel == "digital" && d.DocumentId is not null).Select(d => d.DocumentId!).ToList(); // A citizen may only submit their own uploads — reject before the submit writes a // foreign document id onto the aanvraag (ADR-0001: the FE holds no authority). if (documentIds is { Count: > 0 } && DocumentStore.ForeignIds(documentIds, ctx.Zorgverlener().Bsn) is { Count: > 0 } foreignIds) return Results.Problem( detail: $"Onbekend of niet-eigen document(en): {string.Join(", ", foreignIds)}.", statusCode: StatusCodes.Status400BadRequest); var submitted = ApplicationStore.Submit(id, ctx.Zorgverlener().Bsn, reject, autoApprovable, documentIds); if (submitted is null) return Results.Conflict(); app.Logger.LogInformation( "aanvraag submit id={Id} type={Type} outcome={Outcome} auto={Auto} reference={Reference}", id, existing.Type, reject is null ? "accepted" : "rejected", autoApprovable, submitted.Referentie); // 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). 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. // // WP-60: the local submit above already committed — it is never rolled back on a ZGW // failure (an orphan zaak from a rolled-back-then-retried submit is worse than a flagged // one, see openzaak-integration.md's "Write resilience" section). Each ZGW half is caught // separately so a create-zaak failure doesn't also skip the (still-local) document link. var referentie = submitted.Referentie; var status = submitted.ToStatusDto(DateTimeOffset.UtcNow); string? zaakUrl = null; try { (referentie, status, zaakUrl) = zaken.CreateZaak(submitted, DateTimeOffset.UtcNow, ctx.Caller()); if (zaakUrl is not null) ApplicationStore.SetZaakUrl(id, zaakUrl); } catch (Exception ex) { RecordZgwDivergence(ctx, id, referentie, ex); } // 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) { try { documents.LinkToZaak(documentIds, zaakUrl, ctx.Caller()); } catch (Exception ex) { RecordZgwDivergence(ctx, id, referentie, ex); } } return Results.Ok(new AanvraagIndienenResponse(referentie, status)); }) .Produces() .ProducesProblem(StatusCodes.Status400BadRequest) .ProducesProblem(StatusCodes.Status409Conflict) .Produces(StatusCodes.Status404NotFound); // --- Admin cases (WP-36): cross-owner list + admin delete, gated by `cases:manage`. --- // --- reads --- api.MapGet("/admin/cases", (HttpContext ctx, IZaakSource zaken) => CasesAdmin(ctx, () => Results.Ok(zaken.ListCases(DateTimeOffset.UtcNow)))) .Gate("CasesAdmin") .Produces>() .ProducesProblem(StatusCodes.Status403Forbidden); // Queryable authz/PII-reveal audit trail (WP-41) — data-minimised, no PII. Admin-gated // via the existing CasesAdmin (cases:manage); a dedicated audit:read cap is a later refinement. api.MapGet("/admin/audit", (HttpContext ctx) => CasesAdmin(ctx, () => Results.Ok(AuthzAuditStore.List() .Select(a => new AuthzAuditDto(a.At.ToString("o"), a.Action, a.Resource, a.Decision, a.Role, a.CorrelationId)) .ToList()))) .Gate("CasesAdmin") .Produces>() .ProducesProblem(StatusCodes.Status403Forbidden); // --- writes --- // Admin delete removes ANY case (any owner, submitted or not) — unlike the user-facing // DELETE /aanvragen/{id}. A missing id is a 404. api.MapDelete("/admin/cases/{id}", (string id, HttpContext ctx) => CasesAdmin(ctx, () => { if (!ApplicationStore.DeleteAny(id)) return Results.NotFound(); app.Logger.LogInformation("admin case delete id={Id}", id); return Results.NoContent(); })) .Gate("CasesAdmin") .Produces(StatusCodes.Status204NoContent) .Produces(StatusCodes.Status404NotFound) .ProducesProblem(StatusCodes.Status403Forbidden); // --- Werkvoorraad (WP-64): the behandelportal's queue of aanvragen needing treatment. --- // Cross-owner like /admin/cases, but gated by the medewerker capability (`CanBeoordelen`, // WP-62) rather than the admin role, and pre-filtered to the two "still open" status tags — // a behandelaar never needs to see a Concept (not their business yet) or a terminal case. api.MapGet("/werkvoorraad", (HttpContext ctx, IZaakSource zaken) => Beoordelen(ctx, "werkvoorraad", () => Results.Ok(zaken.ListCases(DateTimeOffset.UtcNow) .Where(c => c.Status.Tag is "Ingediend" or "InBehandeling") .ToList()))) .Gate("Beoordelen") .Produces>() .ProducesProblem(StatusCodes.Status403Forbidden); // --- Beoordeling (WP-65): one aanvraag's case-treatment detail — read side only (recording // a decision is WP-65's second half). Reads through IZaakSource.ListCases (no new seam method: // adding one now would force an OpenZaak get-by-id + mapper, which is WP-66's surface) — O(n) // over a POC-sized table. A Concept isn't a case a behandelaar can treat yet, so it 404s here // same as an unknown id (only /aanvragen/{id}, citizen-scoped, shows a Concept). api.MapGet("/beoordeling/{id}", (string id, HttpContext ctx, IZaakSource zaken) => Beoordelen(ctx, $"aanvraag/{id}", () => { var c = zaken.ListCases(DateTimeOffset.UtcNow).FirstOrDefault(x => x.Id == id); if (c is null || c.Status.Tag == "Concept") return Results.NotFound(); var docs = DocumentStore.ByIds(c.DocumentIds) .Select(d => new BeoordelingDocumentDto(d.DocumentId, d.CategoryId, d.FileName)).ToList(); // Belt and braces: ToAdminSummaryDto already masks the local source (RB-03) and // MaskTail is idempotent, but IZaakSource has a second implementation whose Owner // is mapped from OpenZaak, so this stays as the guarantee for this response. var masked = c with { Owner = Pii.MaskTail(c.Owner!, 3) }; // WP-68 (F3): non-throwing — c.Status.Tag crosses the IZaakSource wire boundary, so an // unrecognised tag degrades to "cannot decide" instead of a 500. var canBesluiten = Enum.TryParse(c.Status.Tag, out var tag) && BeoordelingRules.CanDecide(tag); var decisions = new BeoordelingDecisionsDto(canBesluiten); return Results.Ok(new BeoordelingViewDto(masked, docs, decisions)); })) .Gate("Beoordelen") .Produces() .ProducesProblem(StatusCodes.Status403Forbidden) .Produces(StatusCodes.Status404NotFound); // --- Besluit (WP-65b/66): record a behandelaar's decision, advancing the WP-63 status // lifecycle. The local write runs against ApplicationStore directly (not the IZaakSource // seam) — same reasoning as the GET above. The transition-legality check // (BeoordelingRules.CanDecide) is the SAME function the GET's canBesluiten flag uses, // so the two can never drift — and (WP-68 F2) it now runs inside ApplicationStore.RecordBesluit's // write lock rather than here, so two concurrent besluiten can't both pass it before either // writes. WP-66: once the local decision has committed, IZaakSource also gets a chance to // advance the ZGW-side zaak status — LocalZaakSource no-ops, OpenZaakZaakSource POSTs a new // Statussen entry (see its RecordBesluit). api.MapPost("/beoordeling/{id}/besluit", (string id, RecordBesluitRequest req, HttpContext ctx, IZaakSource zaken) => Beoordelen(ctx, $"aanvraag/{id}/besluit", () => { if (!Enum.TryParse(req.Besluit, out var besluit)) return Results.Problem(detail: $"Onbekend besluit '{req.Besluit}'.", statusCode: StatusCodes.Status400BadRequest); // WP-68 (F6): moved to BeoordelingRules.RequiresToelichting — same rule, now unit-testable. if (BeoordelingRules.RequiresToelichting(besluit) && string.IsNullOrWhiteSpace(req.Toelichting)) return Results.Problem(detail: "Toelichting is verplicht bij dit besluit.", statusCode: StatusCodes.Status400BadRequest); var now = DateTimeOffset.UtcNow; // Real bug fix (WP-66): `id` is the FE-facing case id from IZaakSource.ListCases — under // OpenZaakZaakSource that's the ZGW zaak's own uuid, not this store's primary key (a // ListCases lookup, not ApplicationStore.GetAny(id), same seam the GET sibling above // uses), so resolve the case first and go to the local Aanvraag via its Referentie // (see ApplicationStore.GetByReferentie). var c = zaken.ListCases(now).FirstOrDefault(x => x.Id == id); var a = c?.Status.Referentie is { } referentie ? ApplicationStore.GetByReferentie(referentie) : null; if (a is null) return Results.NotFound(); var (outcome, updated) = ApplicationStore.RecordBesluit(a.Id, besluit, req.Toelichting, now); if (outcome == ApplicationStore.RecordBesluitOutcome.NotFound) return Results.NotFound(); if (outcome == ApplicationStore.RecordBesluitOutcome.Conflict) return Results.Problem( detail: "Deze aanvraag staat geen besluit meer toe in de huidige status.", statusCode: StatusCodes.Status409Conflict); app.Logger.LogInformation("aanvraag besluit id={Id} besluit={Besluit}", a.Id, besluit); // RB-07/BIO-007: the gate above records that a behandelaar was allowed to act; this // records what they decided. Without it /beheer/audit cannot answer "who rejected this // aanvraag", which is the question the trail exists for. AuditAuthz(ctx, "aanvraag:besluit", $"aanvraag/{a.Id}/{besluit}", true, Authz.ResolvePrincipal(ctx)); // WP-60: the local decision above already committed — a ZGW failure here is caught and // flagged rather than allowed to diverge silently, same handling as submit's create-zaak // and document-link writes. try { zaken.RecordBesluit(updated!, besluit, req.Toelichting, now, ctx.Caller()); } catch (Exception ex) { // WP-73: Aanvraag.Decided's Referentie is required/non-null — no `?? a.Id` fallback needed. RecordZgwDivergence(ctx, a.Id, updated!.Referentie, ex); } return Results.Ok(new RecordBesluitResponse(updated!.ToStatusDto(now))); })) .Gate("Beoordelen") .Produces() .ProducesProblem(StatusCodes.Status400BadRequest) .ProducesProblem(StatusCodes.Status403Forbidden) .ProducesProblem(StatusCodes.Status409Conflict) .Produces(StatusCodes.Status404NotFound); // OpenZaak's Notificaties API (NRC) calls this on every zaak event once an `abonnement` is // provisioned (WP-52, out-of-band — see openzaak-integration.md, no app code subscribes it). // The caller is NRC, not a user: no Principal, so this audits via AuthzAuditStore directly // rather than the Principal-shaped AuditAuthz helper below. A plain shared secret (not a // JWT — that's only for this BFF's OUTBOUND ZGW calls) compared in fixed time; an unconfigured // secret always rejects. api.MapPost("/zgw/notificaties", (HttpContext ctx, NotificatieDto body) => { var expected = zgw.NotificatieAuthorization; var actual = ctx.Request.Headers.Authorization.ToString(); var allowed = !string.IsNullOrEmpty(expected) && CryptographicOperations.FixedTimeEquals(Encoding.UTF8.GetBytes(actual), Encoding.UTF8.GetBytes(expected)); var cid = ctx.Items.TryGetValue("CorrelationId", out var v) ? (string)v! : "none"; app.Logger.LogInformation( "authz action={Action} resource={Resource} decision={Decision} role={Role} correlationId={Cid}", "zgw:notificatie", body.HoofdObject, allowed ? "allow" : "deny", "nrc", cid); AuthzAuditStore.Record("zgw:notificatie", body.HoofdObject, allowed, "nrc", cid); if (!allowed) return Results.Unauthorized(); // ponytail: nothing to invalidate — /admin/cases above already reads IZaakSource fresh // every call, no cache exists anywhere in this backend. Add real invalidation here if/when // one is introduced; today a valid notification's only effect is the audit trail proving // the webhook round-trip works. return Results.NoContent(); }) // NRC calls this directly, not the FE — same "hand-written, no client codegen" seam as // /uploads and /brief/reveal-bignummer. .ExcludeFromDescription(); // PRD-0002 §6: coarse, role-derived capabilities for nav/menu-level checks (NOT // tied to a specific brief's live status — see BriefDecisionsDto for that). // WP-64: `aanvraag:beoordelen` is caller-kind-derived (CanBeoordelen), not role-derived like // the rest of RoleCapabilities — appended here rather than folded into that switch, since it // depends on CallerIdentity (medewerker rollen), not the dev X-Role stand-in. api.MapGet("/me", (HttpContext ctx) => { var caps = Authz.RoleCapabilities(Authz.ResolvePrincipal(ctx)).ToList(); if (Authz.CanBeoordelen(ctx.Caller())) caps.Add("aanvraag:beoordelen"); return new MeDto(caps); }) .Produces(); // Feature flags (WP-47). GET is readable by any principal (it drives FE gating); the toggle is // admin-only. Catalog is code; state is the runtime override in SQLite. api.MapGet("/flags", () => Results.Ok(FeatureFlagStore.All().Select(f => new FeatureFlagDto(f.Key, f.Description, f.Enabled)).ToList())) .Produces>(); api.MapPut("/admin/flags/{key}", (string key, SetFeatureFlagRequest req, HttpContext ctx) => FlagsAdmin(ctx, $"feature-flags/{key}={req.Enabled}", () => FeatureFlagStore.Set(key, req.Enabled) ? Results.NoContent() : Results.NotFound())) .Gate("FlagsAdmin") .Produces(StatusCodes.Status204NoContent) .Produces(StatusCodes.Status404NotFound) .ProducesProblem(StatusCodes.Status403Forbidden); // --- Brief (letter composition). One demo brief per owner; the server owns the // status machine + authorization (Authz, PRD-0002 phase P1). Principal is a // dev-only stand-in via X-Role (mirrors the FE ?role= toggle) — no real // identities in this POC. --- // --- reads --- api.MapGet("/brief", (HttpContext ctx) => { // RB-23/CQ-007: a read that used to allocate a row on first call. The owner's first // draft now comes only from the explicit POST /brief/reset (BriefStore.ResetAndCreate) // — this GET is a pure query and 404s when there is nothing to read yet. var e = BriefStore.Get(ctx.Zorgverlener().Bsn); if (e is null) return Results.NotFound(); return Results.Ok(ToView(ctx, e)); }) .Produces() .Produces(StatusCodes.Status404NotFound); // Server-rendered HTML preview (WP-25): "what you compose is what is sent" — the // same LetterHtml.Render a sent brief archived. Hand-written on the FE (fetch → // blob → new tab), so excluded from the OpenAPI doc, same seam as uploads. Sent // letters serve their frozen archive; anything else renders live with a watermark. api.MapGet("/brief/preview", (HttpContext ctx) => { // RB-23: BriefStore.GetOrCreate is gone (split into Get + ResetAndCreate). This GET // must not create a brief as a side effect either, so it 404s under the same // precondition as GET /brief — in the running app the FE only reaches this endpoint // from the brief page, which has already loaded (and, if needed, reset) a brief. var e = BriefStore.Get(ctx.Zorgverlener().Bsn); if (e is null) return Results.NotFound(); if (e.Status.Tag == "sent" && e.ArchivedHtml is { } archived) return Results.Content(archived, "text/html"); var template = OrgTemplateStore.TemplateForBrief(e.SubOrgId, null); return Results.Content(LetterHtml.Render(e, template, Now(), watermark: true), "text/html"); }) .ExcludeFromDescription(); // --- writes --- api.MapPut("/brief", (SaveBriefRequest req, HttpContext ctx) => { var isDrafter = Authz.ResolvePrincipal(ctx).Role == PrincipalRole.Drafter; return BriefResult(ctx, BriefStore.Save(ctx.Zorgverlener().Bsn, req.Sections, isDrafter), "Alleen de opsteller mag de brief bewerken."); }) .Produces() .ProducesProblem(StatusCodes.Status403Forbidden) .ProducesProblem(StatusCodes.Status409Conflict); api.MapPost("/brief/submit", (HttpContext ctx) => { var isDrafter = Authz.ResolvePrincipal(ctx).Role == PrincipalRole.Drafter; var r = BriefStore.Submit(ctx.Zorgverlener().Bsn, isDrafter, Now()); LogBrief(ctx, "submit", r); return BriefResult(ctx, r, "Alleen de opsteller mag indienen."); }) .WithName("briefSubmit") // distinct name so the generated client method isn't `submit2` .Produces() .ProducesProblem(StatusCodes.Status403Forbidden) .ProducesProblem(StatusCodes.Status409Conflict); api.MapPost("/brief/approve", (HttpContext ctx) => { var r = BriefStore.Approve(ctx.Zorgverlener().Bsn, Authz.ResolvePrincipal(ctx), Now()); LogBrief(ctx, "approve", r); return BriefResult(ctx, r, "De beoordelaar mag niet de opsteller zijn."); }) .Produces() .ProducesProblem(StatusCodes.Status403Forbidden) .ProducesProblem(StatusCodes.Status409Conflict); api.MapPost("/brief/reject", (RejectBriefRequest req, HttpContext ctx) => { var r = BriefStore.Reject(ctx.Zorgverlener().Bsn, Authz.ResolvePrincipal(ctx), req.Comments, Now()); LogBrief(ctx, "reject", r); return BriefResult(ctx, r, "De beoordelaar mag niet de opsteller zijn."); }) .Produces() .ProducesProblem(StatusCodes.Status403Forbidden) .ProducesProblem(StatusCodes.Status409Conflict); 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(ctx.Zorgverlener().Bsn, Now()); LogBrief(ctx, "send", r); return BriefResult(ctx, r, "Versturen kan niet in deze status."); }) .Produces() .ProducesProblem(StatusCodes.Status409Conflict); // Field-level PII reveal (PRD-0002 §5c/§5d, phase P2): the case screen ships the // BIG-nummer masked (see ToView). Unmasking requires the reveal capability AND a // step-up (stubbed here as the X-Step-Up header); every attempt — allow or deny — is // audited with NO PII (AuditAuthz). The unmasked value is returned only on allow, // and never written to a log line. api.MapPost("/brief/reveal-bignummer", (HttpContext ctx) => { var principal = Authz.ResolvePrincipal(ctx); var canReveal = Authz.CanRevealBigNummer(principal); var steppedUp = ctx.Request.Headers["X-Step-Up"] == "true"; var allowed = canReveal && steppedUp; // RB-02/BIO-008: the resource ref is the brief, not the subject — a BSN concatenated // here lands in a persisted, admin-visible column the "no PII" guarantee covers. One // brief exists per owner, so the id added nothing the acting principal did not imply. AuditAuthz(ctx, "brief:reveal-bignummer", "brief", allowed, principal); if (!allowed) return Results.Problem( detail: canReveal ? "Aanvullende verificatie vereist om het BIG-nummer te tonen." : "U mag het BIG-nummer niet inzien.", statusCode: StatusCodes.Status403Forbidden); return Results.Ok(new RevealBigNummerResponse(SeedData.Registration.BigNummer)); }) // Hand-written fetch on the FE (needs a per-call X-Step-Up header) — excluded from the // OpenAPI doc, same seam as /brief/preview and uploads. .ExcludeFromDescription(); api.MapPost("/brief/reset", (HttpContext ctx) => { // Demo "start over": recreate a fresh draft. No guards — showcase affordance only. var e = BriefStore.ResetAndCreate(ctx.Zorgverlener().Bsn); return ToView(ctx, e); }) .WithName("briefReset") .Produces(); // --- Organization templates (WP-23): the second template axis — appearance and // identity per sub-organization. Admin-only (X-Role: admin, the same dev-stub seam // as drafter/approver); the same Authz check gates every endpoint and feeds the // `orgtemplate:edit` capability on /me, so emit and enforce cannot drift. --- // --- reads --- api.MapGet("/admin/org-templates", (HttpContext ctx) => OrgAdmin(ctx, () => Results.Ok(OrgTemplateStore.List()))) .Gate("OrgAdmin") .WithName("orgTemplates") .Produces>() .ProducesProblem(StatusCodes.Status403Forbidden); api.MapGet("/admin/org-template/{subOrgId}", (string subOrgId, HttpContext ctx) => OrgAdmin(ctx, () => OrgTemplateStore.AdminView(subOrgId) is { } view ? Results.Ok(view) : Results.NotFound())) .Gate("OrgAdmin") .WithName("orgTemplateGET") .Produces() .ProducesProblem(StatusCodes.Status403Forbidden) .Produces(StatusCodes.Status404NotFound); // Proefbrief: the admin's unpublished draft template rendered over a fixture // brief, so the appearance can be checked before publishing touches real letters. api.MapGet("/admin/org-template/{subOrgId}/preview", (string subOrgId, HttpContext ctx) => OrgAdmin(ctx, () => { var view = OrgTemplateStore.AdminView(subOrgId); if (view is null) return Results.NotFound(); var fixture = BriefSeed.NewBrief("proefbrief"); return Results.Content(LetterHtml.Render(fixture, view.Draft, Now(), watermark: true), "text/html"); })) .Gate("OrgAdmin") .ExcludeFromDescription(); // --- writes --- api.MapPut("/admin/org-template/{subOrgId}", (string subOrgId, SaveOrgTemplateRequest req, HttpContext ctx) => OrgAdmin(ctx, () => { var reject = OrgTemplateRules.RejectDraft(req.Draft); if (reject is not null) return Results.Problem(detail: reject, statusCode: StatusCodes.Status400BadRequest); return OrgTemplateStore.SaveDraft(subOrgId, req.Draft) is { } view ? Results.Ok(view) : Results.NotFound(); })) .Gate("OrgAdmin") .WithName("orgTemplatePUT") .Produces() .ProducesProblem(StatusCodes.Status400BadRequest) .ProducesProblem(StatusCodes.Status403Forbidden) .Produces(StatusCodes.Status404NotFound); api.MapPost("/admin/org-template/{subOrgId}/publish", (string subOrgId, HttpContext ctx) => OrgAdmin(ctx, () => { var r = OrgTemplateStore.Publish(subOrgId, Now()); if (r is not null) app.Logger.LogInformation("orgtemplate publish subOrg={SubOrg} version={Version} affected={Affected}", subOrgId, r.Version, r.AffectedUnsentBriefs); return r is not null ? Results.Ok(r) : Results.NotFound(); })) .Gate("OrgAdmin") .WithName("orgTemplatePublish") .Produces() .ProducesProblem(StatusCodes.Status403Forbidden) .Produces(StatusCodes.Status404NotFound); api.MapPost("/admin/org-template/{subOrgId}/rollback/{version:int}", (string subOrgId, int version, HttpContext ctx) => OrgAdmin(ctx, () => OrgTemplateStore.Rollback(subOrgId, version) is { } view ? Results.Ok(view) : Results.NotFound())) .Gate("OrgAdmin") .WithName("orgTemplateRollback") .Produces() .ProducesProblem(StatusCodes.Status403Forbidden) .Produces(StatusCodes.Status404NotFound); app.Run(); // One gate for every org-template endpoint — the enforce twin of the // `orgtemplate:edit` capability RoleCapabilities emits (single Authz source). // // RB-07/BIO-007: every gate below audits the real decision, allow *and* deny. Auditing // only denials left /beheer/audit able to answer "who was turned away" but not "who // changed this", which for a register whose integrity is the product is the wrong half // (PRD-0002 §8 lists approvals alongside denials). The allow row is written by the gate, // not by the endpoint, so a new admin endpoint cannot be added that forgets it. IResult OrgAdmin(HttpContext ctx, Func action) { var principal = Authz.ResolvePrincipal(ctx); var ok = Authz.CanManageOrgTemplates(principal); AuditAuthz(ctx, "orgtemplate:edit", "org-templates", ok, principal); if (ok) return action(); return Results.Problem(detail: "Alleen een beheerder mag organisatiesjablonen beheren.", statusCode: StatusCodes.Status403Forbidden); } // One gate for every stamdata read endpoint — the enforce twin of the `stamdata:edit` // capability RoleCapabilities emits (single Authz source). A denial is audited. IResult StamdataAdmin(HttpContext ctx, Func action) { var principal = Authz.ResolvePrincipal(ctx); var ok = Authz.CanEditStamdata(principal); AuditAuthz(ctx, "stamdata:edit", "stamdata", ok, principal); if (ok) return action(); return Results.Problem(detail: "Alleen een beheerder mag stamdata onderhouden.", statusCode: StatusCodes.Status403Forbidden); } // One gate for every admin-cases endpoint — the enforce twin of the `cases:manage` // capability RoleCapabilities emits (single Authz source, WP-36). A denial is audited. IResult CasesAdmin(HttpContext ctx, Func action) { var principal = Authz.ResolvePrincipal(ctx); var ok = Authz.CanManageCases(principal); AuditAuthz(ctx, "cases:manage", "cases", ok, principal); if (ok) return action(); return Results.Problem(detail: "Alleen een beheerder mag aanvragen beheren.", statusCode: StatusCodes.Status403Forbidden); } // One gate for every behandelaar endpoint (werkvoorraad, WP-64; beoordeling detail, WP-65) — // the enforce twin of `CanBeoordelen` (WP-62). Unlike the other *Admin gates above, this // checks the CallerIdentity directly (medewerker rollen), not a role-only Principal — a // zorgverlener with X-Role=admin still gets denied. `resource` feeds the denial's audit row. IResult Beoordelen(HttpContext ctx, string resource, Func action) { var ok = Authz.CanBeoordelen(ctx.Caller()); AuditAuthz(ctx, "aanvraag:beoordelen", resource, ok, Authz.ResolvePrincipal(ctx)); if (ok) return action(); return Results.Problem(detail: "Alleen een behandelaar mag aanvragen beoordelen.", statusCode: StatusCodes.Status403Forbidden); } // One gate for the feature-flag toggle — the enforce twin of `flags:manage` (WP-47). Takes a // per-call `resource` like Beoordelen does, because the toggle endpoint writes no log line of // its own (BIO-007): a bare "feature-flags" row would say a flag changed without saying which, // and this is the surface CQ-004/ADR-C-009 hinge on. IResult FlagsAdmin(HttpContext ctx, string resource, Func action) { var principal = Authz.ResolvePrincipal(ctx); var ok = Authz.CanManageFeatureFlags(principal); AuditAuthz(ctx, "flags:manage", resource, ok, principal); if (ok) return action(); return Results.Problem(detail: "Alleen een beheerder mag functievlaggen beheren.", statusCode: StatusCodes.Status403Forbidden); } static StamdataColumnDto ToColumnDto(StamdataColumn c) => new(c.Name, c.Type, c.IsKey, c.Options); // Authorization audit (PRD-0002 §8): access-relevant decisions recorded with NO PII — // action, resource ref, allow/deny, acting role, correlation id. Never the value that // was (or wasn't) revealed. Mirrors the no-PII Submit audit below. void AuditAuthz(HttpContext ctx, string action, string resource, bool allowed, Principal principal) { var cid = ctx.Items.TryGetValue("CorrelationId", out var v) ? (string)v! : "none"; app.Logger.LogInformation( "authz action={Action} resource={Resource} decision={Decision} role={Role} correlationId={Cid}", action, resource, allowed ? "allow" : "deny", principal.Role, cid); // Persist the queryable, data-minimised trail (WP-41) alongside the log line. AuthzAuditStore.Record(action, resource, allowed, principal.Role.ToString(), cid); } // WP-60: the local write already committed — this records that its ZGW counterpart didn't, // rather than letting the two sides diverge silently (openzaak-integration.md's "Write // resilience" section). Same audit trail AuditAuthz writes to (/beheer/audit), so a // divergence is visible next to every other decision, not a separate mechanism. void RecordZgwDivergence(HttpContext ctx, string id, string referentie, Exception ex) { app.Logger.LogError(ex, "zgw divergence aanvraag={Id} reference={Reference}", id, referentie); ApplicationStore.SetZgwError(id, ex.Message); var cid = ctx.Items.TryGetValue("CorrelationId", out var v) ? (string)v! : "none"; AuthzAuditStore.Record("zgw:divergence", referentie, allowed: false, Authz.ResolvePrincipal(ctx).Role.ToString(), cid); } static string Now() => DateTimeOffset.UtcNow.ToString("o"); BriefViewDto ToView(HttpContext ctx, BriefEntity e) => new( e.ToDto(), BriefSeed.PassagesFor(e.Beroep), Authz.Decisions(Authz.ResolvePrincipal(ctx), e.Status.Tag, e.DrafterId), // Sent letters render with the version pinned at send; everything else follows // the sub-org's current published template (WP-23 immutability invariant). OrgTemplateStore.TemplateForBrief(e.SubOrgId, e.Status.Tag == "sent" ? e.SentOrgTemplateVersion : null), // The case this letter is about — joined from the seeded zorgverlener so the // behandel scherm can show whom/what it concerns without brief/ importing registratie. // The BIG-nummer ships MASKED by default (PRD-0002 §5c, field-level PII); the reveal // endpoint returns the full value, gated + audited. new CaseContextDto(SeedData.Registration.Naam, Pii.MaskTail(SeedData.Registration.BigNummer, 3), e.Beroep, BriefSeed.AanvraagReferentie)); // Emit (decision flags, via ToView) and enforce (Forbidden/Conflict below) both run // through Authz — see BriefStore.Review and Authz.CanActOn — so they cannot drift. IResult BriefResult(HttpContext ctx, (BriefStore.Outcome outcome, BriefEntity? entity) r, string forbiddenDetail) => r.outcome switch { BriefStore.Outcome.Ok => Results.Ok(ToView(ctx, r.entity!)), BriefStore.Outcome.Forbidden => Results.Problem(detail: forbiddenDetail, statusCode: StatusCodes.Status403Forbidden), _ => Results.Problem(detail: "Ongeldige overgang voor de huidige status van de brief.", statusCode: StatusCodes.Status409Conflict), }; // RB-07/BIO-007: every brief transition already funnelled through here for its log line, // so the audit row goes here too — a fifth transition cannot be added that logs but leaves // no trail. Resource is the bare "brief" (RB-02: never the owner's BSN); the decision is // the transition's own outcome, so a 403 or a 409 is as visible as a success. void LogBrief(HttpContext ctx, string action, (BriefStore.Outcome outcome, BriefEntity? entity) r) { app.Logger.LogInformation("brief {Action} outcome={Outcome} status={Status}", action, r.outcome, r.entity?.Status.Tag ?? "-"); AuditAuthz(ctx, "brief:" + action, "brief", r.outcome == BriefStore.Outcome.Ok, Authz.ResolvePrincipal(ctx)); } // Audit + outcome for a submit, with NO personal data: only kind, outcome, // generated reference and the caller's correlation id (the observability seam — a // real system ships this to structured logging / an audit store). A repeated // Idempotency-Key short-circuits to the first call's result — see IdempotencyStore // — so a retried submit dedupes instead of minting a second reference. The key is // scoped to the caller (RB-18/BIO-018): two callers who happen to send the same // client-chosen header value do not share a cached result. IResult Submit(HttpContext ctx, string kind, string? reject, IReadOnlyList? documents = null) { var cid = ctx.Items.TryGetValue("CorrelationId", out var v) ? (string)v! : "none"; var idemKey = ctx.Request.Headers.TryGetValue("Idempotency-Key", out var k) && !string.IsNullOrEmpty(k) ? $"{ctx.Caller().SubjectId}:{k}" : null; if (idemKey is not null && IdempotencyStore.TryGet(idemKey, out var cached)) { app.Logger.LogInformation("submit kind={Kind} outcome=replayed correlationId={Cid}", kind, cid); return cached!; } IResult result; if (reject is not null) { app.Logger.LogInformation("submit kind={Kind} outcome=rejected correlationId={Cid}", kind, cid); result = Results.Problem(detail: reject, statusCode: StatusCodes.Status422UnprocessableEntity); } else { if (documents is not null) { // Link digital documents (blocks later user delete) and record post-delivery // intent so a caseworker knows to expect the physical document. DocumentStore.Link(documents.Where(d => d.Channel == "digital" && d.DocumentId is not null).Select(d => d.DocumentId!)); foreach (var d in documents.Where(d => d.Channel == "post")) DocumentStore.Audit("post-delivery", d.DocumentId ?? "-", d.CategoryId, cid); } var reference = SubmissionRules.NewReference(); app.Logger.LogInformation( "submit kind={Kind} outcome=accepted reference={Reference} correlationId={Cid} at={At:o}", kind, reference, cid, DateTimeOffset.UtcNow); result = Results.Ok(new ReferentieResponse(reference)); } if (idemKey is not null) IdempotencyStore.Set(idemKey, result); return result; } // RB-12/BIO-016: a machine-checkable "this endpoint passes through one of the five admin // authz wrappers" signal, attached at mapping time. It has to be attached here — reflecting // over the compiled lambda at test time cannot see which local function a closure calls, but // endpoint metadata set when the route is mapped is exactly what EndpointDataSource exposes // to a test host. RouteInventoryTests.cs cross-checks every mapped route against either this // marker or an explicit, named allow-list — see that file for the actual safety net. // Public, not internal: RouteInventoryTests.cs (a separate assembly, no InternalsVisibleTo // wired up for one marker type) reads this metadata directly off EndpointDataSource. public sealed record AuthzGateMetadata(string Wrapper); public static class AuthzGateEndpointExtensions { public static TBuilder Gate(this TBuilder builder, string wrapper) where TBuilder : IEndpointConventionBuilder { builder.WithMetadata(new AuthzGateMetadata(wrapper)); return builder; } } // Exposed so the integration tests can spin up the app with WebApplicationFactory. public partial class Program { }