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:
@@ -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 —
|
||||
|
||||
Reference in New Issue
Block a user