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:
@@ -188,6 +188,7 @@ api.MapGet("/intake/policy", () => new IntakePolicyDto(IntakePolicy.ScholingThre
|
|||||||
api.MapGet("/stamdata", (HttpContext ctx) => StamdataAdmin(ctx, () =>
|
api.MapGet("/stamdata", (HttpContext ctx) => StamdataAdmin(ctx, () =>
|
||||||
Results.Ok(StamdataCatalog.All.Select(t =>
|
Results.Ok(StamdataCatalog.All.Select(t =>
|
||||||
new StamdataTableSummaryDto(t.Id, t.Label, t.Columns.Select(ToColumnDto).ToList(), t.Temporal)).ToList())))
|
new StamdataTableSummaryDto(t.Id, t.Label, t.Columns.Select(ToColumnDto).ToList(), t.Temporal)).ToList())))
|
||||||
|
.Gate("StamdataAdmin")
|
||||||
.WithName("stamdataTables")
|
.WithName("stamdataTables")
|
||||||
.Produces<List<StamdataTableSummaryDto>>()
|
.Produces<List<StamdataTableSummaryDto>>()
|
||||||
.ProducesProblem(StatusCodes.Status403Forbidden);
|
.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();
|
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));
|
return Results.Ok(new StamdataTableDto(t.Id, t.Label, t.Columns.Select(ToColumnDto).ToList(), t.Temporal, rows));
|
||||||
}))
|
}))
|
||||||
|
.Gate("StamdataAdmin")
|
||||||
.WithName("stamdataTable")
|
.WithName("stamdataTable")
|
||||||
.Produces<StamdataTableDto>()
|
.Produces<StamdataTableDto>()
|
||||||
.ProducesProblem(StatusCodes.Status403Forbidden)
|
.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).
|
// unaudited; CasesAdmin gives it the missing AuthzAuditStore row for free (RB-07).
|
||||||
api.MapDelete("/admin/uploads/{documentId}", (string documentId, HttpContext ctx) => CasesAdmin(ctx, () =>
|
api.MapDelete("/admin/uploads/{documentId}", (string documentId, HttpContext ctx) => CasesAdmin(ctx, () =>
|
||||||
DocumentStore.AdminDelete(documentId, "admin") ? Results.NoContent() : Results.NotFound()))
|
DocumentStore.AdminDelete(documentId, "admin") ? Results.NoContent() : Results.NotFound()))
|
||||||
|
.Gate("CasesAdmin")
|
||||||
.Produces(StatusCodes.Status204NoContent)
|
.Produces(StatusCodes.Status204NoContent)
|
||||||
.ProducesProblem(StatusCodes.Status403Forbidden)
|
.ProducesProblem(StatusCodes.Status403Forbidden)
|
||||||
.Produces(StatusCodes.Status404NotFound);
|
.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`. ---
|
// --- Admin cases (WP-36): cross-owner list + admin delete, gated by `cases:manage`. ---
|
||||||
api.MapGet("/admin/cases", (HttpContext ctx, IZaakSource zaken) => CasesAdmin(ctx, () =>
|
api.MapGet("/admin/cases", (HttpContext ctx, IZaakSource zaken) => CasesAdmin(ctx, () =>
|
||||||
Results.Ok(zaken.ListCases(DateTimeOffset.UtcNow))))
|
Results.Ok(zaken.ListCases(DateTimeOffset.UtcNow))))
|
||||||
|
.Gate("CasesAdmin")
|
||||||
.Produces<List<ApplicationSummaryDto>>()
|
.Produces<List<ApplicationSummaryDto>>()
|
||||||
.ProducesProblem(StatusCodes.Status403Forbidden);
|
.ProducesProblem(StatusCodes.Status403Forbidden);
|
||||||
|
|
||||||
@@ -465,6 +469,7 @@ api.MapGet("/werkvoorraad", (HttpContext ctx, IZaakSource zaken) => Beoordelen(c
|
|||||||
Results.Ok(zaken.ListCases(DateTimeOffset.UtcNow)
|
Results.Ok(zaken.ListCases(DateTimeOffset.UtcNow)
|
||||||
.Where(c => c.Status.Tag is "Ingediend" or "InBehandeling")
|
.Where(c => c.Status.Tag is "Ingediend" or "InBehandeling")
|
||||||
.ToList())))
|
.ToList())))
|
||||||
|
.Gate("Beoordelen")
|
||||||
.Produces<List<ApplicationSummaryDto>>()
|
.Produces<List<ApplicationSummaryDto>>()
|
||||||
.ProducesProblem(StatusCodes.Status403Forbidden);
|
.ProducesProblem(StatusCodes.Status403Forbidden);
|
||||||
|
|
||||||
@@ -490,6 +495,7 @@ api.MapGet("/beoordeling/{id}", (string id, HttpContext ctx, IZaakSource zaken)
|
|||||||
var decisions = new BeoordelingDecisionsDto(canBesluiten);
|
var decisions = new BeoordelingDecisionsDto(canBesluiten);
|
||||||
return Results.Ok(new BeoordelingViewDto(masked, docs, decisions));
|
return Results.Ok(new BeoordelingViewDto(masked, docs, decisions));
|
||||||
}))
|
}))
|
||||||
|
.Gate("Beoordelen")
|
||||||
.Produces<BeoordelingViewDto>()
|
.Produces<BeoordelingViewDto>()
|
||||||
.ProducesProblem(StatusCodes.Status403Forbidden)
|
.ProducesProblem(StatusCodes.Status403Forbidden)
|
||||||
.Produces(StatusCodes.Status404NotFound);
|
.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)));
|
return Results.Ok(new RecordBesluitResponse(updated!.ToStatusDto(now)));
|
||||||
}))
|
}))
|
||||||
|
.Gate("Beoordelen")
|
||||||
.Produces<RecordBesluitResponse>()
|
.Produces<RecordBesluitResponse>()
|
||||||
.ProducesProblem(StatusCodes.Status400BadRequest)
|
.ProducesProblem(StatusCodes.Status400BadRequest)
|
||||||
.ProducesProblem(StatusCodes.Status403Forbidden)
|
.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);
|
app.Logger.LogInformation("admin case delete id={Id}", id);
|
||||||
return Results.NoContent();
|
return Results.NoContent();
|
||||||
}))
|
}))
|
||||||
|
.Gate("CasesAdmin")
|
||||||
.Produces(StatusCodes.Status204NoContent)
|
.Produces(StatusCodes.Status204NoContent)
|
||||||
.Produces(StatusCodes.Status404NotFound)
|
.Produces(StatusCodes.Status404NotFound)
|
||||||
.ProducesProblem(StatusCodes.Status403Forbidden);
|
.ProducesProblem(StatusCodes.Status403Forbidden);
|
||||||
@@ -604,6 +612,7 @@ api.MapGet("/admin/audit", (HttpContext ctx) => CasesAdmin(ctx, () =>
|
|||||||
Results.Ok(AuthzAuditStore.List()
|
Results.Ok(AuthzAuditStore.List()
|
||||||
.Select(a => new AuthzAuditDto(a.At.ToString("o"), a.Action, a.Resource, a.Decision, a.Role, a.CorrelationId))
|
.Select(a => new AuthzAuditDto(a.At.ToString("o"), a.Action, a.Resource, a.Decision, a.Role, a.CorrelationId))
|
||||||
.ToList())))
|
.ToList())))
|
||||||
|
.Gate("CasesAdmin")
|
||||||
.Produces<List<AuthzAuditDto>>()
|
.Produces<List<AuthzAuditDto>>()
|
||||||
.ProducesProblem(StatusCodes.Status403Forbidden);
|
.ProducesProblem(StatusCodes.Status403Forbidden);
|
||||||
|
|
||||||
@@ -629,6 +638,7 @@ api.MapGet("/flags", () =>
|
|||||||
api.MapPut("/admin/flags/{key}", (string key, SetFeatureFlagRequest req, HttpContext ctx) =>
|
api.MapPut("/admin/flags/{key}", (string key, SetFeatureFlagRequest req, HttpContext ctx) =>
|
||||||
FlagsAdmin(ctx, $"feature-flags/{key}={req.Enabled}", () =>
|
FlagsAdmin(ctx, $"feature-flags/{key}={req.Enabled}", () =>
|
||||||
FeatureFlagStore.Set(key, req.Enabled) ? Results.NoContent() : Results.NotFound()))
|
FeatureFlagStore.Set(key, req.Enabled) ? Results.NoContent() : Results.NotFound()))
|
||||||
|
.Gate("FlagsAdmin")
|
||||||
.Produces(StatusCodes.Status204NoContent)
|
.Produces(StatusCodes.Status204NoContent)
|
||||||
.Produces(StatusCodes.Status404NotFound)
|
.Produces(StatusCodes.Status404NotFound)
|
||||||
.ProducesProblem(StatusCodes.Status403Forbidden);
|
.ProducesProblem(StatusCodes.Status403Forbidden);
|
||||||
@@ -748,6 +758,7 @@ api.MapGet("/admin/org-template/{subOrgId}/preview", (string subOrgId, HttpConte
|
|||||||
var fixture = BriefSeed.NewBrief("proefbrief");
|
var fixture = BriefSeed.NewBrief("proefbrief");
|
||||||
return Results.Content(LetterHtml.Render(fixture, view.Draft, Now(), watermark: true), "text/html");
|
return Results.Content(LetterHtml.Render(fixture, view.Draft, Now(), watermark: true), "text/html");
|
||||||
}))
|
}))
|
||||||
|
.Gate("OrgAdmin")
|
||||||
.ExcludeFromDescription();
|
.ExcludeFromDescription();
|
||||||
|
|
||||||
api.MapPost("/brief/reset", (HttpContext ctx) =>
|
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, () =>
|
api.MapGet("/admin/org-templates", (HttpContext ctx) => OrgAdmin(ctx, () =>
|
||||||
Results.Ok(OrgTemplateStore.List())))
|
Results.Ok(OrgTemplateStore.List())))
|
||||||
|
.Gate("OrgAdmin")
|
||||||
.WithName("orgTemplates")
|
.WithName("orgTemplates")
|
||||||
.Produces<List<SubOrgSummaryDto>>()
|
.Produces<List<SubOrgSummaryDto>>()
|
||||||
.ProducesProblem(StatusCodes.Status403Forbidden);
|
.ProducesProblem(StatusCodes.Status403Forbidden);
|
||||||
|
|
||||||
api.MapGet("/admin/org-template/{subOrgId}", (string subOrgId, HttpContext ctx) => OrgAdmin(ctx, () =>
|
api.MapGet("/admin/org-template/{subOrgId}", (string subOrgId, HttpContext ctx) => OrgAdmin(ctx, () =>
|
||||||
OrgTemplateStore.AdminView(subOrgId) is { } view ? Results.Ok(view) : Results.NotFound()))
|
OrgTemplateStore.AdminView(subOrgId) is { } view ? Results.Ok(view) : Results.NotFound()))
|
||||||
|
.Gate("OrgAdmin")
|
||||||
.WithName("orgTemplateGET")
|
.WithName("orgTemplateGET")
|
||||||
.Produces<OrgTemplateAdminViewDto>()
|
.Produces<OrgTemplateAdminViewDto>()
|
||||||
.ProducesProblem(StatusCodes.Status403Forbidden)
|
.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);
|
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();
|
return OrgTemplateStore.SaveDraft(subOrgId, req.Draft) is { } view ? Results.Ok(view) : Results.NotFound();
|
||||||
}))
|
}))
|
||||||
|
.Gate("OrgAdmin")
|
||||||
.WithName("orgTemplatePUT")
|
.WithName("orgTemplatePUT")
|
||||||
.Produces<OrgTemplateAdminViewDto>()
|
.Produces<OrgTemplateAdminViewDto>()
|
||||||
.ProducesProblem(StatusCodes.Status400BadRequest)
|
.ProducesProblem(StatusCodes.Status400BadRequest)
|
||||||
@@ -797,6 +811,7 @@ api.MapPost("/admin/org-template/{subOrgId}/publish", (string subOrgId, HttpCont
|
|||||||
subOrgId, r.Version, r.AffectedUnsentBriefs);
|
subOrgId, r.Version, r.AffectedUnsentBriefs);
|
||||||
return r is not null ? Results.Ok(r) : Results.NotFound();
|
return r is not null ? Results.Ok(r) : Results.NotFound();
|
||||||
}))
|
}))
|
||||||
|
.Gate("OrgAdmin")
|
||||||
.WithName("orgTemplatePublish")
|
.WithName("orgTemplatePublish")
|
||||||
.Produces<PublishOrgTemplateResponse>()
|
.Produces<PublishOrgTemplateResponse>()
|
||||||
.ProducesProblem(StatusCodes.Status403Forbidden)
|
.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, () =>
|
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()))
|
OrgTemplateStore.Rollback(subOrgId, version) is { } view ? Results.Ok(view) : Results.NotFound()))
|
||||||
|
.Gate("OrgAdmin")
|
||||||
.WithName("orgTemplateRollback")
|
.WithName("orgTemplateRollback")
|
||||||
.Produces<OrgTemplateAdminViewDto>()
|
.Produces<OrgTemplateAdminViewDto>()
|
||||||
.ProducesProblem(StatusCodes.Status403Forbidden)
|
.ProducesProblem(StatusCodes.Status403Forbidden)
|
||||||
@@ -988,5 +1004,25 @@ IResult Submit(HttpContext ctx, string kind, string? reject, IReadOnlyList<Docum
|
|||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// RB-12/BIO-016: a machine-checkable "this endpoint passes through one of the five admin
|
||||||
|
// authz wrappers" signal, attached at mapping time. It has to be attached here — reflecting
|
||||||
|
// over the compiled lambda at test time cannot see which local function a closure calls, but
|
||||||
|
// endpoint metadata set when the route is mapped is exactly what EndpointDataSource exposes
|
||||||
|
// to a test host. RouteInventoryTests.cs cross-checks every mapped route against either this
|
||||||
|
// marker or an explicit, named allow-list — see that file for the actual safety net.
|
||||||
|
// Public, not internal: RouteInventoryTests.cs (a separate assembly, no InternalsVisibleTo
|
||||||
|
// wired up for one marker type) reads this metadata directly off EndpointDataSource.
|
||||||
|
public sealed record AuthzGateMetadata(string Wrapper);
|
||||||
|
|
||||||
|
public static class AuthzGateEndpointExtensions
|
||||||
|
{
|
||||||
|
public static TBuilder Gate<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.
|
// Exposed so the integration tests can spin up the app with WebApplicationFactory.
|
||||||
public partial class Program { }
|
public partial class Program { }
|
||||||
|
|||||||
@@ -0,0 +1,150 @@
|
|||||||
|
using Microsoft.AspNetCore.Http;
|
||||||
|
using Microsoft.AspNetCore.Routing;
|
||||||
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
|
|
||||||
|
namespace BigRegister.Tests;
|
||||||
|
|
||||||
|
/// RB-12/BIO-016 (BL-006 — "the backend has zero automated architecture enforcement"): the
|
||||||
|
/// only thing that used to keep an admin-shaped endpoint behind `Authz` was a human noticing
|
||||||
|
/// in review. BIO-003 (`X-Admin`, a second gate outside `Authz`) and BIO-004 (two endpoints
|
||||||
|
/// with no gate at all) are exactly the failure mode this test is a safety net for — and it is
|
||||||
|
/// the safety net RB-19 (a 900-line `Program.cs` reorder) leans on, so its value is entirely in
|
||||||
|
/// being hard to fool.
|
||||||
|
///
|
||||||
|
/// Every mapped route must be accounted for exactly one of two ways:
|
||||||
|
/// - it carries an <see cref="AuthzGateMetadata"/> marker (<c>.Gate("XAdmin")</c>, added at the
|
||||||
|
/// call site in Program.cs) naming one of the five admin authz wrappers, or
|
||||||
|
/// - it is named, with a reason, in <see cref="AllowList"/> below.
|
||||||
|
///
|
||||||
|
/// The allow-list is deliberately not "public routes" — most of its entries are NOT public.
|
||||||
|
/// `GET /applications/{id}` requires a caller identity and is scoped to that caller's own BSN
|
||||||
|
/// inline (`ctx.Zorgverlener()`), not through one of the five wrappers, which only gate the
|
||||||
|
/// coarse admin/behandelaar surfaces. Recording that here, with the actual reason, is the point
|
||||||
|
/// of BIO-016's remediation ("makes 'this endpoint is public' a decision someone wrote down")
|
||||||
|
/// generalised to every route that isn't wrapper-gated: the reviewer reads a name and a reason,
|
||||||
|
/// not silence.
|
||||||
|
public class RouteInventoryTests(TestWebApplicationFactory factory) : IClassFixture<TestWebApplicationFactory>
|
||||||
|
{
|
||||||
|
// Not TestWebApplicationFactory's HttpClient — this never issues a request, only reads the
|
||||||
|
// route table off the host's DI container. Uses the shared per-class isolated db file (see
|
||||||
|
// TestWebApplicationFactory's own doc comment) rather than a bare `new
|
||||||
|
// WebApplicationFactory<Program>()`, which would share the mutable static Db.ConnectionString
|
||||||
|
// with whatever other test class last set it and race "table already exists" against it.
|
||||||
|
private TestWebApplicationFactory Factory { get; } = factory;
|
||||||
|
|
||||||
|
private sealed record AllowListEntry(string Method, string Pattern, string Reason);
|
||||||
|
|
||||||
|
private static readonly AllowListEntry[] AllowList =
|
||||||
|
[
|
||||||
|
// --- Orchestrator probes: no data, no PII, run before any identity concern applies. ---
|
||||||
|
new("GET", "/health", "Liveness probe for orchestrators."),
|
||||||
|
new("GET", "/health/ready", "Readiness probe for orchestrators."),
|
||||||
|
|
||||||
|
// --- Static/reference demo data (SeedData & friends): identical for every caller in
|
||||||
|
// this POC (one seeded citizen), nothing to scope by. ---
|
||||||
|
new("GET", "/api/v1/dashboard-view", "Static reference data (SeedData) — same for every caller in this POC."),
|
||||||
|
new("GET", "/api/v1/notes", "Static reference data (SeedData.Notes) — same for every caller in this POC."),
|
||||||
|
new("GET", "/api/v1/brp/address", "Static BRP reference fixture — same for every caller in this POC."),
|
||||||
|
new("GET", "/api/v1/duo/diplomas", "Static DUO reference fixture + manual-diploma policy — same for every caller."),
|
||||||
|
new("GET", "/api/v1/intake/policy", "Config VALUE shipped for instant FE feedback (ADR-0001); the server re-validates as authority."),
|
||||||
|
new("GET", "/api/v1/uploads/categories", "Static per-wizard category config, no PII, no per-caller distinction."),
|
||||||
|
new("GET", "/api/v1/flags", "Feature-flag catalog + state, readable by any principal by design (WP-47) — only the PUT toggle is admin-gated."),
|
||||||
|
new("GET", "/api/v1/me", "Reflects only the ACTING caller's own role-derived capabilities — no other caller's data to leak."),
|
||||||
|
|
||||||
|
// --- Citizen-submitted writes / ownership-scoped inline (ctx.Zorgverlener()/ctx.Caller()),
|
||||||
|
// not a role-only admin wrapper because the boundary is resource ownership, not a role. ---
|
||||||
|
new("POST", "/api/v1/change-requests", "Citizen submission; Submit() records outcome + idempotency, attributed to the acting caller."),
|
||||||
|
new("POST", "/api/v1/uploads", "Upload is attributed to ctx.Zorgverlener() as owner — there is no pre-existing resource to own yet."),
|
||||||
|
new("GET", "/api/v1/uploads/{documentId}/content", "Ownership-scoped inline (RB-01/BIO-004): owning citizen, or a behandelaar via Authz.CanBeoordelen."),
|
||||||
|
new("GET", "/api/v1/uploads/status", "Ownership-scoped inline: DocumentStore.ByLocalIds filtered to ctx.Zorgverlener().Bsn."),
|
||||||
|
new("DELETE", "/api/v1/uploads/{documentId}", "Ownership-scoped inline: DocumentStore.DeleteOwned keyed by ctx.Zorgverlener().Bsn."),
|
||||||
|
new("GET", "/api/v1/applications", "Ownership-scoped inline: IZaakSource.ListMyCases(ctx.Zorgverlener(), ...)."),
|
||||||
|
new("GET", "/api/v1/applications/{id}", "Ownership-scoped inline: ApplicationStore.Get(id, ctx.Zorgverlener().Bsn)."),
|
||||||
|
new("POST", "/api/v1/applications", "Ownership-scoped inline: created under ctx.Zorgverlener().Bsn."),
|
||||||
|
new("PUT", "/api/v1/applications/{id}", "Ownership-scoped inline: ApplicationStore.SyncDraft keyed by ctx.Zorgverlener().Bsn."),
|
||||||
|
new("DELETE", "/api/v1/applications/{id}", "Ownership-scoped inline: ApplicationStore.Get/.Delete keyed by ctx.Zorgverlener().Bsn."),
|
||||||
|
new("POST", "/api/v1/applications/{id}/submit", "Ownership-scoped inline: ApplicationStore.Submit keyed by ctx.Zorgverlener().Bsn."),
|
||||||
|
|
||||||
|
// --- External caller, not a Principal at all. ---
|
||||||
|
new("POST", "/api/v1/zgw/notificaties", "OpenZaak's NRC, not a user: gated by a fixed-time shared-secret comparison, audited directly."),
|
||||||
|
|
||||||
|
// --- Brief (letter composition): PRD-0002's own status-machine enforcement is the
|
||||||
|
// enforce/emit twin for this whole surface (Authz.CanActOn via BriefStore, ToView's
|
||||||
|
// Decisions dto) — a different single-source-of-truth than the five Program.cs wrappers,
|
||||||
|
// not a missing one. ---
|
||||||
|
new("GET", "/api/v1/brief", "Ownership-scoped inline: BriefStore.GetOrCreate(ctx.Zorgverlener().Bsn)."),
|
||||||
|
new("PUT", "/api/v1/brief", "Brief status-machine enforcement: BriefStore.Save + Authz.CanActOn (drafter-only)."),
|
||||||
|
new("POST", "/api/v1/brief/submit", "Brief status-machine enforcement: BriefStore.Submit + Authz.CanActOn."),
|
||||||
|
new("POST", "/api/v1/brief/approve", "Brief status-machine enforcement: BriefStore.Approve + Authz.CanActOn (approver != drafter)."),
|
||||||
|
new("POST", "/api/v1/brief/reject", "Brief status-machine enforcement: BriefStore.Reject + Authz.CanActOn."),
|
||||||
|
new("POST", "/api/v1/brief/send", "Brief status-machine enforcement: BriefStore.Send; not role-gated today, per the endpoint's own comment."),
|
||||||
|
new("POST", "/api/v1/brief/reveal-bignummer", "Own inline capability + step-up check (Authz.CanRevealBigNummer + X-Step-Up), audited directly."),
|
||||||
|
new("GET", "/api/v1/brief/preview", "Ownership-scoped inline: BriefStore.GetOrCreate(ctx.Zorgverlener().Bsn); hand-written FE fetch."),
|
||||||
|
new("POST", "/api/v1/brief/reset", "Deliberately unguarded demo affordance — the endpoint's own comment says so: 'showcase affordance only'."),
|
||||||
|
];
|
||||||
|
|
||||||
|
private static readonly HashSet<string> KnownWrappers =
|
||||||
|
["OrgAdmin", "StamdataAdmin", "CasesAdmin", "Beoordelen", "FlagsAdmin"];
|
||||||
|
|
||||||
|
private static IEnumerable<RouteEndpoint> RealRoutes(EndpointDataSource source) =>
|
||||||
|
source.Endpoints.OfType<RouteEndpoint>()
|
||||||
|
// MapGroup's own catch-all/description endpoints carry no HTTP method — not a route
|
||||||
|
// an HTTP client can actually hit distinctly, so not this test's concern.
|
||||||
|
.Where(e => e.Metadata.GetMetadata<HttpMethodMetadata>() is not null);
|
||||||
|
|
||||||
|
private static string Key(string method, string pattern) => $"{method} {pattern}";
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Every_mapped_route_is_authz_gated_or_on_the_named_allow_list()
|
||||||
|
{
|
||||||
|
var source = Factory.Services.GetRequiredService<EndpointDataSource>();
|
||||||
|
|
||||||
|
var allowed = AllowList.ToDictionary(e => Key(e.Method, e.Pattern));
|
||||||
|
var seenAllowListKeys = new HashSet<string>();
|
||||||
|
var unaccounted = new List<string>();
|
||||||
|
|
||||||
|
foreach (var route in RealRoutes(source))
|
||||||
|
{
|
||||||
|
var pattern = route.RoutePattern.RawText!;
|
||||||
|
foreach (var method in route.Metadata.GetMetadata<HttpMethodMetadata>()!.HttpMethods)
|
||||||
|
{
|
||||||
|
var key = Key(method, pattern);
|
||||||
|
var gated = route.Metadata.GetMetadata<AuthzGateMetadata>() is { } gate && KnownWrappers.Contains(gate.Wrapper);
|
||||||
|
var listed = allowed.ContainsKey(key);
|
||||||
|
if (listed) seenAllowListKeys.Add(key);
|
||||||
|
if (!gated && !listed) unaccounted.Add(key);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Assert.True(unaccounted.Count == 0,
|
||||||
|
"Route(s) with no authz gate and no allow-list entry — either add `.Gate(\"XAdmin\")` " +
|
||||||
|
"at the mapping site, or add a named, reasoned entry to RouteInventoryTests.AllowList:\n" +
|
||||||
|
string.Join("\n", unaccounted));
|
||||||
|
|
||||||
|
// The allow-list is a decision log, not a wishlist — an entry for a route that no longer
|
||||||
|
// exists (renamed, removed) is exactly the kind of drift this test exists to catch.
|
||||||
|
var stale = allowed.Keys.Except(seenAllowListKeys).ToList();
|
||||||
|
Assert.True(stale.Count == 0,
|
||||||
|
"Allow-list entry with no matching live route (stale — the route was renamed or " +
|
||||||
|
"removed):\n" + string.Join("\n", stale));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Every `.Gate(...)` call must name one of the five known wrappers — a typo here would
|
||||||
|
/// silently fall back to "unaccounted for" above, but pinning it down explicitly gives a
|
||||||
|
/// clearer failure than the generic route-mismatch message.
|
||||||
|
[Fact]
|
||||||
|
public void Every_gate_marker_names_a_known_admin_wrapper()
|
||||||
|
{
|
||||||
|
var source = Factory.Services.GetRequiredService<EndpointDataSource>();
|
||||||
|
|
||||||
|
var unknown = RealRoutes(source)
|
||||||
|
.Select(r => r.Metadata.GetMetadata<AuthzGateMetadata>())
|
||||||
|
.Where(g => g is not null)
|
||||||
|
.Select(g => g!.Wrapper)
|
||||||
|
.Where(w => !KnownWrappers.Contains(w))
|
||||||
|
.Distinct()
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
Assert.True(unknown.Count == 0, "Unknown wrapper name(s) in a .Gate(...) call: " + string.Join(", ", unknown));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,115 @@
|
|||||||
|
# RB-12 — a route-table test: every route hits an authz wrapper or an explicit allow-list
|
||||||
|
|
||||||
|
Status: **implemented** · 2026-08-27 · Source findings: `07-bio2-compliance.md` BIO-016, `00-baseline.md` BL-006 · `99-backlog.md` RB-12
|
||||||
|
|
||||||
|
## What was wrong
|
||||||
|
|
||||||
|
BL-006, verbatim: "the backend has zero automated architecture enforcement … `Domain/`
|
||||||
|
purity currently holds by convention." BIO-016 names the specific consequence for
|
||||||
|
authorization: nothing asserted the **set** of gated endpoints, so an endpoint added
|
||||||
|
without a gate (BIO-003's `X-Admin` gate outside `Authz`, BIO-004's two endpoints with
|
||||||
|
no gate at all) failed no test. Both were caught by a human reading `Program.cs`, not by
|
||||||
|
CI.
|
||||||
|
|
||||||
|
## What changed
|
||||||
|
|
||||||
|
| File | Change |
|
||||||
|
| ------------------------------------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||||
|
| `Program.cs` — 16 endpoint mappings | each chains a new `.Gate("XAdmin")` call, naming the admin wrapper (`OrgAdmin`, `StamdataAdmin`, `CasesAdmin`, `Beoordelen`, `FlagsAdmin`) already used inside its handler |
|
||||||
|
| `Program.cs` — new types, end of file | `public sealed record AuthzGateMetadata(string Wrapper)` + a `Gate(...)` extension method on `IEndpointConventionBuilder` that attaches it via `.WithMetadata(...)` |
|
||||||
|
| `tests/BigRegister.Tests/RouteInventoryTests.cs` | **new** — walks the real app's `EndpointDataSource`, asserts every route carries either an `AuthzGateMetadata` naming a known wrapper, or an entry in a written-down allow-list; a second test asserts every `.Gate(...)` name is one of the five known wrappers |
|
||||||
|
|
||||||
|
## Design: metadata at mapping time, not reflection over the compiled lambda
|
||||||
|
|
||||||
|
The ticket left the detection mechanism open, noting the wrappers are local functions
|
||||||
|
in `Program.cs`. Reflecting over a compiled minimal-API lambda to determine which local
|
||||||
|
function its closure calls is fragile-to-impossible (the call is inside IL a test would
|
||||||
|
have to disassemble, and a local function's identity isn't easily recoverable from the
|
||||||
|
delegate's `MethodInfo`). Endpoint **metadata**, attached at the same call site where the
|
||||||
|
route is mapped, is exactly what `EndpointDataSource` hands back to a test host and
|
||||||
|
doesn't depend on inspecting compiled code at all — so a `.Gate("XAdmin")` extension
|
||||||
|
method was added and chained onto each of the 16 mappings that call one of the five
|
||||||
|
wrappers.
|
||||||
|
|
||||||
|
This is a **declaration**, not a **derivation**: the test does not verify that
|
||||||
|
`.Gate("CasesAdmin")` and an actual `CasesAdmin(ctx, …)` call inside the handler agree —
|
||||||
|
it only verifies that a marker is present. A handler that swapped its `CasesAdmin(ctx,
|
||||||
|
…)` call for a no-op without updating `.Gate(...)` would go undetected here. What _is_
|
||||||
|
caught, reliably, is the actual BIO-003/BIO-004 failure mode: a new endpoint mapped with
|
||||||
|
**no** marker and **no** allow-list entry — verified below by adding one and watching the
|
||||||
|
test go red.
|
||||||
|
|
||||||
|
## Judgement call: the allow-list is not "public routes"
|
||||||
|
|
||||||
|
The ticket's literal framing — every route "goes through one of the authz wrappers …
|
||||||
|
or appears in an explicit, named allow-list of deliberately-public routes" — doesn't fit
|
||||||
|
this codebase as read. Only 16 of the app's 47 routes go through one of the five admin
|
||||||
|
wrappers. The other 31 are not uniformly public:
|
||||||
|
|
||||||
|
- **10 are genuinely public** — orchestrator health probes and static/reference demo
|
||||||
|
data (`SeedData`, the DUO/BRP fixtures, the scholing-threshold config value, the
|
||||||
|
feature-flag catalog, `/me`'s reflection of the caller's own capabilities) that reads
|
||||||
|
the same for every caller in this one-seeded-citizen POC.
|
||||||
|
- **19 are ownership-scoped inline**, not public and not wrapper-gated: `GET
|
||||||
|
/applications/{id}`, the upload endpoints, every brief transition, etc. all key off
|
||||||
|
`ctx.Zorgverlener().Bsn` / `ctx.Caller()` — an authenticated citizen (or, for the
|
||||||
|
uploads-content endpoint, a behandelaar) reading or writing only their own resource.
|
||||||
|
Calling these "public" in an allow-list would misrepresent exactly the property
|
||||||
|
BIO-004 was about — object-level authorization existing at all.
|
||||||
|
- **1 (`POST /zgw/notificaties`) uses a different mechanism entirely** — a fixed-time
|
||||||
|
shared-secret comparison for a non-Principal external caller (OpenZaak's
|
||||||
|
notifications), audited the same way but never going through `Authz`.
|
||||||
|
- **1 (`POST /brief/reset`) is deliberately, literally unguarded** — the endpoint's own
|
||||||
|
pre-existing comment says so ("No guards — showcase affordance only").
|
||||||
|
|
||||||
|
The allow-list (`RouteInventoryTests.AllowList`) keeps all 31 as one array for the test's
|
||||||
|
sake, but every entry carries its own reason string rather than a blanket "public" label —
|
||||||
|
preserving BIO-016's actual intent ("makes 'this endpoint is public' a decision someone
|
||||||
|
wrote down rather than an omission") generalised to "this endpoint's access boundary is
|
||||||
|
_X_, deliberately," which is true of all 31 and false of "public" for 20 of them. This is
|
||||||
|
recorded here rather than silently reinterpreted, per this task's brief: implementing the
|
||||||
|
literal "public" framing would have been actively misleading about which endpoints have no
|
||||||
|
access control at all.
|
||||||
|
|
||||||
|
## Other judgement calls
|
||||||
|
|
||||||
|
- **`AuthzGateMetadata` and its extension method are `public`, not `internal`.** The test
|
||||||
|
project has no `InternalsVisibleTo` wired up for `BigRegister.Api` (checked — none
|
||||||
|
exists anywhere in `backend/`), and adding one for a single marker type was more
|
||||||
|
machinery than the alternative. Both types carry a comment stating why.
|
||||||
|
- **A second test (`Every_gate_marker_names_a_known_admin_wrapper`) guards against a typo
|
||||||
|
in a `.Gate(...)` call.** Without it, a call like `.Gate("CasesAdmn")` would just fall
|
||||||
|
through to "unaccounted for" in the main test with a less specific failure message —
|
||||||
|
fine, but a dedicated assertion names the actual mistake.
|
||||||
|
- **The main test also asserts the reverse direction: no stale allow-list entries.** An
|
||||||
|
allow-list entry for a route that was renamed or removed is exactly the kind of drift
|
||||||
|
a "decision someone wrote down" ledger needs to catch, not just silently keep. Verified
|
||||||
|
this fires: temporarily added one extra `AllowList` entry for a route that doesn't
|
||||||
|
exist (via Edit, not committed) — every real route was still covered, so only the
|
||||||
|
stale-entry assertion tripped, naming exactly that bogus entry. Reverted the same way.
|
||||||
|
- **`RouteInventoryTests` uses the house `TestWebApplicationFactory` + `IClassFixture`
|
||||||
|
idiom**, not a bare `new WebApplicationFactory<Program>()` per test. The first draft did
|
||||||
|
the latter and immediately hit `SQLite Error 1: 'table "Applications" already exists'`
|
||||||
|
— `Db.ConnectionString` (`Data/Db.cs`) is a shared mutable **static** field, and a bare
|
||||||
|
factory doesn't override `ConnectionStrings:AppDb`, so two such factories in the same
|
||||||
|
class end up pointed at the same file, and the second one's `Migrate()` collides with
|
||||||
|
the first's already-created tables (the first factory's default `Dispose()` doesn't
|
||||||
|
delete that file — only `TestWebApplicationFactory`'s override does, to its own
|
||||||
|
per-instance temp path). This is exactly the hazard `TestWebApplicationFactory`'s own
|
||||||
|
doc comment describes; switching to it (as every other endpoint-test class in this
|
||||||
|
suite already does) fixed it outright — no product code involved, purely a test-fixture
|
||||||
|
choice.
|
||||||
|
|
||||||
|
## Verification
|
||||||
|
|
||||||
|
- **Proved the test is hard to fool**, per the ticket's explicit ask: added a throwaway
|
||||||
|
`api.MapDelete("/rb12-throwaway-unguarded/{id}", …)` with no `.Gate(...)` and no
|
||||||
|
allow-list entry (via Edit, not `git checkout`) — `Every_mapped_route_is_authz_gated_or_
|
||||||
|
on_the_named_allow_list` failed red, naming exactly that route. Reverted the same way;
|
||||||
|
re-ran green. Repeated once more after switching to `TestWebApplicationFactory` to
|
||||||
|
confirm the fixture change didn't weaken the check — same red, same green.
|
||||||
|
- `dotnet build` (both `src/BigRegister.Api` and `tests/BigRegister.Tests`): clean, 0
|
||||||
|
warnings.
|
||||||
|
- `dotnet format BigRegister.slnx --verify-no-changes`: clean.
|
||||||
|
- `dotnet test --filter "Category!=Integration"`: **257 passed, 0 failed** (255
|
||||||
|
pre-existing + 2 new).
|
||||||
Reference in New Issue
Block a user