test(backend): assert every route is authz-gated (RB-12)

BL-006: the backend has zero automated architecture enforcement.
BIO-016 names the concrete consequence for authorization — nothing
asserted the *set* of gated endpoints, so BIO-003's X-Admin gate
(outside Authz) and BIO-004's two ungated endpoints were caught only
by a human reading Program.cs, not by CI.

Adds RouteInventoryTests: walks the real app's EndpointDataSource and
asserts every mapped route either carries a .Gate("XAdmin") metadata
marker (added at the 16 call sites that already call one of the five
admin wrappers — OrgAdmin/StamdataAdmin/CasesAdmin/Beoordelen/
FlagsAdmin) or appears in a written-down, reasoned allow-list. Proved
it's hard to fool by adding a throwaway unguarded route, watching the
test go red, and reverting.

The allow-list is not "public routes" as the ticket's shorthand put
it — 19 of its 31 entries are ownership-scoped inline (ctx.Zorgverlener()/
ctx.Caller()) endpoints, not public ones, and labelling them public
would misrepresent the exact property BIO-004 was about. Each entry
instead carries its own reason. Implementation note has the full
route-by-route breakdown and judgement calls.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
eho
2026-08-27 16:36:30 +02:00
co-authored by Claude Opus 5
parent 988612cd7e
commit ee0d449510
3 changed files with 301 additions and 0 deletions
+36
View File
@@ -188,6 +188,7 @@ api.MapGet("/intake/policy", () => new IntakePolicyDto(IntakePolicy.ScholingThre
api.MapGet("/stamdata", (HttpContext ctx) => StamdataAdmin(ctx, () =>
Results.Ok(StamdataCatalog.All.Select(t =>
new StamdataTableSummaryDto(t.Id, t.Label, t.Columns.Select(ToColumnDto).ToList(), t.Temporal)).ToList())))
.Gate("StamdataAdmin")
.WithName("stamdataTables")
.Produces<List<StamdataTableSummaryDto>>()
.ProducesProblem(StatusCodes.Status403Forbidden);
@@ -201,6 +202,7 @@ api.MapGet("/stamdata/{table}", (string table, string? peildatum, HttpContext ct
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));
}))
.Gate("StamdataAdmin")
.WithName("stamdataTable")
.Produces<StamdataTableDto>()
.ProducesProblem(StatusCodes.Status403Forbidden)
@@ -298,6 +300,7 @@ api.MapDelete("/uploads/{documentId}", (string documentId, HttpContext ctx) =>
// unaudited; CasesAdmin gives it the missing AuthzAuditStore row for free (RB-07).
api.MapDelete("/admin/uploads/{documentId}", (string documentId, HttpContext ctx) => CasesAdmin(ctx, () =>
DocumentStore.AdminDelete(documentId, "admin") ? Results.NoContent() : Results.NotFound()))
.Gate("CasesAdmin")
.Produces(StatusCodes.Status204NoContent)
.ProducesProblem(StatusCodes.Status403Forbidden)
.Produces(StatusCodes.Status404NotFound);
@@ -454,6 +457,7 @@ api.MapPost("/applications/{id}/submit", (string id, SubmitApplicationRequest re
// --- 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))))
.Gate("CasesAdmin")
.Produces<List<ApplicationSummaryDto>>()
.ProducesProblem(StatusCodes.Status403Forbidden);
@@ -465,6 +469,7 @@ api.MapGet("/werkvoorraad", (HttpContext ctx, IZaakSource zaken) => Beoordelen(c
Results.Ok(zaken.ListCases(DateTimeOffset.UtcNow)
.Where(c => c.Status.Tag is "Ingediend" or "InBehandeling")
.ToList())))
.Gate("Beoordelen")
.Produces<List<ApplicationSummaryDto>>()
.ProducesProblem(StatusCodes.Status403Forbidden);
@@ -490,6 +495,7 @@ api.MapGet("/beoordeling/{id}", (string id, HttpContext ctx, IZaakSource zaken)
var decisions = new BeoordelingDecisionsDto(canBesluiten);
return Results.Ok(new BeoordelingViewDto(masked, docs, decisions));
}))
.Gate("Beoordelen")
.Produces<BeoordelingViewDto>()
.ProducesProblem(StatusCodes.Status403Forbidden)
.Produces(StatusCodes.Status404NotFound);
@@ -550,6 +556,7 @@ api.MapPost("/beoordeling/{id}/besluit", (string id, RecordBesluitRequest req, H
return Results.Ok(new RecordBesluitResponse(updated!.ToStatusDto(now)));
}))
.Gate("Beoordelen")
.Produces<RecordBesluitResponse>()
.ProducesProblem(StatusCodes.Status400BadRequest)
.ProducesProblem(StatusCodes.Status403Forbidden)
@@ -594,6 +601,7 @@ api.MapDelete("/admin/cases/{id}", (string id, HttpContext ctx) => CasesAdmin(ct
app.Logger.LogInformation("admin case delete id={Id}", id);
return Results.NoContent();
}))
.Gate("CasesAdmin")
.Produces(StatusCodes.Status204NoContent)
.Produces(StatusCodes.Status404NotFound)
.ProducesProblem(StatusCodes.Status403Forbidden);
@@ -604,6 +612,7 @@ api.MapGet("/admin/audit", (HttpContext ctx) => CasesAdmin(ctx, () =>
Results.Ok(AuthzAuditStore.List()
.Select(a => new AuthzAuditDto(a.At.ToString("o"), a.Action, a.Resource, a.Decision, a.Role, a.CorrelationId))
.ToList())))
.Gate("CasesAdmin")
.Produces<List<AuthzAuditDto>>()
.ProducesProblem(StatusCodes.Status403Forbidden);
@@ -629,6 +638,7 @@ api.MapGet("/flags", () =>
api.MapPut("/admin/flags/{key}", (string key, SetFeatureFlagRequest req, HttpContext ctx) =>
FlagsAdmin(ctx, $"feature-flags/{key}={req.Enabled}", () =>
FeatureFlagStore.Set(key, req.Enabled) ? Results.NoContent() : Results.NotFound()))
.Gate("FlagsAdmin")
.Produces(StatusCodes.Status204NoContent)
.Produces(StatusCodes.Status404NotFound)
.ProducesProblem(StatusCodes.Status403Forbidden);
@@ -748,6 +758,7 @@ api.MapGet("/admin/org-template/{subOrgId}/preview", (string subOrgId, HttpConte
var fixture = BriefSeed.NewBrief("proefbrief");
return Results.Content(LetterHtml.Render(fixture, view.Draft, Now(), watermark: true), "text/html");
}))
.Gate("OrgAdmin")
.ExcludeFromDescription();
api.MapPost("/brief/reset", (HttpContext ctx) =>
@@ -766,12 +777,14 @@ api.MapPost("/brief/reset", (HttpContext ctx) =>
api.MapGet("/admin/org-templates", (HttpContext ctx) => OrgAdmin(ctx, () =>
Results.Ok(OrgTemplateStore.List())))
.Gate("OrgAdmin")
.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()))
.Gate("OrgAdmin")
.WithName("orgTemplateGET")
.Produces<OrgTemplateAdminViewDto>()
.ProducesProblem(StatusCodes.Status403Forbidden)
@@ -783,6 +796,7 @@ api.MapPut("/admin/org-template/{subOrgId}", (string subOrgId, SaveOrgTemplateRe
if (reject is not null) return Results.Problem(detail: reject, statusCode: StatusCodes.Status400BadRequest);
return OrgTemplateStore.SaveDraft(subOrgId, req.Draft) is { } view ? Results.Ok(view) : Results.NotFound();
}))
.Gate("OrgAdmin")
.WithName("orgTemplatePUT")
.Produces<OrgTemplateAdminViewDto>()
.ProducesProblem(StatusCodes.Status400BadRequest)
@@ -797,6 +811,7 @@ api.MapPost("/admin/org-template/{subOrgId}/publish", (string subOrgId, HttpCont
subOrgId, r.Version, r.AffectedUnsentBriefs);
return r is not null ? Results.Ok(r) : Results.NotFound();
}))
.Gate("OrgAdmin")
.WithName("orgTemplatePublish")
.Produces<PublishOrgTemplateResponse>()
.ProducesProblem(StatusCodes.Status403Forbidden)
@@ -804,6 +819,7 @@ api.MapPost("/admin/org-template/{subOrgId}/publish", (string subOrgId, HttpCont
api.MapPost("/admin/org-template/{subOrgId}/rollback/{version:int}", (string subOrgId, int version, HttpContext ctx) => OrgAdmin(ctx, () =>
OrgTemplateStore.Rollback(subOrgId, version) is { } view ? Results.Ok(view) : Results.NotFound()))
.Gate("OrgAdmin")
.WithName("orgTemplateRollback")
.Produces<OrgTemplateAdminViewDto>()
.ProducesProblem(StatusCodes.Status403Forbidden)
@@ -988,5 +1004,25 @@ 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
// authz wrappers" signal, attached at mapping time. It has to be attached here — reflecting
// over the compiled lambda at test time cannot see which local function a closure calls, but
// endpoint metadata set when the route is mapped is exactly what EndpointDataSource exposes
// to a test host. RouteInventoryTests.cs cross-checks every mapped route against either this
// marker or an explicit, named allow-list — see that file for the actual safety net.
// Public, not internal: RouteInventoryTests.cs (a separate assembly, no InternalsVisibleTo
// wired up for one marker type) reads this metadata directly off EndpointDataSource.
public sealed record AuthzGateMetadata(string Wrapper);
public static class AuthzGateEndpointExtensions
{
public static TBuilder Gate<TBuilder>(this TBuilder builder, string wrapper)
where TBuilder : IEndpointConventionBuilder
{
builder.WithMetadata(new AuthzGateMetadata(wrapper));
return builder;
}
}
// Exposed so the integration tests can spin up the app with WebApplicationFactory.
public partial class Program { }