POST /registrations passed its Documents list straight to Submit, which calls
DocumentStore.Link on every digital documentId in it — and linking a document
blocks its owner from ever deleting it (DeleteOwned returns 409 Linked). That
path had no ForeignIds ownership check, so any authenticated citizen could
post another citizen's document id and permanently block them from deleting
their own diploma scan. POST /applications/{id}/submit, the endpoint actually
in use, has had that guard since it was written.
Deleted rather than guarded: the endpoint is dead. No frontend caller, and
the whole registratie flow goes through /applications/{id}/submit.
RegistratieRequest went with it, and so did SubmissionRules.RejectRegistratie
— reachable only from here, and contradicted by the live path, which treats a
handmatig diploma as "does not auto-approve" rather than a 422 rejection. Its
own message said as much while being returned as a rejection. That last part
is a judgement call beyond the ticket's wording; reverting the two
SubmissionRules hunks restores it in isolation.
Coverage moved rather than vanished: the problem+json shape assertion is now
on /change-requests (the other endpoint on the same Submit helper), and the
linked-delete 409 test goes through the real submit path.
swagger.json, the generated client and the behaviour spec regenerated.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
947 lines
49 KiB
C#
947 lines
49 KiB
C#
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.
|
|
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
|
|
// OpenZaak client (needs the base URLs + credentials in the Zgw config section).
|
|
var zgw = builder.Configuration.GetSection("Zgw").Get<ZgwOptions>() ?? new ZgwOptions();
|
|
if (zgw.Enabled)
|
|
{
|
|
builder.Services.AddSingleton(zgw);
|
|
builder.Services.AddSingleton<ZgwTokenProvider>();
|
|
// 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<IZaakSource, OpenZaakZaakSource>(c => c.Timeout = TimeSpan.FromSeconds(15));
|
|
// WP-51: the documents (Documenten API / DRC) seam — same pattern as IZaakSource above.
|
|
var documentClientBuilder = builder.Services.AddHttpClient<IDocumentSource, OpenZaakDocumentSource>(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<ZgwDiagnosticHandler>();
|
|
zaakClientBuilder.AddHttpMessageHandler<ZgwDiagnosticHandler>();
|
|
documentClientBuilder.AddHttpMessageHandler<ZgwDiagnosticHandler>();
|
|
}
|
|
}
|
|
else
|
|
{
|
|
builder.Services.AddSingleton<IZaakSource, LocalZaakSource>();
|
|
builder.Services.AddSingleton<IDocumentSource, LocalDocumentSource>();
|
|
}
|
|
|
|
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.
|
|
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);
|
|
|
|
// 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())))
|
|
.WithName("stamdataTables")
|
|
.Produces<List<StamdataTableSummaryDto>>()
|
|
.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();
|
|
var rows = peildatum is { Length: > 0 } p ? t.RowsOn(DateOnly.Parse(p)) : t.Rows();
|
|
return Results.Ok(new StamdataTableDto(t.Id, t.Label, t.Columns.Select(ToColumnDto).ToList(), t.Temporal, rows));
|
|
}))
|
|
.WithName("stamdataTable")
|
|
.Produces<StamdataTableDto>()
|
|
.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<ReferentieResponse>()
|
|
.ProducesProblem(StatusCodes.Status422UnprocessableEntity);
|
|
|
|
// --- Document upload ---
|
|
|
|
// 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()));
|
|
|
|
// 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();
|
|
|
|
// 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);
|
|
});
|
|
|
|
// 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 (seam): a real system requires an admin role; here an X-Admin header
|
|
// stands in. Bypasses ownership, unlinks, and flags the submission for review.
|
|
api.MapDelete("/admin/uploads/{documentId}", (string documentId, HttpContext ctx) =>
|
|
!IsAdmin(ctx) ? Results.StatusCode(StatusCodes.Status403Forbidden)
|
|
: DocumentStore.AdminDelete(documentId, "admin") ? Results.NoContent() : Results.NotFound())
|
|
.Produces(StatusCodes.Status204NoContent)
|
|
.Produces(StatusCodes.Status403Forbidden)
|
|
.Produces(StatusCodes.Status404NotFound);
|
|
|
|
// --- Applications (aanvragen): the system of record the dashboard 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("/applications", (HttpContext ctx, IZaakSource zaken) =>
|
|
zaken.ListMyCases(ctx.Zorgverlener(), DateTimeOffset.UtcNow));
|
|
|
|
api.MapGet("/applications/{id}", (string id, HttpContext ctx) =>
|
|
ApplicationStore.Get(id, ctx.Zorgverlener().Bsn) is { } a
|
|
? Results.Ok(a.ToDetailDto(DateTimeOffset.UtcNow))
|
|
: Results.NotFound())
|
|
.Produces<ApplicationDetailDto>()
|
|
.Produces(StatusCodes.Status404NotFound);
|
|
|
|
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, 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/applications/{a.Id}", a.ToDetailDto(DateTimeOffset.UtcNow));
|
|
})
|
|
.Produces<ApplicationDetailDto>(StatusCodes.Status201Created)
|
|
.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, 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("/applications/{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("/applications/{id}/submit", (string id, SubmitApplicationRequest 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 SubmitApplicationResponse(referentie, status));
|
|
})
|
|
.Produces<SubmitApplicationResponse>()
|
|
.ProducesProblem(StatusCodes.Status400BadRequest)
|
|
.ProducesProblem(StatusCodes.Status409Conflict)
|
|
.Produces(StatusCodes.Status404NotFound);
|
|
|
|
// --- Admin cases (WP-36): cross-owner list + admin delete, gated by `cases:manage`. ---
|
|
api.MapGet("/admin/cases", (HttpContext ctx, IZaakSource zaken) => CasesAdmin(ctx, () =>
|
|
Results.Ok(zaken.ListCases(DateTimeOffset.UtcNow))))
|
|
.Produces<List<ApplicationSummaryDto>>()
|
|
.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())))
|
|
.Produces<List<ApplicationSummaryDto>>()
|
|
.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 /applications/{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<AanvraagStatusTag>(c.Status.Tag, out var tag) && BeoordelingRules.CanDecide(tag);
|
|
var decisions = new BeoordelingDecisionsDto(canBesluiten);
|
|
return Results.Ok(new BeoordelingViewDto(masked, docs, decisions));
|
|
}))
|
|
.Produces<BeoordelingViewDto>()
|
|
.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<Besluit>(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);
|
|
|
|
// 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)));
|
|
}))
|
|
.Produces<RecordBesluitResponse>()
|
|
.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();
|
|
|
|
// Admin delete removes ANY case (any owner, submitted or not) — unlike the user-facing
|
|
// DELETE /applications/{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();
|
|
}))
|
|
.Produces(StatusCodes.Status204NoContent)
|
|
.Produces(StatusCodes.Status404NotFound)
|
|
.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())))
|
|
.Produces<List<AuthzAuditDto>>()
|
|
.ProducesProblem(StatusCodes.Status403Forbidden);
|
|
|
|
// 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<MeDto>();
|
|
|
|
// 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<List<FeatureFlagDto>>();
|
|
|
|
api.MapPut("/admin/flags/{key}", (string key, SetFeatureFlagRequest req, HttpContext ctx) => FlagsAdmin(ctx, () =>
|
|
FeatureFlagStore.Set(key, req.Enabled) ? Results.NoContent() : Results.NotFound()))
|
|
.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 X-Admin seam and the FE ?role=
|
|
// toggle) — no real identities in this POC. ---
|
|
|
|
api.MapGet("/brief", (HttpContext ctx) =>
|
|
{
|
|
var e = BriefStore.GetOrCreate(ctx.Zorgverlener().Bsn);
|
|
return ToView(ctx, e);
|
|
})
|
|
.Produces<BriefViewDto>();
|
|
|
|
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<BriefViewDto>()
|
|
.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("submit", r);
|
|
return BriefResult(ctx, r, "Alleen de opsteller mag indienen.");
|
|
})
|
|
.WithName("briefSubmit") // distinct name so the generated client method isn't `submit2`
|
|
.Produces<BriefViewDto>()
|
|
.ProducesProblem(StatusCodes.Status403Forbidden)
|
|
.ProducesProblem(StatusCodes.Status409Conflict);
|
|
|
|
api.MapPost("/brief/approve", (HttpContext ctx) =>
|
|
{
|
|
var r = BriefStore.Approve(ctx.Zorgverlener().Bsn, Authz.ResolvePrincipal(ctx), Now());
|
|
LogBrief("approve", r);
|
|
return BriefResult(ctx, r, "De beoordelaar mag niet de opsteller zijn.");
|
|
})
|
|
.Produces<BriefViewDto>()
|
|
.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("reject", r);
|
|
return BriefResult(ctx, r, "De beoordelaar mag niet de opsteller zijn.");
|
|
})
|
|
.Produces<BriefViewDto>()
|
|
.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("send", r);
|
|
return BriefResult(ctx, r, "Versturen kan niet in deze status.");
|
|
})
|
|
.Produces<BriefViewDto>()
|
|
.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();
|
|
|
|
// 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) =>
|
|
{
|
|
var e = BriefStore.GetOrCreate(ctx.Zorgverlener().Bsn);
|
|
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();
|
|
|
|
// 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");
|
|
}))
|
|
.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<BriefViewDto>();
|
|
|
|
// --- 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. ---
|
|
|
|
api.MapGet("/admin/org-templates", (HttpContext ctx) => OrgAdmin(ctx, () =>
|
|
Results.Ok(OrgTemplateStore.List())))
|
|
.WithName("orgTemplates")
|
|
.Produces<List<SubOrgSummaryDto>>()
|
|
.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()))
|
|
.WithName("orgTemplateGET")
|
|
.Produces<OrgTemplateAdminViewDto>()
|
|
.ProducesProblem(StatusCodes.Status403Forbidden)
|
|
.Produces(StatusCodes.Status404NotFound);
|
|
|
|
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();
|
|
}))
|
|
.WithName("orgTemplatePUT")
|
|
.Produces<OrgTemplateAdminViewDto>()
|
|
.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();
|
|
}))
|
|
.WithName("orgTemplatePublish")
|
|
.Produces<PublishOrgTemplateResponse>()
|
|
.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()))
|
|
.WithName("orgTemplateRollback")
|
|
.Produces<OrgTemplateAdminViewDto>()
|
|
.ProducesProblem(StatusCodes.Status403Forbidden)
|
|
.Produces(StatusCodes.Status404NotFound);
|
|
|
|
app.Run();
|
|
|
|
static bool IsAdmin(HttpContext ctx) => ctx.Request.Headers["X-Admin"] == "true";
|
|
|
|
// One gate for every org-template endpoint — the enforce twin of the
|
|
// `orgtemplate:edit` capability RoleCapabilities emits (single Authz source). A denial
|
|
// is audited (PRD-0002 §8); the allow path is left un-logged (the endpoints log their
|
|
// own effect, e.g. publish).
|
|
IResult OrgAdmin(HttpContext ctx, Func<IResult> action)
|
|
{
|
|
var principal = Authz.ResolvePrincipal(ctx);
|
|
if (Authz.CanManageOrgTemplates(principal)) return action();
|
|
AuditAuthz(ctx, "orgtemplate:edit", "org-templates", false, principal);
|
|
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<IResult> action)
|
|
{
|
|
var principal = Authz.ResolvePrincipal(ctx);
|
|
if (Authz.CanEditStamdata(principal)) return action();
|
|
AuditAuthz(ctx, "stamdata:edit", "stamdata", false, principal);
|
|
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<IResult> action)
|
|
{
|
|
var principal = Authz.ResolvePrincipal(ctx);
|
|
if (Authz.CanManageCases(principal)) return action();
|
|
AuditAuthz(ctx, "cases:manage", "cases", false, principal);
|
|
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<IResult> action)
|
|
{
|
|
if (Authz.CanBeoordelen(ctx.Caller())) return action();
|
|
AuditAuthz(ctx, "aanvraag:beoordelen", resource, false, Authz.ResolvePrincipal(ctx));
|
|
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).
|
|
IResult FlagsAdmin(HttpContext ctx, Func<IResult> action)
|
|
{
|
|
var principal = Authz.ResolvePrincipal(ctx);
|
|
if (Authz.CanManageFeatureFlags(principal)) return action();
|
|
AuditAuthz(ctx, "flags:manage", "feature-flags", false, principal);
|
|
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),
|
|
};
|
|
|
|
void LogBrief(string action, (BriefStore.Outcome outcome, BriefEntity? entity) r) =>
|
|
app.Logger.LogInformation("brief {Action} outcome={Outcome} status={Status}",
|
|
action, r.outcome, r.entity?.Status.Tag ?? "-");
|
|
|
|
// 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.
|
|
IResult Submit(HttpContext ctx, string kind, string? reject, IReadOnlyList<DocumentRefDto>? 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)
|
|
? k.ToString()
|
|
: 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;
|
|
}
|
|
|
|
// Exposed so the integration tests can spin up the app with WebApplicationFactory.
|
|
public partial class Program { }
|