feat(admin): runtime feature flags (catalog-in-code, admin toggle, FE+backend)

Catalog declared in code (Domain/Features/FeatureFlags.cs, build-validated), on/off state
persisted in SQLite (FeatureFlagStore + migration). GET /flags (drives FE gating) + admin
PUT /admin/flags/{key} (new flags:manage capability + FlagsAdmin gate). Enforced end-to-end:
the `inschrijving-open` flag hides the Inschrijven nav item + dashboard action (FE) AND makes
POST /applications for a registratie 403 when off (backend). FE FeatureFlagStore mirrors
AccessStore (enabled() deny-by-default); admin toggle page at /beheer/functies in ADMIN_LINKS.
+4 backend tests, /me cap-list updated, client regenerated.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
eho
2026-07-23 22:29:48 +02:00
co-authored by Claude Opus 4.8
parent ed264be714
commit 67802c68b4
28 changed files with 1154 additions and 52 deletions
+26
View File
@@ -5,6 +5,7 @@ using BigRegister.Api.Data;
using BigRegister.Domain.Authorization;
using BigRegister.Domain.Diplomas;
using BigRegister.Domain.Documents;
using BigRegister.Domain.Features;
using BigRegister.Domain.Intake;
using BigRegister.Domain.Letters;
using BigRegister.Domain.Registrations;
@@ -244,6 +245,9 @@ api.MapGet("/applications/{id}", (string id) =>
api.MapPost("/applications", (CreateApplicationRequest req) =>
{
// 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, DocumentStore.DemoOwner);
if (a is null)
return Results.Problem(
@@ -344,6 +348,18 @@ api.MapGet("/admin/audit", (HttpContext ctx) => CasesAdmin(ctx, () =>
api.MapGet("/me", (HttpContext ctx) => new MeDto(Authz.RoleCapabilities(Authz.ResolvePrincipal(ctx))))
.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=
@@ -556,6 +572,16 @@ IResult CasesAdmin(HttpContext ctx, Func<IResult> action)
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 —