GET /uploads/{documentId}/content took only (string documentId) — no
HttpContext, so no authorization was possible. It streams diploma and
identity scans, protected by GUID unguessability alone, while DELETE on the
same resource has always been owner-scoped. GET /uploads/status had the same
shape and confirmed whether any client-chosen localId exists, plus its
documentId.
Both now take HttpContext. Content is readable by the owning
ZorgverlenerCaller or a caller passing Authz.CanBeoordelen — matched on the
caller kind rather than branched on a boolean, because ctx.Zorgverlener()
throws for a MedewerkerCaller and the behandelportal's beoordeling screen is
a legitimate reader. Status is scoped to ctx.Zorgverlener().Bsn via a new
owner parameter on DocumentStore.ByLocalIds (one call site).
404, not 403, on both: a foreign document id must not be distinguishable
from one that never existed, and a foreign localId reads back as "unknown".
Residual, recorded in the implementation note: both callers reach the URL as
a plain browser navigation (<a href> / previewUrl), which carries no identity
header and no interceptor, so StubIdentityProvider resolves it to the seeded
citizen. That is BIO-002 and belongs to RB-09; the links keep working today
only because one citizen owns every document in the POC.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
951 lines
49 KiB
C#
951 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.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("/registrations", (RegistratieRequest req, HttpContext ctx) =>
|
|
Submit(ctx, "registratie", SubmissionRules.RejectRegistratie(req.DiplomaHerkomst), req.Documents))
|
|
.Produces<ReferentieResponse>()
|
|
.ProducesProblem(StatusCodes.Status422UnprocessableEntity);
|
|
|
|
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();
|
|
var masked = c with { Owner = 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;
|
|
AuditAuthz(ctx, "brief:reveal-bignummer", "brief/" + ctx.Zorgverlener().Bsn, 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);
|
|
}
|
|
|
|
// Keep the last `keep` characters, mask the rest — mirrors the FE maskTail
|
|
// (src/app/shared/ui/debug-state/mask.ts) so wire redaction and the dev panel agree.
|
|
static string MaskTail(string value, int keep) =>
|
|
value.Length <= keep ? new string('*', value.Length)
|
|
: new string('*', value.Length - keep) + value[^keep..];
|
|
|
|
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, 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 { }
|