diff --git a/backend/src/BigRegister.Api/Contracts/Dtos.cs b/backend/src/BigRegister.Api/Contracts/Dtos.cs index b9ce037..62f6ff5 100644 --- a/backend/src/BigRegister.Api/Contracts/Dtos.cs +++ b/backend/src/BigRegister.Api/Contracts/Dtos.cs @@ -83,6 +83,10 @@ public sealed record ChangeRequestRequest(string Telefoon); public sealed record AuthzAuditDto( string At, string Action, string Resource, string Decision, string Role, string CorrelationId); +// Feature flags (WP-47): the resolved flag set + the admin toggle body. +public sealed record FeatureFlagDto(string Key, string Description, bool Enabled); +public sealed record SetFeatureFlagRequest(bool Enabled); + public sealed record ReferentieResponse(string Referentie); // --- Applications (aanvragen): the system of record for the dashboard. --- diff --git a/backend/src/BigRegister.Api/Data/AppDbContext.cs b/backend/src/BigRegister.Api/Data/AppDbContext.cs index 3eec246..fa562ca 100644 --- a/backend/src/BigRegister.Api/Data/AppDbContext.cs +++ b/backend/src/BigRegister.Api/Data/AppDbContext.cs @@ -20,6 +20,7 @@ public sealed class AppDbContext(DbContextOptions options) : DbCon public DbSet Documents => Set(); public DbSet AuditEntries => Set(); public DbSet AuthzAudit => Set(); + public DbSet FeatureFlags => Set(); public DbSet Applications => Set(); public DbSet Briefs => Set(); public DbSet OrgTemplates => Set(); @@ -40,6 +41,8 @@ public sealed class AppDbContext(DbContextOptions options) : DbCon e.Property(a => a.Id).ValueGeneratedOnAdd(); }); + modelBuilder.Entity().HasKey(f => f.Key); + modelBuilder.Entity(e => { e.HasKey(a => a.Id); diff --git a/backend/src/BigRegister.Api/Data/FeatureFlagStore.cs b/backend/src/BigRegister.Api/Data/FeatureFlagStore.cs new file mode 100644 index 0000000..b016d19 --- /dev/null +++ b/backend/src/BigRegister.Api/Data/FeatureFlagStore.cs @@ -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); + +/// +/// Runtime feature-flag state (WP-47). SQLite-backed like , same +/// single-gate idiom. The CATALOG (which flags exist + their defaults) is code +/// (); this store only holds the admin's on/off overrides. An unknown +/// key is never writable/enabled — the code catalog is the authority. +/// +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 All() + { + Dictionary 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; + } +} diff --git a/backend/src/BigRegister.Api/Data/Migrations/20260723202131_FeatureFlags.Designer.cs b/backend/src/BigRegister.Api/Data/Migrations/20260723202131_FeatureFlags.Designer.cs new file mode 100644 index 0000000..351a69b --- /dev/null +++ b/backend/src/BigRegister.Api/Data/Migrations/20260723202131_FeatureFlags.Designer.cs @@ -0,0 +1,273 @@ +// +using System; +using BigRegister.Api.Data; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; + +#nullable disable + +namespace BigRegister.Api.Data.Migrations +{ + [DbContext(typeof(AppDbContext))] + [Migration("20260723202131_FeatureFlags")] + partial class FeatureFlags + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder.HasAnnotation("ProductVersion", "10.0.9"); + + modelBuilder.Entity("BigRegister.Api.Data.Aanvraag", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("AutoApprovable") + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("DocumentIds") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Draft") + .HasColumnType("TEXT"); + + b.Property("Owner") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Reden") + .HasColumnType("TEXT"); + + b.Property("Referentie") + .HasColumnType("TEXT"); + + b.Property("StepCount") + .HasColumnType("INTEGER"); + + b.Property("StepIndex") + .HasColumnType("INTEGER"); + + b.Property("Submitted") + .HasColumnType("INTEGER"); + + b.Property("SubmittedAt") + .HasColumnType("TEXT"); + + b.Property("Type") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.ToTable("Applications"); + }); + + modelBuilder.Entity("BigRegister.Api.Data.AuditEntry", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Action") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Actor") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("At") + .HasColumnType("TEXT"); + + b.Property("CategoryId") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("DocumentId") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.ToTable("AuditEntries"); + }); + + modelBuilder.Entity("BigRegister.Api.Data.AuthzAuditEntry", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Action") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("At") + .HasColumnType("TEXT"); + + b.Property("CorrelationId") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Decision") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Resource") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Role") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.ToTable("AuthzAudit"); + }); + + modelBuilder.Entity("BigRegister.Api.Data.BriefEntity", b => + { + b.Property("BriefId") + .HasColumnType("TEXT"); + + b.Property("ArchivedHtml") + .HasColumnType("TEXT"); + + b.Property("Beroep") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("DrafterId") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Owner") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Placeholders") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Sections") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("SentOrgTemplateVersion") + .HasColumnType("INTEGER"); + + b.Property("Status") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("SubOrgId") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("TemplateId") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("BriefId"); + + b.HasIndex("Owner") + .IsUnique(); + + b.ToTable("Briefs"); + }); + + modelBuilder.Entity("BigRegister.Api.Data.FeatureFlagEntity", b => + { + b.Property("Key") + .HasColumnType("TEXT"); + + b.Property("Enabled") + .HasColumnType("INTEGER"); + + b.HasKey("Key"); + + b.ToTable("FeatureFlags"); + }); + + modelBuilder.Entity("BigRegister.Api.Data.OrgTemplateEntity", b => + { + b.Property("SubOrgId") + .HasColumnType("TEXT"); + + b.Property("Draft") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("History") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("PublishedVersion") + .HasColumnType("INTEGER"); + + b.HasKey("SubOrgId"); + + b.ToTable("OrgTemplates"); + }); + + modelBuilder.Entity("BigRegister.Api.Data.StoredDocument", b => + { + b.Property("DocumentId") + .HasColumnType("TEXT"); + + b.Property("CategoryId") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Content") + .IsRequired() + .HasColumnType("BLOB"); + + b.Property("ContentType") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("FileName") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Linked") + .HasColumnType("INTEGER"); + + b.Property("LocalId") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Owner") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("SizeBytes") + .HasColumnType("INTEGER"); + + b.Property("UploadedAt") + .HasColumnType("TEXT"); + + b.Property("WizardId") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("DocumentId"); + + b.ToTable("Documents"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/backend/src/BigRegister.Api/Data/Migrations/20260723202131_FeatureFlags.cs b/backend/src/BigRegister.Api/Data/Migrations/20260723202131_FeatureFlags.cs new file mode 100644 index 0000000..490baf2 --- /dev/null +++ b/backend/src/BigRegister.Api/Data/Migrations/20260723202131_FeatureFlags.cs @@ -0,0 +1,33 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace BigRegister.Api.Data.Migrations +{ + /// + public partial class FeatureFlags : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "FeatureFlags", + columns: table => new + { + Key = table.Column(type: "TEXT", nullable: false), + Enabled = table.Column(type: "INTEGER", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_FeatureFlags", x => x.Key); + }); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "FeatureFlags"); + } + } +} diff --git a/backend/src/BigRegister.Api/Data/Migrations/AppDbContextModelSnapshot.cs b/backend/src/BigRegister.Api/Data/Migrations/AppDbContextModelSnapshot.cs index 7235c69..358b68a 100644 --- a/backend/src/BigRegister.Api/Data/Migrations/AppDbContextModelSnapshot.cs +++ b/backend/src/BigRegister.Api/Data/Migrations/AppDbContextModelSnapshot.cs @@ -184,6 +184,19 @@ namespace BigRegister.Api.Data.Migrations b.ToTable("Briefs"); }); + modelBuilder.Entity("BigRegister.Api.Data.FeatureFlagEntity", b => + { + b.Property("Key") + .HasColumnType("TEXT"); + + b.Property("Enabled") + .HasColumnType("INTEGER"); + + b.HasKey("Key"); + + b.ToTable("FeatureFlags"); + }); + modelBuilder.Entity("BigRegister.Api.Data.OrgTemplateEntity", b => { b.Property("SubOrgId") diff --git a/backend/src/BigRegister.Api/Domain/Authorization/Authz.cs b/backend/src/BigRegister.Api/Domain/Authorization/Authz.cs index ed5f77f..d5d9cd3 100644 --- a/backend/src/BigRegister.Api/Domain/Authorization/Authz.cs +++ b/backend/src/BigRegister.Api/Domain/Authorization/Authz.cs @@ -43,7 +43,7 @@ public static class Authz public static IReadOnlyList RoleCapabilities(Principal principal) => principal.Role switch { PrincipalRole.Approver => new[] { "brief:approve", "brief:reject", "brief:send" }, - PrincipalRole.Admin => new[] { "orgtemplate:edit", "stamdata:edit", "cases:manage" }, + PrincipalRole.Admin => new[] { "orgtemplate:edit", "stamdata:edit", "cases:manage", "flags:manage" }, _ => Array.Empty(), }; @@ -74,6 +74,9 @@ public static class Authz /// list + admin delete. public static bool CanManageCases(Principal principal) => principal.Role == PrincipalRole.Admin; + /// Feature-flag management (WP-47): admin-only, resource-independent — role IS the decision. + public static bool CanManageFeatureFlags(Principal principal) => principal.Role == PrincipalRole.Admin; + /// Field-level PII (PRD-0002 §5c, phase P2): the case screen's BIG-nummer ships /// masked by default; only the behandelaar (Drafter) composing the case — the actor /// whose behandel-scherm shows the field — may reveal it. Role-based in the POC; a diff --git a/backend/src/BigRegister.Api/Domain/Features/FeatureFlags.cs b/backend/src/BigRegister.Api/Domain/Features/FeatureFlags.cs new file mode 100644 index 0000000..4132c03 --- /dev/null +++ b/backend/src/BigRegister.Api/Domain/Features/FeatureFlags.cs @@ -0,0 +1,22 @@ +namespace BigRegister.Domain.Features; + +/// A known feature flag — DECLARED in code (this catalog, build-validated), TOGGLED at runtime +/// (on/off state persisted in SQLite by FeatureFlagStore). Same split as stamdata (catalog is +/// config-as-code) × org-templates (runtime state in the DB): what flags exist is code; whether +/// they're on is operational config an admin flips. +public sealed record FeatureFlagDef(string Key, string Description, bool DefaultEnabled); + +public static class FeatureFlags +{ + /// Whether self-service registration (inschrijving) is open. When off, the FE hides the + /// "Inschrijven" action and POST /applications for a `registratie` is refused (server-enforced). + public const string InschrijvingOpen = "inschrijving-open"; + + public static readonly IReadOnlyList Catalog = new[] + { + new FeatureFlagDef( + InschrijvingOpen, + "Zelf-inschrijving in het BIG-register is opengesteld.", + DefaultEnabled: true), + }; +} diff --git a/backend/src/BigRegister.Api/Program.cs b/backend/src/BigRegister.Api/Program.cs index 624a83f..be2545a 100644 --- a/backend/src/BigRegister.Api/Program.cs +++ b/backend/src/BigRegister.Api/Program.cs @@ -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(); +// 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>(); + +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 action) statusCode: StatusCodes.Status403Forbidden); } +// One gate for the feature-flag toggle — the enforce twin of `flags:manage` (WP-47). +IResult FlagsAdmin(HttpContext ctx, Func 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 — diff --git a/backend/swagger.json b/backend/swagger.json index e3ee165..76406c0 100644 --- a/backend/swagger.json +++ b/backend/swagger.json @@ -852,6 +852,73 @@ } } }, + "/api/v1/flags": { + "get": { + "tags": [ + "BigRegister.Api, Version=1.0.0.0, Culture=neutral, PublicKeyToken=null" + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/FeatureFlagDto" + } + } + } + } + } + } + } + }, + "/api/v1/admin/flags/{key}": { + "put": { + "tags": [ + "BigRegister.Api, Version=1.0.0.0, Culture=neutral, PublicKeyToken=null" + ], + "parameters": [ + { + "name": "key", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SetFeatureFlagRequest" + } + } + }, + "required": true + }, + "responses": { + "204": { + "description": "No Content" + }, + "404": { + "description": "Not Found" + }, + "403": { + "description": "Forbidden", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + } + } + } + }, "/api/v1/brief": { "get": { "tags": [ @@ -1830,6 +1897,23 @@ }, "additionalProperties": false }, + "FeatureFlagDto": { + "type": "object", + "properties": { + "key": { + "type": "string", + "nullable": true + }, + "description": { + "type": "string", + "nullable": true + }, + "enabled": { + "type": "boolean" + } + }, + "additionalProperties": false + }, "HerregistratieDecisionsDto": { "type": "object", "properties": { @@ -2396,6 +2480,15 @@ }, "additionalProperties": false }, + "SetFeatureFlagRequest": { + "type": "object", + "properties": { + "enabled": { + "type": "boolean" + } + }, + "additionalProperties": false + }, "StamdataColumnDto": { "type": "object", "properties": { diff --git a/backend/tests/BigRegister.Tests/FeatureFlagTests.cs b/backend/tests/BigRegister.Tests/FeatureFlagTests.cs new file mode 100644 index 0000000..6502e78 --- /dev/null +++ b/backend/tests/BigRegister.Tests/FeatureFlagTests.cs @@ -0,0 +1,72 @@ +using System.Net; +using System.Net.Http.Json; +using BigRegister.Api.Contracts; +using BigRegister.Domain.Features; +using Microsoft.AspNetCore.Mvc.Testing; + +namespace BigRegister.Tests; + +/// WP-47: runtime feature flags — catalog in code, admin-toggled, server-enforced. +public class FeatureFlagTests(TestWebApplicationFactory factory) : IClassFixture +{ + private readonly HttpClient _client = factory.CreateClient(); + + private HttpRequestMessage Admin(HttpMethod method, string path, object? body = null) + { + var req = new HttpRequestMessage(method, path) { Headers = { { "X-Role", "admin" } } }; + if (body is not null) req.Content = JsonContent.Create(body); + return req; + } + + [Fact] + public void The_catalog_has_unique_keys() + { + var keys = FeatureFlags.Catalog.Select(f => f.Key).ToList(); + Assert.Equal(keys.Count, keys.Distinct().Count()); + Assert.All(FeatureFlags.Catalog, f => Assert.False(string.IsNullOrWhiteSpace(f.Key))); + } + + [Fact] + public async Task Get_flags_returns_the_catalog() + { + var flags = await _client.GetFromJsonAsync>("/api/v1/flags"); + Assert.Contains(flags!, f => f.Key == FeatureFlags.InschrijvingOpen); + } + + [Fact] + public async Task Toggling_is_admin_only_and_an_unknown_key_is_404() + { + // Non-admin (no X-Role → drafter) may not toggle. + var denied = await _client.PutAsJsonAsync( + $"/api/v1/admin/flags/{FeatureFlags.InschrijvingOpen}", new { enabled = false }); + Assert.Equal(HttpStatusCode.Forbidden, denied.StatusCode); + + // Admin, unknown flag → 404. + var unknown = await _client.SendAsync(Admin(HttpMethod.Put, "/api/v1/admin/flags/does-not-exist", new { enabled = true })); + Assert.Equal(HttpStatusCode.NotFound, unknown.StatusCode); + } + + [Fact] + public async Task Closing_inschrijving_blocks_a_registratie_then_reopening_allows_it() + { + try + { + // Off → POST /applications for a registratie is refused. + (await _client.SendAsync(Admin(HttpMethod.Put, $"/api/v1/admin/flags/{FeatureFlags.InschrijvingOpen}", new { enabled = false }))) + .EnsureSuccessStatusCode(); + var blocked = await _client.PostAsJsonAsync("/api/v1/applications", new { type = "registratie" }); + Assert.Equal(HttpStatusCode.Forbidden, blocked.StatusCode); + + // On → allowed again. + (await _client.SendAsync(Admin(HttpMethod.Put, $"/api/v1/admin/flags/{FeatureFlags.InschrijvingOpen}", new { enabled = true }))) + .EnsureSuccessStatusCode(); + var ok = await _client.PostAsJsonAsync("/api/v1/applications", new { type = "registratie" }); + Assert.Equal(HttpStatusCode.Created, ok.StatusCode); + } + finally + { + // Leave the flag on (shared DB across this class). + await _client.SendAsync(Admin(HttpMethod.Put, $"/api/v1/admin/flags/{FeatureFlags.InschrijvingOpen}", new { enabled = true })); + } + } +} diff --git a/backend/tests/BigRegister.Tests/OrgTemplateEndpointTests.cs b/backend/tests/BigRegister.Tests/OrgTemplateEndpointTests.cs index fdb6a79..01784b9 100644 --- a/backend/tests/BigRegister.Tests/OrgTemplateEndpointTests.cs +++ b/backend/tests/BigRegister.Tests/OrgTemplateEndpointTests.cs @@ -201,7 +201,9 @@ public class OrgTemplateEndpointTests(TestWebApplicationFactory factory) : IClas { var res = await _client.SendAsync(Req(HttpMethod.Get, "/api/v1/me", role: "admin")); var me = await res.Content.ReadFromJsonAsync(); - Assert.Equal(new[] { "orgtemplate:edit", "stamdata:edit", "cases:manage" }, me!.Capabilities); + Assert.Equal( + new[] { "orgtemplate:edit", "stamdata:edit", "cases:manage", "flags:manage" }, + me!.Capabilities); } [Fact] diff --git a/docs/project/backlog/README.md b/docs/project/backlog/README.md index c8ee163..57232fb 100644 --- a/docs/project/backlog/README.md +++ b/docs/project/backlog/README.md @@ -91,6 +91,7 @@ for its existing violations, so every WP ends green. | [WP-44](WP-44-context-generator.md) | Runnable generator: `gen:context` | 8 · platform/DX/showcase | todo | | [WP-45](WP-45-create-ssp-generator.md) | `create-ssp` bootstrap generator (mechanise new-ssp) | 8 · platform/DX/showcase | todo | | [WP-46](WP-46-vitest-coverage.md) | Vitest coverage (report + report-only thresholds) | 8 · platform/DX/showcase | done | +| [WP-47](WP-47-feature-flags.md) | Runtime feature flags (catalog-in-code, admin toggle, FE+backend) | 8 · platform/DX/showcase | done | Sequencing dependencies (stated in the WPs too): 01 before 10–15 (axe covers story churn); 03/04 before 05–09 (boundaries stop new violations during refactors); 06 before 07 (typed diff --git a/docs/project/backlog/WP-47-feature-flags.md b/docs/project/backlog/WP-47-feature-flags.md new file mode 100644 index 0000000..613e8fd --- /dev/null +++ b/docs/project/backlog/WP-47-feature-flags.md @@ -0,0 +1,38 @@ +# WP-47 — Runtime feature flags (catalog-in-code, admin-toggled) + +Status: done +Phase: 8 — platform/DX/showcase + +## Why + +Ops needs to turn features on/off at runtime without a deploy. Mirrors the two house templates: the +capability spine (server-resolved, FE reads) and the org-template runtime-SQLite config (admin edits +at runtime). Per ADR-0004 the **catalog** (which flags exist + defaults) is config-as-code; only the +**on/off state** is runtime. + +## Decisions (locked with the user) + +- Catalog in code (typed, build-validated); on/off state in SQLite; admin toggles at runtime. +- **FE + backend enforcement** — the FE hides the surface AND the server enforces (a flag can guard + a real feature, not just UI). + +## Outcome + +- Backend: `Domain/Features/FeatureFlags.cs` (catalog: one flag `inschrijving-open`, default on) + + `Data/FeatureFlagStore.cs` (`FeatureFlagEntity` in SQLite + migration; `All()` merges catalog + defaults with overrides, `IsEnabled`, `Set` rejects unknown keys). `GET /flags` (readable, drives + FE gating) + `PUT /admin/flags/{key}` (gated by new `flags:manage` cap + `FlagsAdmin`). Enforced + end-to-end: `POST /applications` for a `registratie` returns 403 when `inschrijving-open` is off. +- FE: `shared/domain/feature-flag.ts` + `feature-flags.adapter.ts` (parse boundary) + + `shared/application/feature-flags.store.ts` (root singleton, `enabled(key)` deny-by-default, + `set`). Capability `flags:manage` (union + me.adapter + role.interceptor `/api/v1/admin/flags`). + The "Inschrijven" nav item + dashboard action hide when the flag is off. Admin toggle page + `beheer/ui/feature-flags.page.ts` at `/beheer/functies`, in `ADMIN_LINKS`. +- Tests: catalog-unique + endpoint (admin-only toggle, 404 unknown key, close→403 / reopen→201). + `/me` cap-list test updated. Backend 136; typed client regenerated. + +## Acceptance criteria + +- [x] Admin toggles a flag at runtime; state persists (SQLite) and the whole app reads it. +- [x] FE hides the flagged feature AND the backend enforces it (registration close → 403). +- [x] `npm run ci` green (dep:check, localized build, backend `dotnet test`, drift clean after commit). diff --git a/src/app/app.routes.ts b/src/app/app.routes.ts index a105472..67618a5 100644 --- a/src/app/app.routes.ts +++ b/src/app/app.routes.ts @@ -91,6 +91,13 @@ export const routes: Routes = [ canActivate: [capabilityGuard('cases:manage')], loadComponent: () => import('@beheer/ui/audit.page').then((m) => m.AuditPage), }, + { + path: 'beheer/functies', + // Admin-only feature-flag toggles (WP-47), gated by `flags:manage`. + canActivate: [capabilityGuard('flags:manage')], + loadComponent: () => + import('@beheer/ui/feature-flags.page').then((m) => m.FeatureFlagsPage), + }, { path: 'concepts', loadComponent: () => import('./showcase/concepts.page').then((m) => m.ConceptsPage), diff --git a/src/app/beheer/ui/feature-flags.page.ts b/src/app/beheer/ui/feature-flags.page.ts new file mode 100644 index 0000000..d97c404 --- /dev/null +++ b/src/app/beheer/ui/feature-flags.page.ts @@ -0,0 +1,98 @@ +import { Component, computed, inject } from '@angular/core'; +import { PageShellComponent } from '@shared/layout/page-shell/page-shell.component'; +import { AlertComponent } from '@shared/ui/alert/alert.component'; +import { ButtonComponent } from '@shared/ui/button/button.component'; +import { ASYNC } from '@shared/ui/async/async.component'; +import { AccessStore } from '@shared/application/access.store'; +import { FeatureFlagStore } from '@shared/application/feature-flags.store'; + +/** + * Admin page: toggle runtime feature flags (WP-47). Deny-by-default capability gate + * (`flags:manage`). The catalog is server-owned (code); this only flips the on/off state, which + * the whole app reads via the same `FeatureFlagStore`. + */ +@Component({ + selector: 'app-feature-flags-page', + imports: [PageShellComponent, AlertComponent, ButtonComponent, ...ASYNC], + styles: [ + ` + .flag { + display: flex; + align-items: center; + justify-content: space-between; + gap: var(--rhc-space-max-lg); + padding: var(--rhc-space-max-md) 0; + border-block-end: var(--rhc-border-width-sm) solid var(--rhc-color-cool-grey-200); + } + .flag .meta { + min-inline-size: 0; + } + .flag .key { + font-family: monospace; + font-size: var(--rhc-text-font-size-sm); + color: var(--rhc-color-grijs-700); + } + .state { + font-weight: var(--rhc-text-font-weight-semi-bold); + margin-inline-end: var(--rhc-space-max-md); + } + `, + ], + template: ` + + @if (!access.ready()) { + + } @else if (!canManage()) { + {{ deniedText }} + } @else { + + + {{ failedText }} + {{ retryText }} + + + @for (f of store.all(); track f.key) { +
+
+
{{ f.description }}
+
{{ f.key }}
+
+
+ {{ f.enabled ? onText : offText }} + {{ f.enabled ? disableText : enableText }} +
+
+ } +
+
+ } +
+ `, +}) +export class FeatureFlagsPage { + protected store = inject(FeatureFlagStore); + protected access = inject(AccessStore); + + protected canManage = computed(() => this.access.can('flags:manage')); + + protected heading = $localize`:@@flags.heading:Functievlaggen`; + protected intro = $localize`:@@flags.intro:Zet functionaliteit aan of uit tijdens runtime. De catalogus staat vast in code; hier beheert u de status.`; + protected deniedText = $localize`:@@flags.denied:U hebt geen rechten om functievlaggen te beheren.`; + protected failedText = $localize`:@@flags.failed:De functievlaggen konden niet worden geladen.`; + protected retryText = $localize`:@@flags.retry:Opnieuw proberen`; + protected onText = $localize`:@@flags.on:Aan`; + protected offText = $localize`:@@flags.off:Uit`; + protected enableText = $localize`:@@flags.enable:Aanzetten`; + protected disableText = $localize`:@@flags.disable:Uitzetten`; + + protected toggle(key: string, enabled: boolean) { + void this.store.set(key, enabled); + } + protected reload() { + void this.store.load(); + } +} diff --git a/src/app/registratie/ui/dashboard.page.ts b/src/app/registratie/ui/dashboard.page.ts index fdb81e5..1b675ac 100644 --- a/src/app/registratie/ui/dashboard.page.ts +++ b/src/app/registratie/ui/dashboard.page.ts @@ -11,6 +11,8 @@ import { ApplicationListComponent } from '@shared/ui/application-list/applicatio import { ApplicationLinkComponent } from '@shared/ui/application-link/application-link.component'; import { ASYNC } from '@shared/ui/async/async.component'; import { AccessStore } from '@shared/application/access.store'; +import { FeatureFlagStore } from '@shared/application/feature-flags.store'; +import { FLAG_INSCHRIJVING_OPEN } from '@shared/domain/feature-flag'; import { ADMIN_LINKS } from '@shared/layout/admin-links'; import { RegistrationSummaryComponent } from '@registratie/ui/registration-summary/registration-summary.component'; import { RegistrationTableComponent } from '@registratie/ui/registration-table/registration-table.component'; @@ -176,7 +178,7 @@ import { tasksFromProfile } from '@registratie/domain/tasks';
Wat wilt u doen? - @for (a of acties; track a.to) { + @for (a of acties(); track a.to) {
  • + this.allActies.filter( + (a) => a.to !== '/registreren' || this.flags.enabled(FLAG_INSCHRIJVING_OPEN), + ), + ); } diff --git a/src/app/shared/application/feature-flags.store.ts b/src/app/shared/application/feature-flags.store.ts new file mode 100644 index 0000000..94806e2 --- /dev/null +++ b/src/app/shared/application/feature-flags.store.ts @@ -0,0 +1,58 @@ +import { Injectable, computed, inject, signal } from '@angular/core'; +import { RemoteData } from '@shared/application/remote-data'; +import { FeatureFlag } from '@shared/domain/feature-flag'; +import { FeatureFlagsAdapter, parseFlags } from '@shared/infrastructure/feature-flags.adapter'; + +type Err = Error | undefined; + +/** + * Runtime feature-flag state (WP-47) — one root singleton, mirroring `AccessStore`. Loads the + * resolved flag set once from `GET /flags`; `enabled(key)` gates a feature (deny-by-default: + * false until loaded / unknown key). `set()` is the admin toggle (PUT + reload). The catalog is + * server-owned; the FE only mirrors + renders it. + */ +@Injectable({ providedIn: 'root' }) +export class FeatureFlagStore { + private adapter = inject(FeatureFlagsAdapter); + private state = signal>({ tag: 'Loading' }); + + readonly flags = this.state.asReadonly(); + /** The resolved list (empty until loaded) — for the admin toggle UI. */ + readonly all = computed(() => { + const rd = this.state(); + return rd.tag === 'Success' ? rd.value : []; + }); + + constructor() { + void this.load(); + } + + async load() { + if (this.state().tag !== 'Success') this.state.set({ tag: 'Loading' }); + try { + const parsed = parseFlags(await this.adapter.list()); + this.state.set( + parsed.ok + ? { tag: 'Success', value: parsed.value } + : { tag: 'Failure', error: new Error(parsed.error) }, + ); + } catch (e) { + this.state.set({ tag: 'Failure', error: e as Error }); + } + } + + /** Deny-by-default: false while loading/failed or for an unknown key. Reactive (reads the signal). */ + enabled(key: string): boolean { + const rd = this.state(); + return rd.tag === 'Success' && (rd.value.find((f) => f.key === key)?.enabled ?? false); + } + + /** Admin toggle: persist then reload so the state reflects the server. */ + async set(key: string, enabled: boolean) { + try { + await this.adapter.set(key, enabled); + } finally { + await this.load(); + } + } +} diff --git a/src/app/shared/domain/capability.ts b/src/app/shared/domain/capability.ts index 84ee405..7886d56 100644 --- a/src/app/shared/domain/capability.ts +++ b/src/app/shared/domain/capability.ts @@ -8,4 +8,5 @@ export type Capability = | 'brief:send' | 'orgtemplate:edit' | 'stamdata:edit' - | 'cases:manage'; + | 'cases:manage' + | 'flags:manage'; diff --git a/src/app/shared/domain/feature-flag.ts b/src/app/shared/domain/feature-flag.ts new file mode 100644 index 0000000..094cc4b --- /dev/null +++ b/src/app/shared/domain/feature-flag.ts @@ -0,0 +1,9 @@ +/** A runtime feature flag as the FE sees it (resolved: catalog default + admin override). */ +export interface FeatureFlag { + key: string; + description: string; + enabled: boolean; +} + +/** Known flag keys the FE gates on — must match the backend `FeatureFlags` catalog. */ +export const FLAG_INSCHRIJVING_OPEN = 'inschrijving-open'; diff --git a/src/app/shared/infrastructure/api-client.ts b/src/app/shared/infrastructure/api-client.ts index ed60fb5..6943f55 100644 --- a/src/app/shared/infrastructure/api-client.ts +++ b/src/app/shared/infrastructure/api-client.ts @@ -1198,6 +1198,92 @@ export class ApiClient { return Promise.resolve(null as any); } + /** + * @return OK + */ + flagsAll(): Promise { + let url_ = this.baseUrl + "/api/v1/flags"; + url_ = url_.replace(/[?&]$/, ""); + + let options_: RequestInit = { + method: "GET", + headers: { + "Accept": "application/json" + } + }; + + return this.http.fetch(url_, options_).then((_response: Response) => { + return this.processFlagsAll(_response); + }); + } + + protected processFlagsAll(response: Response): Promise { + const status = response.status; + let _headers: any = {}; if (response.headers && response.headers.forEach) { response.headers.forEach((v: any, k: any) => _headers[k] = v); }; + if (status === 200) { + return response.text().then((_responseText) => { + let result200: any = null; + result200 = _responseText === "" ? null : JSON.parse(_responseText, this.jsonParseReviver) as FeatureFlagDto[]; + return result200; + }); + } else if (status !== 200 && status !== 204) { + return response.text().then((_responseText) => { + return throwException("An unexpected server error occurred.", status, _responseText, _headers); + }); + } + return Promise.resolve(null as any); + } + + /** + * @return No Content + */ + flags(key: string, body: SetFeatureFlagRequest): Promise { + let url_ = this.baseUrl + "/api/v1/admin/flags/{key}"; + if (key === undefined || key === null) + throw new globalThis.Error("The parameter 'key' must be defined."); + url_ = url_.replace("{key}", encodeURIComponent("" + key)); + url_ = url_.replace(/[?&]$/, ""); + + const content_ = JSON.stringify(body); + + let options_: RequestInit = { + body: content_, + method: "PUT", + headers: { + "Content-Type": "application/json", + } + }; + + return this.http.fetch(url_, options_).then((_response: Response) => { + return this.processFlags(_response); + }); + } + + protected processFlags(response: Response): Promise { + const status = response.status; + let _headers: any = {}; if (response.headers && response.headers.forEach) { response.headers.forEach((v: any, k: any) => _headers[k] = v); }; + if (status === 204) { + return response.text().then((_responseText) => { + return; + }); + } else if (status === 403) { + return response.text().then((_responseText) => { + let result403: any = null; + result403 = _responseText === "" ? null : JSON.parse(_responseText, this.jsonParseReviver) as ProblemDetails; + return throwException("Forbidden", status, _responseText, _headers, result403); + }); + } else if (status === 404) { + return response.text().then((_responseText) => { + return throwException("Not Found", status, _responseText, _headers); + }); + } else if (status !== 200 && status !== 204) { + return response.text().then((_responseText) => { + return throwException("An unexpected server error occurred.", status, _responseText, _headers); + }); + } + return Promise.resolve(null as any); + } + /** * @return OK */ @@ -1918,6 +2004,12 @@ export interface DuoLookupDto { handmatig?: ManualDiplomaPolicyDto; } +export interface FeatureFlagDto { + key?: string | undefined; + description?: string | undefined; + enabled?: boolean; +} + export interface HerregistratieDecisionsDto { eligibleForHerregistratie?: boolean; herregistratieReason?: string | undefined; @@ -2098,6 +2190,10 @@ export interface SaveOrgTemplateRequest { draft?: OrgTemplateDto; } +export interface SetFeatureFlagRequest { + enabled?: boolean; +} + export interface StamdataColumnDto { name?: string | undefined; type?: string | undefined; diff --git a/src/app/shared/infrastructure/feature-flags.adapter.ts b/src/app/shared/infrastructure/feature-flags.adapter.ts new file mode 100644 index 0000000..48efd9b --- /dev/null +++ b/src/app/shared/infrastructure/feature-flags.adapter.ts @@ -0,0 +1,38 @@ +import { Injectable, inject } from '@angular/core'; +import { Result, ok, err } from '@shared/kernel/fp'; +import { ApiClient } from '@shared/infrastructure/api-client'; +import { FeatureFlag } from '@shared/domain/feature-flag'; + +/** + * Infrastructure adapter for feature flags (WP-47): `GET /flags` (resolved set, drives FE gating) + * and the admin `PUT /admin/flags/{key}`. The single place the ApiClient lives for flags; the + * store parses at the boundary. + */ +@Injectable({ providedIn: 'root' }) +export class FeatureFlagsAdapter { + private client = inject(ApiClient); + + list() { + return this.client.flagsAll(); + } + set(key: string, enabled: boolean) { + return this.client.flags(key, { enabled }); + } +} + +/** Trust-boundary parse of the flag set. */ +export function parseFlags(json: unknown): Result { + if (!Array.isArray(json)) return err('flags: not an array'); + const out: FeatureFlag[] = []; + for (const f of json) { + if (typeof f !== 'object' || f === null) return err('flags: row not an object'); + const d = f as Partial; + if (typeof d.key !== 'string' || typeof d.enabled !== 'boolean') return err('flags: bad shape'); + out.push({ + key: d.key, + description: typeof d.description === 'string' ? d.description : '', + enabled: d.enabled, + }); + } + return ok(out); +} diff --git a/src/app/shared/infrastructure/me.adapter.ts b/src/app/shared/infrastructure/me.adapter.ts index 834ce1c..0524a6a 100644 --- a/src/app/shared/infrastructure/me.adapter.ts +++ b/src/app/shared/infrastructure/me.adapter.ts @@ -10,6 +10,7 @@ const KNOWN: readonly Capability[] = [ 'orgtemplate:edit', 'stamdata:edit', 'cases:manage', + 'flags:manage', ]; /** diff --git a/src/app/shared/infrastructure/role.interceptor.ts b/src/app/shared/infrastructure/role.interceptor.ts index 5b9750b..5dc5140 100644 --- a/src/app/shared/infrastructure/role.interceptor.ts +++ b/src/app/shared/infrastructure/role.interceptor.ts @@ -14,6 +14,7 @@ const ROLE_AWARE = [ '/api/v1/admin/org-template', '/api/v1/admin/cases', '/api/v1/admin/audit', + '/api/v1/admin/flags', '/api/v1/stamdata', '/api/v1/me', ]; diff --git a/src/app/shared/layout/admin-links.ts b/src/app/shared/layout/admin-links.ts index 294c859..719871c 100644 --- a/src/app/shared/layout/admin-links.ts +++ b/src/app/shared/layout/admin-links.ts @@ -37,4 +37,10 @@ export const ADMIN_LINKS: readonly AdminLink[] = [ to: '/beheer/audit', cap: 'cases:manage', }, + { + label: $localize`:@@header.nav.functies:Functievlaggen`, + description: $localize`:@@admin.link.functies.desc:Functionaliteit aan- of uitzetten`, + to: '/beheer/functies', + cap: 'flags:manage', + }, ]; diff --git a/src/app/shared/layout/site-header/site-header.component.ts b/src/app/shared/layout/site-header/site-header.component.ts index 53935d8..94a673a 100644 --- a/src/app/shared/layout/site-header/site-header.component.ts +++ b/src/app/shared/layout/site-header/site-header.component.ts @@ -4,6 +4,8 @@ import { NavigationEnd, Router, RouterLink, RouterLinkActive } from '@angular/ro import { filter, map } from 'rxjs/operators'; import { SESSION_PORT } from '@shared/application/session.port'; import { AccessStore } from '@shared/application/access.store'; +import { FeatureFlagStore } from '@shared/application/feature-flags.store'; +import { FLAG_INSCHRIJVING_OPEN } from '@shared/domain/feature-flag'; import { ADMIN_LINKS } from '@shared/layout/admin-links'; import { BreadcrumbComponent } from '@shared/layout/breadcrumb/breadcrumb.component'; import { trailFor } from '@shared/layout/breadcrumb/breadcrumb-trail'; @@ -87,7 +89,7 @@ const NAV_ITEMS: readonly HeaderNavItem[] = [