refactor: strip WP-/RB- ticket refs from backend (RD-19)
The backend half of the sweep RD-18 did for the front end. git blame holds the provenance and stays correct when the code moves; the comment names a closed ticket and tells the reader nothing the sentence around it does not. public/letter.css and LetterHtml.golden.html change together, because the renderer inlines the CSS and the golden file snapshots the result. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
@@ -39,20 +39,20 @@ 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
|
||||
// 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)
|
||||
// The per-request acting caller — resolved once (middleware, below)
|
||||
// into HttpContext.Items, consumed by Authz.ResolvePrincipal, ZgwTokenProvider.Mint(caller), and
|
||||
// every store call site that used to hardcode DocumentStore.DemoOwner. Stub today (X-Role/
|
||||
// X-Subject for a zorgverlener, X-Medewerker/X-Rollen for a medewerker); a real
|
||||
// DigiD/employee-SSO provider swaps in without touching a consumer.
|
||||
//
|
||||
// RB-09/BIO-002: StubIdentityProvider invents a citizen identity for any request with no
|
||||
// BIO-002: StubIdentityProvider invents a citizen identity for any request with no
|
||||
// credential at all — a production behandelportal build sends no X-Medewerker header, so it
|
||||
// used to authenticate every request as the seeded citizen (open on that citizen's own rights,
|
||||
// including CanRevealBigNummer). Registering the stub only in Development, and failing to
|
||||
@@ -65,10 +65,10 @@ if (builder.Environment.IsDevelopment())
|
||||
else if (builder.Environment.IsProduction())
|
||||
throw new InvalidOperationException(
|
||||
"No IIdentityProvider is registered for a Production environment. StubIdentityProvider " +
|
||||
"is Development-only (RB-09/BIO-002); there is no real DigiD/employee-SSO provider in " +
|
||||
"is Development-only (BIO-002); there is no real DigiD/employee-SSO provider in " +
|
||||
"this POC yet. Register one before deploying to Production.");
|
||||
|
||||
// WP-49: the cases (zaken) READ path goes through IZaakSource so a real ZGW backend
|
||||
// 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).
|
||||
@@ -77,11 +77,11 @@ if (zgw.Enabled)
|
||||
{
|
||||
builder.Services.AddSingleton(zgw);
|
||||
builder.Services.AddSingleton<ZgwTokenProvider>();
|
||||
// WP-60: a bounded client timeout matters once ZgwHttpClient retries — without one, the
|
||||
// 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.
|
||||
// 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
|
||||
@@ -101,10 +101,10 @@ else
|
||||
|
||||
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
|
||||
// Migrate on every startup, seed nothing: unlike SeedData's read-only
|
||||
// reference fixtures (registration/diplomas/notes — untouched by this change, 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
|
||||
// started empty and accumulated through normal use before this change 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();
|
||||
@@ -124,9 +124,9 @@ app.Use(async (ctx, next) =>
|
||||
await next(ctx);
|
||||
});
|
||||
|
||||
// WP-53: resolve the acting citizen once per request, right after correlation — everything
|
||||
// Resolve the acting citizen once per request, right after correlation — everything
|
||||
// downstream (Authz.ResolvePrincipal, the endpoints below) reads it via ctx.Caller() instead of
|
||||
// re-deriving "who" itself. RB-09/BIO-002: a null resolution is "no identity", not "the seeded
|
||||
// re-deriving "who" itself. BIO-002: a null resolution is "no identity", not "the seeded
|
||||
// citizen" — this is the one place that turns it into a response (401) rather than letting it
|
||||
// flow downstream as a silent identity substitution.
|
||||
var identityProvider = app.Services.GetRequiredService<IIdentityProvider>();
|
||||
@@ -142,7 +142,7 @@ app.Use(async (ctx, next) =>
|
||||
await next(ctx);
|
||||
});
|
||||
|
||||
// RB-15/BIO-015: the OpenAPI document + its UI are a genuine attack-surface reduction to
|
||||
// BIO-015: the OpenAPI document + its UI are a genuine attack-surface reduction to
|
||||
// gate — they enumerate every route, request/response shape and (via SwaggerUI's "Try it
|
||||
// out") let a caller fire requests straight from the browser. Development-only, like the
|
||||
// dev-role/scenario-toggle hatches this POC already keeps out of production builds
|
||||
@@ -150,8 +150,8 @@ app.Use(async (ctx, next) =>
|
||||
// Development). `dotnet swagger tofile` (npm run gen:api) is unaffected: Swashbuckle's CLI
|
||||
// resolves ISwaggerProvider straight out of the DI container to build swagger.json — it
|
||||
// never sends an HTTP request through this pipeline, so it never touches this middleware at
|
||||
// all, gated or not. Verified empirically (see rb-15.md) rather than assumed, per RB-09's
|
||||
// note that this exact file has already broken that tool once.
|
||||
// all, gated or not. Verified empirically (see rb-15.md) rather than assumed — a past
|
||||
// regression already broke that tool once in this exact file.
|
||||
if (app.Environment.IsDevelopment())
|
||||
{
|
||||
app.UseSwagger();
|
||||
@@ -213,7 +213,7 @@ api.MapGet("/stamdata/{table}", (string table, string? peildatum, HttpContext ct
|
||||
var t = StamdataCatalog.Find(table);
|
||||
if (t is null) return Results.NotFound();
|
||||
DateOnly? peildatumWaarde = null;
|
||||
// RB-16/BIO-019: DateOnly.Parse threw FormatException on unparseable input, surfacing as
|
||||
// BIO-019: DateOnly.Parse threw FormatException on unparseable input, surfacing as
|
||||
// an unhandled 500 (and, in Development, an exception detail leaked to the caller) — an
|
||||
// admin-gated but still user-supplied string needs the same 400 path every other bad-input
|
||||
// check in this file uses, not a crash.
|
||||
@@ -250,7 +250,7 @@ api.MapGet("/uploads/categories", (string wizardId, string? diplomaHerkomst, str
|
||||
|
||||
// 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
|
||||
// Scoped like DELETE on the same resource (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) =>
|
||||
@@ -272,7 +272,7 @@ api.MapGet("/uploads/{documentId}/content", (string documentId, HttpContext ctx)
|
||||
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
|
||||
// Owner-scoped (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)
|
||||
@@ -301,7 +301,7 @@ api.MapPost("/uploads", async (HttpRequest request, HttpContext ctx, IDocumentSo
|
||||
|
||||
using var ms = new MemoryStream();
|
||||
await file.CopyToAsync(ms);
|
||||
// WP-51: route through IDocumentSource — LocalDocumentSource is the same DocumentStore.Add
|
||||
// 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());
|
||||
@@ -325,8 +325,8 @@ api.MapDelete("/uploads/{documentId}", (string documentId, HttpContext ctx) =>
|
||||
|
||||
// Admin delete: bypasses ownership, unlinks, and flags the submission for review. Gated
|
||||
// by the same CasesAdmin wrapper (cases:manage) the other admin-cases endpoints use
|
||||
// (RB-08/BIO-003) — it used to be gated by a standalone X-Admin header, outside Authz and
|
||||
// unaudited; CasesAdmin gives it the missing AuthzAuditStore row for free (RB-07).
|
||||
// (BIO-003) — it used to be gated by a standalone X-Admin header, outside Authz and
|
||||
// unaudited; CasesAdmin gives it the missing AuthzAuditStore row for free.
|
||||
api.MapDelete("/admin/uploads/{documentId}", (string documentId, HttpContext ctx) => CasesAdmin(ctx, () =>
|
||||
DocumentStore.AdminDelete(documentId, "admin") ? Results.NoContent() : Results.NotFound()))
|
||||
.Gate("CasesAdmin")
|
||||
@@ -338,7 +338,7 @@ api.MapDelete("/admin/uploads/{documentId}", (string documentId, HttpContext ctx
|
||||
|
||||
// --- reads ---
|
||||
|
||||
// WP-53: routed through IZaakSource (like /admin/cases already was) rather than calling
|
||||
// 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.
|
||||
@@ -356,7 +356,7 @@ api.MapGet("/aanvragen/{id}", (string id, HttpContext ctx) =>
|
||||
|
||||
api.MapPost("/aanvragen", (CreateAanvraagRequest req, HttpContext ctx) =>
|
||||
{
|
||||
// Feature flag (WP-47): self-service registration can be closed by an admin.
|
||||
// Feature flag: 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);
|
||||
@@ -417,7 +417,7 @@ api.MapPost("/aanvragen/{id}/submit", (string id, AanvraagIndienenRequest req, H
|
||||
_ /* herregistratie | intake */ => (SubmissionRules.RejectZeroUren(req.Uren ?? 0), true),
|
||||
};
|
||||
|
||||
// WP-69: intake-only (herregistratie has no scholing question) — guarded by `reject is
|
||||
// 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).
|
||||
@@ -442,13 +442,13 @@ api.MapPost("/aanvragen/{id}/submit", (string id, AanvraagIndienenRequest req, H
|
||||
"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
|
||||
// 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
|
||||
// zero FE contract change either way). 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
|
||||
// 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.
|
||||
@@ -465,7 +465,7 @@ api.MapPost("/aanvragen/{id}/submit", (string id, AanvraagIndienenRequest req, H
|
||||
RecordZgwDivergence(ctx, id, referentie, ex);
|
||||
}
|
||||
|
||||
// WP-51: link the submitted documents to the zaak — LocalDocumentSource is exactly the
|
||||
// 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)
|
||||
@@ -487,7 +487,7 @@ api.MapPost("/aanvragen/{id}/submit", (string id, AanvraagIndienenRequest req, H
|
||||
.ProducesProblem(StatusCodes.Status409Conflict)
|
||||
.Produces(StatusCodes.Status404NotFound);
|
||||
|
||||
// --- Admin cases (WP-36): cross-owner list + admin delete, gated by `cases:manage`. ---
|
||||
// --- Admin cases: cross-owner list + admin delete, gated by `cases:manage`. ---
|
||||
|
||||
// --- reads ---
|
||||
|
||||
@@ -497,7 +497,7 @@ api.MapGet("/admin/cases", (HttpContext ctx, IZaakSource zaken) => CasesAdmin(ct
|
||||
.Produces<List<AanvraagSummaryDto>>()
|
||||
.ProducesProblem(StatusCodes.Status403Forbidden);
|
||||
|
||||
// Queryable authz/PII-reveal audit trail (WP-41) — data-minimised, no PII. Admin-gated
|
||||
// Queryable authz/PII-reveal audit trail — 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()
|
||||
@@ -522,9 +522,9 @@ api.MapDelete("/admin/cases/{id}", (string id, HttpContext ctx) => CasesAdmin(ct
|
||||
.Produces(StatusCodes.Status404NotFound)
|
||||
.ProducesProblem(StatusCodes.Status403Forbidden);
|
||||
|
||||
// --- Werkvoorraad (WP-64): the behandelportal's queue of aanvragen needing treatment. ---
|
||||
// Cross-owner like /admin/cases, but gated by the medewerker capability (`CanBeoordelen`,
|
||||
// WP-62) rather than the admin role, and pre-filtered to the two "still open" status tags —
|
||||
// --- Werkvoorraad: the behandelportal's queue of aanvragen needing treatment. ---
|
||||
// Cross-owner like /admin/cases, but gated by the medewerker capability (`CanBeoordelen`)
|
||||
// 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)
|
||||
@@ -534,9 +534,9 @@ api.MapGet("/werkvoorraad", (HttpContext ctx, IZaakSource zaken) => Beoordelen(c
|
||||
.Produces<List<AanvraagSummaryDto>>()
|
||||
.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)
|
||||
// --- Beoordeling: one aanvraag's case-treatment detail — read side only (recording
|
||||
// a decision is the second half). Reads through IZaakSource.ListCases (no new seam method:
|
||||
// adding one now would force an OpenZaak get-by-id + mapper, which is a later change's surface) — O(n)
|
||||
// over a POC-sized table. A Concept isn't a case a behandelaar can treat yet, so it 404s here
|
||||
// same as an unknown id (only /aanvragen/{id}, citizen-scoped, shows a Concept).
|
||||
api.MapGet("/beoordeling/{id}", (string id, HttpContext ctx, IZaakSource zaken) =>
|
||||
@@ -546,11 +546,11 @@ api.MapGet("/beoordeling/{id}", (string id, HttpContext ctx, IZaakSource zaken)
|
||||
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
|
||||
// Belt and braces: ToAdminSummaryDto already masks the local source 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
|
||||
// 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);
|
||||
@@ -561,13 +561,13 @@ api.MapGet("/beoordeling/{id}", (string id, HttpContext ctx, IZaakSource zaken)
|
||||
.ProducesProblem(StatusCodes.Status403Forbidden)
|
||||
.Produces(StatusCodes.Status404NotFound);
|
||||
|
||||
// --- Besluit (WP-65b/66): record a behandelaar's decision, advancing the WP-63 status
|
||||
// --- Besluit: record a behandelaar's decision, advancing the 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
|
||||
// so the two can never drift — and 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
|
||||
// writes. 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) =>
|
||||
@@ -575,12 +575,12 @@ api.MapPost("/beoordeling/{id}/besluit", (string id, RecordBesluitRequest req, H
|
||||
{
|
||||
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.
|
||||
// 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
|
||||
// Real bug fix: `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
|
||||
@@ -597,12 +597,12 @@ api.MapPost("/beoordeling/{id}/besluit", (string id, RecordBesluitRequest req, H
|
||||
statusCode: StatusCodes.Status409Conflict);
|
||||
|
||||
app.Logger.LogInformation("aanvraag besluit id={Id} besluit={Besluit}", a.Id, besluit);
|
||||
// RB-07/BIO-007: the gate above records that a behandelaar was allowed to act; this
|
||||
// BIO-007: the gate above records that a behandelaar was allowed to act; this
|
||||
// records what they decided. Without it /beheer/audit cannot answer "who rejected this
|
||||
// aanvraag", which is the question the trail exists for.
|
||||
AuditAuthz(ctx, "aanvraag:besluit", $"aanvraag/{a.Id}/{besluit}", true, Authz.ResolvePrincipal(ctx));
|
||||
|
||||
// WP-60: the local decision above already committed — a ZGW failure here is caught and
|
||||
// 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
|
||||
@@ -611,7 +611,7 @@ api.MapPost("/beoordeling/{id}/besluit", (string id, RecordBesluitRequest req, H
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
// WP-73: Aanvraag.Decided's Referentie is required/non-null — no `?? a.Id` fallback needed.
|
||||
// Aanvraag.Decided's Referentie is required/non-null — no `?? a.Id` fallback needed.
|
||||
RecordZgwDivergence(ctx, a.Id, updated!.Referentie, ex);
|
||||
}
|
||||
|
||||
@@ -625,7 +625,7 @@ api.MapPost("/beoordeling/{id}/besluit", (string id, RecordBesluitRequest req, H
|
||||
.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).
|
||||
// provisioned (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
|
||||
@@ -656,7 +656,7 @@ api.MapPost("/zgw/notificaties", (HttpContext ctx, NotificatieDto body) =>
|
||||
|
||||
// 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
|
||||
// `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) =>
|
||||
@@ -667,7 +667,7 @@ api.MapGet("/me", (HttpContext ctx) =>
|
||||
})
|
||||
.Produces<MeDto>();
|
||||
|
||||
// Feature flags (WP-47). GET is readable by any principal (it drives FE gating); the toggle is
|
||||
// Feature flags. 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()))
|
||||
@@ -690,7 +690,7 @@ api.MapPut("/admin/flags/{key}", (string key, SetFeatureFlagRequest req, HttpCon
|
||||
|
||||
api.MapGet("/brief", (HttpContext ctx) =>
|
||||
{
|
||||
// RB-23/CQ-007: a read that used to allocate a row on first call. The owner's first
|
||||
// CQ-007: a read that used to allocate a row on first call. The owner's first
|
||||
// draft now comes only from the explicit POST /brief/reset (BriefStore.ResetAndCreate)
|
||||
// — this GET is a pure query and 404s when there is nothing to read yet.
|
||||
var e = BriefStore.Get(ctx.Zorgverlener().Bsn);
|
||||
@@ -700,13 +700,13 @@ api.MapGet("/brief", (HttpContext ctx) =>
|
||||
.Produces<BriefViewDto>()
|
||||
.Produces(StatusCodes.Status404NotFound);
|
||||
|
||||
// Server-rendered HTML preview (WP-25): "what you compose is what is sent" — the
|
||||
// Server-rendered HTML preview: "what you compose is what is sent" — the
|
||||
// same LetterHtml.Render a sent brief archived. Hand-written on the FE (fetch →
|
||||
// blob → new tab), so excluded from the OpenAPI doc, same seam as uploads. Sent
|
||||
// letters serve their frozen archive; anything else renders live with a watermark.
|
||||
api.MapGet("/brief/preview", (HttpContext ctx) =>
|
||||
{
|
||||
// RB-23: BriefStore.GetOrCreate is gone (split into Get + ResetAndCreate). This GET
|
||||
// BriefStore.GetOrCreate is gone (split into Get + ResetAndCreate). This GET
|
||||
// must not create a brief as a side effect either, so it 404s under the same
|
||||
// precondition as GET /brief — in the running app the FE only reaches this endpoint
|
||||
// from the brief page, which has already loaded (and, if needed, reset) a brief.
|
||||
@@ -785,7 +785,7 @@ api.MapPost("/brief/reveal-bignummer", (HttpContext ctx) =>
|
||||
var canReveal = Authz.CanRevealBigNummer(principal);
|
||||
var steppedUp = ctx.Request.Headers["X-Step-Up"] == "true";
|
||||
var allowed = canReveal && steppedUp;
|
||||
// RB-02/BIO-008: the resource ref is the brief, not the subject — a BSN concatenated
|
||||
// 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);
|
||||
@@ -810,7 +810,7 @@ api.MapPost("/brief/reset", (HttpContext ctx) =>
|
||||
.WithName("briefReset")
|
||||
.Produces<BriefViewDto>();
|
||||
|
||||
// --- Organization templates (WP-23): the second template axis — appearance and
|
||||
// --- Organization templates: 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. ---
|
||||
@@ -886,7 +886,7 @@ app.Run();
|
||||
// One gate for every org-template endpoint — the enforce twin of the
|
||||
// `orgtemplate:edit` capability RoleCapabilities emits (single Authz source).
|
||||
//
|
||||
// RB-07/BIO-007: every gate below audits the real decision, allow *and* deny. Auditing
|
||||
// BIO-007: every gate below audits the real decision, allow *and* deny. Auditing
|
||||
// only denials left /beheer/audit able to answer "who was turned away" but not "who
|
||||
// changed this", which for a register whose integrity is the product is the wrong half
|
||||
// (PRD-0002 §8 lists approvals alongside denials). The allow row is written by the gate,
|
||||
@@ -914,7 +914,7 @@ IResult StamdataAdmin(HttpContext ctx, Func<IResult> action)
|
||||
}
|
||||
|
||||
// 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.
|
||||
// capability RoleCapabilities emits (single Authz source). A denial is audited.
|
||||
IResult CasesAdmin(HttpContext ctx, Func<IResult> action)
|
||||
{
|
||||
var principal = Authz.ResolvePrincipal(ctx);
|
||||
@@ -925,8 +925,8 @@ IResult CasesAdmin(HttpContext ctx, Func<IResult> action)
|
||||
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
|
||||
// One gate for every behandelaar endpoint (werkvoorraad; beoordeling detail) —
|
||||
// the enforce twin of `CanBeoordelen`. 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)
|
||||
@@ -938,7 +938,7 @@ IResult Beoordelen(HttpContext ctx, string resource, Func<IResult> action)
|
||||
statusCode: StatusCodes.Status403Forbidden);
|
||||
}
|
||||
|
||||
// One gate for the feature-flag toggle — the enforce twin of `flags:manage` (WP-47). Takes a
|
||||
// One gate for the feature-flag toggle — the enforce twin of `flags:manage`. Takes a
|
||||
// per-call `resource` like Beoordelen does, because the toggle endpoint writes no log line of
|
||||
// its own (BIO-007): a bare "feature-flags" row would say a flag changed without saying which,
|
||||
// and this is the surface CQ-004/ADR-C-009 hinge on.
|
||||
@@ -963,11 +963,11 @@ void AuditAuthz(HttpContext ctx, string action, string resource, bool allowed, P
|
||||
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.
|
||||
// Persist the queryable, data-minimised trail 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,
|
||||
// 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.
|
||||
@@ -986,7 +986,7 @@ BriefViewDto ToView(HttpContext ctx, BriefEntity e) => new(
|
||||
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).
|
||||
// the sub-org's current published template (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.
|
||||
@@ -1003,9 +1003,9 @@ IResult BriefResult(HttpContext ctx, (BriefStore.Outcome outcome, BriefEntity? e
|
||||
_ => Results.Problem(detail: "Ongeldige overgang voor de huidige status van de brief.", statusCode: StatusCodes.Status409Conflict),
|
||||
};
|
||||
|
||||
// RB-07/BIO-007: every brief transition already funnelled through here for its log line,
|
||||
// BIO-007: every brief transition already funnelled through here for its log line,
|
||||
// so the audit row goes here too — a fifth transition cannot be added that logs but leaves
|
||||
// no trail. Resource is the bare "brief" (RB-02: never the owner's BSN); the decision is
|
||||
// no trail. Resource is the bare "brief" (never the owner's BSN); the decision is
|
||||
// the transition's own outcome, so a 403 or a 409 is as visible as a success.
|
||||
void LogBrief(HttpContext ctx, string action, (BriefStore.Outcome outcome, BriefEntity? entity) r)
|
||||
{
|
||||
@@ -1019,7 +1019,7 @@ void LogBrief(HttpContext ctx, string action, (BriefStore.Outcome outcome, Brief
|
||||
// real system ships this to structured logging / an audit store). A repeated
|
||||
// Idempotency-Key short-circuits to the first call's result — see IdempotencyStore
|
||||
// — so a retried submit dedupes instead of minting a second reference. The key is
|
||||
// scoped to the caller (RB-18/BIO-018): two callers who happen to send the same
|
||||
// scoped to the caller (BIO-018): two callers who happen to send the same
|
||||
// client-chosen header value do not share a cached result.
|
||||
IResult Submit(HttpContext ctx, string kind, string? reject, IReadOnlyList<DocumentRefDto>? documents = null)
|
||||
{
|
||||
@@ -1062,7 +1062,7 @@ IResult Submit(HttpContext ctx, string kind, string? reject, IReadOnlyList<Docum
|
||||
return result;
|
||||
}
|
||||
|
||||
// RB-12/BIO-016: a machine-checkable "this endpoint passes through one of the five admin
|
||||
// BIO-016: a machine-checkable "this endpoint passes through one of the five admin
|
||||
// authz wrappers" signal, attached at mapping time. It has to be attached here — reflecting
|
||||
// over the compiled lambda at test time cannot see which local function a closure calls, but
|
||||
// endpoint metadata set when the route is mapped is exactly what EndpointDataSource exposes
|
||||
|
||||
Reference in New Issue
Block a user