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:
@@ -0,0 +1,67 @@
|
||||
using BigRegister.Domain.Features;
|
||||
|
||||
namespace BigRegister.Api.Data;
|
||||
|
||||
/// One persisted runtime override for a feature flag (only stored once toggled; otherwise the
|
||||
/// catalog default applies). Key = the flag key from the code catalog (FeatureFlags.Catalog).
|
||||
public sealed class FeatureFlagEntity
|
||||
{
|
||||
public required string Key { get; init; }
|
||||
public bool Enabled { get; set; }
|
||||
}
|
||||
|
||||
/// A flag resolved for a consumer: catalog default overlaid with any stored override.
|
||||
public sealed record ResolvedFlag(string Key, string Description, bool Enabled);
|
||||
|
||||
/// <summary>
|
||||
/// Runtime feature-flag state (WP-47). SQLite-backed like <see cref="OrgTemplateStore"/>, same
|
||||
/// single-gate idiom. The CATALOG (which flags exist + their defaults) is code
|
||||
/// (<see cref="FeatureFlags"/>); this store only holds the admin's on/off overrides. An unknown
|
||||
/// key is never writable/enabled — the code catalog is the authority.
|
||||
/// </summary>
|
||||
public static class FeatureFlagStore
|
||||
{
|
||||
private static readonly object _gate = new();
|
||||
|
||||
/// Catalog defaults overlaid with stored overrides — the whole flag set for the admin UI + FE.
|
||||
public static IReadOnlyList<ResolvedFlag> All()
|
||||
{
|
||||
Dictionary<string, bool> overrides;
|
||||
lock (_gate)
|
||||
{
|
||||
using var db = Db.Create();
|
||||
overrides = db.FeatureFlags.ToDictionary(f => f.Key, f => f.Enabled);
|
||||
}
|
||||
return FeatureFlags.Catalog
|
||||
.Select(d => new ResolvedFlag(d.Key, d.Description,
|
||||
overrides.TryGetValue(d.Key, out var e) ? e : d.DefaultEnabled))
|
||||
.ToList();
|
||||
}
|
||||
|
||||
/// Server-side enforcement helper. Unknown key → false (fail closed).
|
||||
public static bool IsEnabled(string key)
|
||||
{
|
||||
var def = FeatureFlags.Catalog.FirstOrDefault(d => d.Key == key);
|
||||
if (def is null) return false;
|
||||
lock (_gate)
|
||||
{
|
||||
using var db = Db.Create();
|
||||
return db.FeatureFlags.Find(key)?.Enabled ?? def.DefaultEnabled;
|
||||
}
|
||||
}
|
||||
|
||||
/// Set an override for a KNOWN flag; returns false for an unknown key (caller → 404).
|
||||
public static bool Set(string key, bool enabled)
|
||||
{
|
||||
if (!FeatureFlags.Catalog.Any(d => d.Key == key)) return false;
|
||||
lock (_gate)
|
||||
{
|
||||
using var db = Db.Create();
|
||||
var row = db.FeatureFlags.Find(key);
|
||||
if (row is null) db.FeatureFlags.Add(new FeatureFlagEntity { Key = key, Enabled = enabled });
|
||||
else row.Enabled = enabled;
|
||||
db.SaveChanges();
|
||||
}
|
||||
return true;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user