From 494cee9d0841d4de85fcda0dee87d462a8149fe6 Mon Sep 17 00:00:00 2001 From: Edwin van den Houdt Date: Thu, 27 Aug 2026 13:55:41 +0200 Subject: [PATCH 1/6] fix(uploads): route the admin delete through CasesAdmin (RB-08) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit DELETE /admin/uploads/{documentId} was gated by a standalone `X-Admin: true` header check (`IsAdmin`), outside the `Authz` module entirely and outside the `CasesAdmin`/`StamdataAdmin`/`OrgAdmin`/ `FlagsAdmin` wrappers the four sibling admin surfaces use. It wrote no AuthzAuditStore row, so a destructive cross-owner document delete never appeared on /beheer/audit. A repo-wide grep confirmed the only sender of X-Admin was the backend test itself — no frontend or e2e path depends on it — so the gate was safe to delete outright. Routed the endpoint through CasesAdmin (Authz.CanManageCases), the same wrapper the other admin-cases endpoints use. RB-07 already moved AuditAuthz onto every *Admin wrapper's allow path, so this gets the missing audit row for free with no second AuditAuthz call. Deleted the now-unused IsAdmin function and updated the two comments that referenced the old X-Admin seam. Updated EndpointTests.cs's Admin_delete_requires_admin_role to send X-Role: admin instead of X-Admin: true, and added AuthzAuditTests.An_admin_upload_delete_is_recorded, which asserts the cases:manage/allow row count increases by exactly one (a plain Contains would already be satisfied by this test class's other cases:manage calls). Verified both tests fail red against the pre-fix gate. Co-Authored-By: Claude Opus 5 --- backend/src/BigRegister.Api/Program.cs | 19 +++-- .../BigRegister.Tests/AuthzAuditTests.cs | 39 ++++++++++ .../tests/BigRegister.Tests/EndpointTests.cs | 4 +- .../refactor-backlog/implementation/rb-08.md | 75 +++++++++++++++++++ 4 files changed, 126 insertions(+), 11 deletions(-) create mode 100644 docs/project/refactor-backlog-setup/refactor-backlog/implementation/rb-08.md diff --git a/backend/src/BigRegister.Api/Program.cs b/backend/src/BigRegister.Api/Program.cs index 614162e..270eacd 100644 --- a/backend/src/BigRegister.Api/Program.cs +++ b/backend/src/BigRegister.Api/Program.cs @@ -269,13 +269,14 @@ api.MapDelete("/uploads/{documentId}", (string documentId, HttpContext ctx) => .ProducesProblem(StatusCodes.Status409Conflict) .Produces(StatusCodes.Status404NotFound); -// Admin delete (seam): a real system requires an admin role; here an X-Admin header -// stands in. Bypasses ownership, unlinks, and flags the submission for review. -api.MapDelete("/admin/uploads/{documentId}", (string documentId, HttpContext ctx) => - !IsAdmin(ctx) ? Results.StatusCode(StatusCodes.Status403Forbidden) - : DocumentStore.AdminDelete(documentId, "admin") ? Results.NoContent() : Results.NotFound()) +// Admin delete: bypasses ownership, unlinks, and flags the submission for review. Gated +// by the same CasesAdmin wrapper (cases:manage) the other admin-cases endpoints use +// (RB-08/BIO-003) — it used to be gated by a standalone X-Admin header, outside Authz and +// unaudited; CasesAdmin gives it the missing AuthzAuditStore row for free (RB-07). +api.MapDelete("/admin/uploads/{documentId}", (string documentId, HttpContext ctx) => CasesAdmin(ctx, () => + DocumentStore.AdminDelete(documentId, "admin") ? Results.NoContent() : Results.NotFound())) .Produces(StatusCodes.Status204NoContent) -.Produces(StatusCodes.Status403Forbidden) +.ProducesProblem(StatusCodes.Status403Forbidden) .Produces(StatusCodes.Status404NotFound); // --- Applications (aanvragen): the system of record the dashboard reads. --- @@ -611,8 +612,8 @@ api.MapPut("/admin/flags/{key}", (string key, SetFeatureFlagRequest req, HttpCon // --- 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= -// toggle) — no real identities in this POC. --- +// dev-only stand-in via X-Role (mirrors the FE ?role= toggle) — no real +// identities in this POC. --- api.MapGet("/brief", (HttpContext ctx) => { @@ -787,8 +788,6 @@ api.MapPost("/admin/org-template/{subOrgId}/rollback/{version:int}", (string sub app.Run(); -static bool IsAdmin(HttpContext ctx) => ctx.Request.Headers["X-Admin"] == "true"; - // One gate for every org-template endpoint — the enforce twin of the // `orgtemplate:edit` capability RoleCapabilities emits (single Authz source). // diff --git a/backend/tests/BigRegister.Tests/AuthzAuditTests.cs b/backend/tests/BigRegister.Tests/AuthzAuditTests.cs index 094f456..1252557 100644 --- a/backend/tests/BigRegister.Tests/AuthzAuditTests.cs +++ b/backend/tests/BigRegister.Tests/AuthzAuditTests.cs @@ -1,4 +1,5 @@ using System.Net; +using System.Net.Http.Headers; using System.Net.Http.Json; using System.Text.RegularExpressions; using BigRegister.Api.Contracts; @@ -27,6 +28,20 @@ public class AuthzAuditTests(TestWebApplicationFactory factory) : IClassFixture< return (await res.Content.ReadFromJsonAsync>())!; } + private async Task UploadAsOwner() + { + var form = new MultipartFormDataContent(); + var file = new ByteArrayContent(new byte[] { 1, 2, 3 }); + file.Headers.ContentType = new MediaTypeHeaderValue("application/pdf"); + form.Add(file, "file", "diploma.pdf"); + form.Add(new StringContent("diploma"), "categoryId"); + form.Add(new StringContent("local-rb08"), "localId"); + form.Add(new StringContent("registratie"), "wizardId"); + var res = await _client.PostAsync("/api/v1/uploads", form); + res.EnsureSuccessStatusCode(); + return (await res.Content.ReadFromJsonAsync())!.DocumentId; + } + [Fact] public async Task A_denied_admin_action_is_recorded() { @@ -54,6 +69,30 @@ public class AuthzAuditTests(TestWebApplicationFactory factory) : IClassFixture< Assert.Contains(await AuditLog(), e => e.Action == "cases:manage" && e.Decision == "allow" && e.Role == "Admin"); } + /// RB-08/BIO-003: the admin upload delete used to be gated by a standalone X-Admin + /// header, outside Authz and writing no AuthzAuditStore row at all. Routing it through + /// CasesAdmin (cases:manage) gives it the same allow-path row every other admin-cases + /// endpoint gets, for free, per RB-07. `CasesAdmin` audits under a fixed "cases" + /// resource shared with the other admin-cases endpoints, so this asserts a **count** + /// increase — reading the store directly (not via `GET /admin/audit`, itself a + /// `CasesAdmin` endpoint that would write its own row and confound the count) — + /// rather than mere presence, which this class's other cases:manage calls would + /// already satisfy even without the fix. + [Fact] + public async Task An_admin_upload_delete_is_recorded() + { + bool IsCasesManageAllow(AuthzAuditEntry e) => + e.Action == "cases:manage" && e.Decision == "allow" && e.Role == "Admin"; + + var documentId = await UploadAsOwner(); + var before = AuthzAuditStore.List().Count(IsCasesManageAllow); + + (await _client.SendAsync(Admin(HttpMethod.Delete, $"/api/v1/admin/uploads/{documentId}"))) + .EnsureSuccessStatusCode(); + + Assert.Equal(before + 1, AuthzAuditStore.List().Count(IsCasesManageAllow)); + } + /// The flag toggle writes no log line of its own, so the audit row is the only record that /// it happened — a bare "feature-flags" resource would not say which flag. [Fact] diff --git a/backend/tests/BigRegister.Tests/EndpointTests.cs b/backend/tests/BigRegister.Tests/EndpointTests.cs index c364a96..b8fb2d7 100644 --- a/backend/tests/BigRegister.Tests/EndpointTests.cs +++ b/backend/tests/BigRegister.Tests/EndpointTests.cs @@ -213,11 +213,13 @@ public class EndpointTests(TestWebApplicationFactory factory) : IClassFixture ctx.Request.Headers["X-Admin"] == "true";` +and `DELETE /admin/uploads/{documentId}` (`:274`) was gated by `IsAdmin` alone — outside +`Authz`, outside every wrapper the four sibling admin surfaces use, and writing no +`AuthzAuditStore` row at all. `DocumentStore.AdminDelete` bypasses ownership and deletes the +row and its bytes; the only record left behind was a `DocumentStore.Audit("delete-admin", …)` +metadata row, which never surfaces on `/beheer/audit`. + +`grep -rn "X-Admin"` over `apps`, `libs`, `backend`, `e2e` (re-verified before deleting the +gate, as the ticket asked) confirmed the finding: the only sender was +`backend/tests/BigRegister.Tests/EndpointTests.cs:231`. No frontend or e2e path uses this +header — it was an orphaned gate, not a live seam. + +## What changed + +| File | Change | +| -------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `Program.cs` `DELETE /admin/uploads/{id}` | now `CasesAdmin(ctx, () => DocumentStore.AdminDelete(...) ? NoContent : NotFound)` — same wrapper the other four admin-cases endpoints use; response doc changed `Produces(403)` → `ProducesProblem(403)` to match `CasesAdmin`'s `Results.Problem` | +| `Program.cs` `IsAdmin` | **deleted** | +| `Program.cs` two stale comments | the endpoint's own comment rewritten to describe the new gate; the brief-section banner comment ("mirrors the X-Admin seam") no longer references a gate that doesn't exist | +| `tests/BigRegister.Tests/EndpointTests.cs` | `Admin_delete_requires_admin_role` sends `X-Role: admin` instead of `X-Admin: true` | +| `tests/BigRegister.Tests/AuthzAuditTests.cs` | **new** `An_admin_upload_delete_is_recorded` — asserts the `cases:manage`/`allow`/`Admin` row count increases by exactly one after the delete | + +No new `Authz` capability was added — `CasesAdmin`/`Authz.CanManageCases` is the wrapper the +ticket named as the expected outcome, and nothing about this endpoint needed a narrower +capability than "manage cases" already provides. + +**RB-07 already moved `AuditAuthz` onto the allow path for every `*Admin` wrapper**, so +routing through `CasesAdmin` gives BIO-003's missing audit row for free. No second +`AuditAuthz` call was added — confirmed by reading `CasesAdmin`'s body (`Program.cs`): it +calls `AuditAuthz(ctx, "cases:manage", "cases", ok, principal)` unconditionally before +branching on `ok`. + +## Judgement calls + +- **Test asserts a count delta, not mere presence.** `CasesAdmin` audits under a fixed + `"cases"` resource literal shared by every `cases:manage` call (`GET /admin/cases`, + `DELETE /admin/cases/{id}`, `GET /admin/audit` itself, and now this endpoint), so + `Assert.Contains(rows, cases:manage/allow/Admin)` would already be satisfied by this test + class's _other_ tests even without the fix. The new test counts matching rows before and + after the delete and asserts the count grew by exactly one. It reads `AuthzAuditStore.List()` + in-process rather than through `GET /admin/audit` — that endpoint is itself a `CasesAdmin` + read, so calling it to take the "before" measurement would have written its own + `cases:manage`/`allow` row and silently inflated the count by one every time it was called + (caught this by running the test once against the fix with an HTTP-based baseline: it + failed with an off-by-one before switching to the in-process read). +- **Two comments referencing the old gate were also updated**, not just the endpoint mapping + itself — one directly above the endpoint, one in the brief-section banner comment + ("dev-only stand-in via X-Role, mirrors the X-Admin seam") that would otherwise describe a + gate that no longer exists. + +## Known residual + +None new. RB-01's implementation note already records that `GET /uploads/{id}/content` is +reached with no identity header via plain browser navigation — that residual is RB-09's +territory, not this ticket's, and is untouched here. + +## Verification + +- Reverted `Program.cs`'s endpoint change only (`git stash push` on that one file, tests + left in place) and re-ran `dotnet test --filter "AuthzAuditTests|EndpointTests"`: **both** + `EndpointTests.Admin_delete_requires_admin_role` and + `AuthzAuditTests.An_admin_upload_delete_is_recorded` failed red (403 Forbidden — the old + gate rejects `X-Role: admin`, and the count-delta test throws on `EnsureSuccessStatusCode` + before it can assert). Restored the fix (`git stash pop`) and re-ran: both green. +- `dotnet build`: clean. +- `dotnet test` (full suite): **253 passed, 1 failed** — the pre-existing + `OpenZaakIntegrationTests.Admin_cases_returns_the_seeded_zaak_mapped_through_real_HTTP_and_JWT`, + which needs a live OpenZaak container and fails identically on a clean tree; not touched by + this ticket. From 8b8b522052903146a3b7f2d7c69f9a8e7b55cae0 Mon Sep 17 00:00:00 2001 From: Edwin van den Houdt Date: Thu, 27 Aug 2026 14:06:49 +0200 Subject: [PATCH 2/6] fix(auth): make no-identity representable; stub dev-only (RB-09) IIdentityProvider.Resolve returned a non-nullable CallerIdentity, so the interface could not express "no identity" - StubIdentityProvider was forced to invent one for any request carrying no credential at all. Consequence: a production behandelportal build sends no X-Medewerker header (medewerkerInterceptor is dev-only), so it used to authenticate as the seeded citizen, role drafter - failing closed on backoffice capabilities but open on every citizen-scoped endpoint, including CanRevealBigNummer. Resolve now returns CallerIdentity?. StubIdentityProvider keeps a non-nullable return type (a valid narrower override) since it never itself has "no identity" to report - it is registered only under IsDevelopment() now. Production registers nothing and throws an InvalidOperationException immediately during startup instead: there is no real DigiD/employee-SSO provider in this POC yet, so a misconfigured Production deploy must fail before serving a single request, not resolve one per request. The identity-resolution middleware turns a null resolution into a 401 rather than passing it downstream. Added StubIdentityProviderTests.Never_returns_null_even_with_no_headers_at_all and ProductionIdentityProviderTests, which builds its own WebApplicationFactory with UseEnvironment("Production") and asserts startup throws. Verified both new tests fail red against the pre-fix code. RB-01's residual (GET /uploads/{id}/content reached via plain browser navigation, no identity header) is confirmed unchanged in Development and its Production consequence is written up in implementation/rb-09.md for whoever lands the real identity provider - no signed-URL/cookie scheme was designed here, per scope. Co-Authored-By: Claude Opus 5 --- .../Domain/Authorization/IIdentityProvider.cs | 9 +- .../Authorization/StubIdentityProvider.cs | 5 + backend/src/BigRegister.Api/Program.cs | 29 ++++- .../StubIdentityProviderTests.cs | 30 +++++ .../refactor-backlog/implementation/rb-09.md | 123 ++++++++++++++++++ 5 files changed, 191 insertions(+), 5 deletions(-) create mode 100644 docs/project/refactor-backlog-setup/refactor-backlog/implementation/rb-09.md diff --git a/backend/src/BigRegister.Api/Domain/Authorization/IIdentityProvider.cs b/backend/src/BigRegister.Api/Domain/Authorization/IIdentityProvider.cs index 5d4f047..a69a16d 100644 --- a/backend/src/BigRegister.Api/Domain/Authorization/IIdentityProvider.cs +++ b/backend/src/BigRegister.Api/Domain/Authorization/IIdentityProvider.cs @@ -4,9 +4,14 @@ namespace BigRegister.Domain.Authorization; /// Resolves the acting for a request (WP-53) — one of the two actor /// kinds (WP-62, ADR-0002 §3): a zorgverlener (real DigiD claims in production) or a medewerker /// (real employee SSO/eHerkenning claims in production). is -/// the only implementation today. +/// the only implementation today, and is registered only in Development (Program.cs, +/// RB-09/BIO-002). /// public interface IIdentityProvider { - CallerIdentity Resolve(HttpContext ctx); + /// Null when the request carries no identity a real implementation can vouch for — + /// e.g. no credential at all. Returning null, rather than inventing a default, is what makes + /// "unauthenticated" representable; the identity-resolution middleware (Program.cs) + /// turns a null into a 401 instead of a silent identity substitution. + CallerIdentity? Resolve(HttpContext ctx); } diff --git a/backend/src/BigRegister.Api/Domain/Authorization/StubIdentityProvider.cs b/backend/src/BigRegister.Api/Domain/Authorization/StubIdentityProvider.cs index d3ba6ff..0a721cd 100644 --- a/backend/src/BigRegister.Api/Domain/Authorization/StubIdentityProvider.cs +++ b/backend/src/BigRegister.Api/Domain/Authorization/StubIdentityProvider.cs @@ -13,6 +13,11 @@ namespace BigRegister.Domain.Authorization; /// A real system builds this from verified DigiD claims (zorgverlener) / employee SSO claims /// (medewerker); every consumer of carries over unchanged once that /// swap happens. +/// +/// Registered only in Development (Program.cs, RB-09/BIO-002) — it always invents a +/// caller for a request with no credential, which is a deliberate developer convenience, not +/// something a production build may do. Its own return type stays non-nullable: unlike +/// , this stub never has "no identity" to report. /// public sealed class StubIdentityProvider : IIdentityProvider { diff --git a/backend/src/BigRegister.Api/Program.cs b/backend/src/BigRegister.Api/Program.cs index 270eacd..e630dd6 100644 --- a/backend/src/BigRegister.Api/Program.cs +++ b/backend/src/BigRegister.Api/Program.cs @@ -51,7 +51,22 @@ Db.ConnectionString = builder.Configuration.GetConnectionString("AppDb") ?? Db.C // every store call site that used to hardcode DocumentStore.DemoOwner. Stub today (X-Role/ // X-Subject for a zorgverlener, X-Medewerker/X-Rollen for a medewerker); a real // DigiD/employee-SSO provider swaps in without touching a consumer. -builder.Services.AddSingleton(); +// +// RB-09/BIO-002: StubIdentityProvider invents a citizen identity for any request with no +// credential at all — a production behandelportal build sends no X-Medewerker header, so it +// used to authenticate every request as the seeded citizen (open on that citizen's own rights, +// including CanRevealBigNummer). Registering the stub only in Development, and failing to +// start in Production rather than falling through to a per-request 401, means a misconfigured +// deploy never serves a single request. The real DigiD/employee-SSO provider is out of scope +// for this POC (BIO-002's remediation says so explicitly) — until one exists, Production simply +// cannot start, which is the correct fail-closed behaviour for "no identity provider available". +if (builder.Environment.IsDevelopment()) + builder.Services.AddSingleton(); +else if (builder.Environment.IsProduction()) + throw new InvalidOperationException( + "No IIdentityProvider is registered for a Production environment. StubIdentityProvider " + + "is Development-only (RB-09/BIO-002); there is no real DigiD/employee-SSO provider in " + + "this POC yet. Register one before deploying to Production."); // WP-49: the cases (zaken) READ path goes through IZaakSource so a real ZGW backend // (OpenZaak) can replace the local SQLite store behind the same DTO contract — the FE never @@ -111,11 +126,19 @@ app.Use(async (ctx, next) => // WP-53: resolve the acting citizen once per request, right after correlation — everything // downstream (Authz.ResolvePrincipal, the endpoints below) reads it via ctx.Caller() instead of -// re-deriving "who" itself. +// re-deriving "who" itself. RB-09/BIO-002: a null resolution is "no identity", not "the seeded +// citizen" — this is the one place that turns it into a response (401) rather than letting it +// flow downstream as a silent identity substitution. var identityProvider = app.Services.GetRequiredService(); app.Use(async (ctx, next) => { - ctx.SetCaller(identityProvider.Resolve(ctx)); + var identity = identityProvider.Resolve(ctx); + if (identity is null) + { + ctx.Response.StatusCode = StatusCodes.Status401Unauthorized; + return; + } + ctx.SetCaller(identity); await next(ctx); }); diff --git a/backend/tests/BigRegister.Tests/StubIdentityProviderTests.cs b/backend/tests/BigRegister.Tests/StubIdentityProviderTests.cs index 529a154..28fef57 100644 --- a/backend/tests/BigRegister.Tests/StubIdentityProviderTests.cs +++ b/backend/tests/BigRegister.Tests/StubIdentityProviderTests.cs @@ -1,6 +1,8 @@ using BigRegister.Api.Data; using BigRegister.Domain.Authorization; +using Microsoft.AspNetCore.Hosting; using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc.Testing; namespace BigRegister.Tests; @@ -93,4 +95,32 @@ public class StubIdentityProviderTests var caller = Resolve(role: "admin", medewerker: "m.jansen"); Assert.Equal(PrincipalRole.Admin, caller.Role); } + + /// RB-09/BIO-002: IIdentityProvider.Resolve can now return null ("no identity"), but this + /// stub's own contract stays non-nullable — it is a developer convenience that always invents + /// a caller, never a source of "no identity" itself. A request with genuinely no headers at + /// all still resolves to the seeded citizen, unchanged. + [Fact] + public void Never_returns_null_even_with_no_headers_at_all() + { + Assert.NotNull(new StubIdentityProvider().Resolve(new DefaultHttpContext())); + } +} + +/// RB-09/BIO-002: in Production, StubIdentityProvider is not registered at all (it is +/// Development-only) and there is no real DigiD/employee-SSO IIdentityProvider in this POC yet — +/// so a Production build must fail at startup rather than silently resolving every request to +/// the seeded citizen (the failure mode BIO-002 documents). +public class ProductionIdentityProviderTests +{ + [Fact] + public void Production_environment_with_no_real_identity_provider_fails_at_startup() + { + using var factory = new WebApplicationFactory() + .WithWebHostBuilder(builder => builder.UseEnvironment("Production")); + + // The throw happens while the app builds services, before any request can be served — + // triggered here by the test host materialising that host to hand out a client. + Assert.ThrowsAny(() => factory.CreateClient()); + } } diff --git a/docs/project/refactor-backlog-setup/refactor-backlog/implementation/rb-09.md b/docs/project/refactor-backlog-setup/refactor-backlog/implementation/rb-09.md new file mode 100644 index 0000000..8e7fd85 --- /dev/null +++ b/docs/project/refactor-backlog-setup/refactor-backlog/implementation/rb-09.md @@ -0,0 +1,123 @@ +# RB-09 — make "no identity" representable; stub Development-only; fail fast in Production + +Status: **implemented** · 2026-08-27 · Source findings: `07-bio2-compliance.md` BIO-002 (folds in BIO-001(a)/(b)) · `99-backlog.md` RB-09 + +## What was wrong + +`IIdentityProvider.Resolve` returned a non-nullable `CallerIdentity` +(`IIdentityProvider.cs:12`, pre-change), so the interface could not express "no identity" — any +implementation, stub or real, was forced to invent one for an unauthenticated request. +`StubIdentityProvider` was registered unconditionally, for every environment. + +Consequence, traced end to end: `apps/behandelportal/src/app/app.config.ts:57-63` puts +`medewerkerInterceptor` inside the `isDevMode()` provider array, so a production +behandelportal build sends **no** `X-Medewerker`/`X-Rollen` header. With neither header, +`StubIdentityProvider.Resolve` fell through to +`new ZorgverlenerCaller(DocumentStore.DemoOwner, ..., PrincipalRole.Drafter)` — the single +seeded citizen, role `drafter`. That: + +- **Fails closed, correctly, on backoffice capabilities** — `Authz.CanBeoordelen` is + `caller is MedewerkerCaller`, so a zorgverlener caller is always `false` regardless of role. + This part of the design was right and is untouched. +- **Fails open on every citizen-scoped endpoint** — `GET/PUT/DELETE /applications*`, + `POST /applications/{id}/submit`, `DELETE /uploads/{id}`, `GET|PUT /brief`, + `POST /brief/submit|send|reset` all resolve `ctx.Zorgverlener().Bsn` to the seeded citizen's + BSN. An employee with no employee identity was granted a citizen's own read/write rights. +- **Holds `CanRevealBigNummer`** — that capability is `Role == PrincipalRole.Drafter`, and + `drafter` is exactly the no-header default. + +`CallerIdentityHttpContextExtensions.Caller()` (`CallerIdentity.cs:44-50`) already throws +loudly when the identity middleware didn't run — the codebase reached for fail-loud one layer +up and then defaulted one layer down, which is the shape of the bug. + +## What changed + +| File | Change | +| ------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| `Domain/Authorization/IIdentityProvider.cs` | `Resolve` returns `CallerIdentity?`; doc comment states what null means and where it's turned into a response | +| `Domain/Authorization/StubIdentityProvider.cs` | **implementation signature unchanged** (`CallerIdentity`, non-nullable) — a valid, narrower override of the nullable interface method (return-type covariance; the compiler accepts it with zero warnings); doc comment records it is Development-only and never itself returns null | +| `Program.cs` — registration | `StubIdentityProvider` registered only under `builder.Environment.IsDevelopment()`; an `else if (builder.Environment.IsProduction())` branch throws `InvalidOperationException` immediately, before `builder.Build()` — the earliest possible failure point | +| `Program.cs` — identity middleware | resolves the identity once; if `null`, sets `401` and returns without calling `next()`, instead of passing a null (or invented) caller downstream | +| `tests/BigRegister.Tests/StubIdentityProviderTests.cs` | **new** `Never_returns_null_even_with_no_headers_at_all`; **new** `ProductionIdentityProviderTests.Production_environment_with_no_real_identity_provider_fails_at_startup` | + +No consumer beyond the middleware itself calls `IIdentityProvider.Resolve` (`grep -rn +"IIdentityProvider\|identityProvider\."` over `backend/src` confirms exactly one call site) — +`Authz.ResolvePrincipal`, `ZgwTokenProvider.Mint`, and every endpoint read `ctx.Caller()` / +`ctx.Zorgverlener()`, which already throw on a missing identity and are untouched. The 401 +now happens _before_ those are ever reached for a request the middleware rejects. + +## Judgement calls + +- **`StubIdentityProvider`'s own method signature stays `CallerIdentity`, not + `CallerIdentity?`.** The interface needed the nullable shape to make "no identity" + representable in general; the stub itself never has that case (it is a developer + convenience that always invents a caller by design) and returning a narrower, + non-nullable type from an override of a nullable-returning interface method is valid C# + nullable-reference-type covariance — verified with a clean `dotnet build` (0 warnings). + This kept every existing `StubIdentityProviderTests` call site (`private static +CallerIdentity Resolve(...)`) compiling with zero changes, rather than sprinkling + null-forgiving operators through a file whose entire point is "the stub always resolves." +- **The Production-only check is `IsProduction()`, not `!IsDevelopment()`.** The ticket and + BIO-002 both say "Production must fail at startup" specifically. A third environment (e.g. + a hypothetical `Staging`) falls through neither branch, registers no `IIdentityProvider` at + all, and would still fail — one line later, when `app.Services.GetRequiredService< +IIdentityProvider>()` throws .NET's own "no service for type" exception — just with a less + specific message than the one this ticket adds for Production. That fallback is a safety + net, not the intended fail-fast message; if a real non-Production, non-Development + environment is added later, giving it the same explicit message is a one-line follow-up, + not a design gap today. +- **The throw sits before `builder.Build()`**, not after (where `GetRequiredService` already + runs today). Both satisfy "throw during service registration / app build so a misconfigured + deploy never serves a request" — throwing earlier was free and gives a message naming the + actual cause (no real identity provider) rather than a generic DI resolution failure. +- **The 401 short-circuits before `ctx.SetCaller`, not after.** `next(ctx)` is never called, + so no downstream middleware or endpoint runs for a request with no identity — a citizen or + behandelaar endpoint reached this way now gets a clean 401 instead of ever executing. +- **`TestWebApplicationFactory` needed no change.** `WebApplicationFactory` defaults its + test host to the `Development` environment when nothing overrides it (confirmed + empirically: `dotnet test` — every non-Production test, all 253 of them pre-existing plus + 2 new, passed unchanged), so the entire existing test suite continues to exercise the + Development path exactly as before. The Production test builds its own + `WebApplicationFactory().WithWebHostBuilder(b => b.UseEnvironment("Production"))` + rather than touching the shared fixture. + +## Known residual — explicitly out of scope, confirmed and written up per the ticket + +**RB-01's residual is this ticket's territory but is explicitly out of scope for this +ticket**, per the task: `GET /uploads/{documentId}/content` is reached by a plain browser +navigation (`` in `beoordeling-documenten.component.ts`, `previewUrl` in +`libs/shared/src/upload/upload.adapter.ts`) that sends no identity header and never passes +through an Angular interceptor. + +- **In Development, this is unchanged** — verified by reading the endpoint + (`Program.cs:253-266`) and confirming `StubIdentityProvider` is still registered and still + resolves the same non-null seeded-citizen default it always did when no headers are + present. `dotnet test`'s full pass (255/255, excluding the pre-existing OpenZaak failure) + including `UploadAccessTests` — which exercises exactly this endpoint — confirms it + byte-for-byte. +- **The Production consequence, for the next ticket:** today Production cannot start at + all (this ticket's fail-fast), so the question is moot until a real `IIdentityProvider` + exists. Once one does, this endpoint's plain-navigation callers carry no credential a real + provider could resolve — the identity middleware would treat that as "no identity" and + return 401 before the endpoint ever runs, breaking both preview links outright. Making the + stub Development-only does not itself break anything (nothing in Production exists yet to + break), but it does mean **whoever builds the real provider must also solve this endpoint's + credential-carrying problem in the same change**, or ship it broken. This is not a + signed-URL or cookie scheme, and no such scheme was designed here, per the ticket's explicit + instruction — it is recorded so the next ticket (RB-13, or whichever lands the real + provider) picks it up deliberately rather than discovering it in a production incident. + +## Verification + +- **Reverted the registration change only** (kept `AddSingleton()` unconditional, left the new tests in place) and ran + `ProductionIdentityProviderTests`: it failed red — `Assert.ThrowsAny() Failure: No exception +was thrown` (the stub gets registered in every environment, so the host builds fine and + `factory.CreateClient()` never throws). Restored the fix and re-ran: green. +- `dotnet build`: clean, **0 warnings** (confirms the nullable-covariance judgement call + above compiles cleanly). +- `dotnet format BigRegister.slnx --verify-no-changes`: clean. +- `dotnet test` (full suite): **255 passed, 1 failed** — the pre-existing + `OpenZaakIntegrationTests.Admin_cases_returns_the_seeded_zaak_mapped_through_real_HTTP_and_JWT` + (needs a live OpenZaak container; fails identically on a clean tree). CI's actual filter, + `dotnet test BigRegister.slnx --filter "Category!=Integration"`: **255 passed, 0 failed**. From 4ac13f6cb524e3f356e0d5db86f2ec9d85dd65c8 Mon Sep 17 00:00:00 2001 From: Edwin van den Houdt Date: Thu, 27 Aug 2026 14:11:51 +0200 Subject: [PATCH 3/6] docs: regenerate behaviour spec (RB-07 drift, RB-08, RB-09) `npm run gen:behaviour-spec`'s drift check (part of `npm run ci`) caught two things: RB-07 had already left this generated doc stale (three of its new AuthzAuditTests cases were never picked up), and RB-08/RB-09 added more test names since. Regenerated so the doc matches the suite it claims to mirror. Co-Authored-By: Claude Opus 5 --- libs/shared/docs/behaviour-spec.mdx | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/libs/shared/docs/behaviour-spec.mdx b/libs/shared/docs/behaviour-spec.mdx index 39566bf..67ece99 100644 --- a/libs/shared/docs/behaviour-spec.mdx +++ b/libs/shared/docs/behaviour-spec.mdx @@ -21,7 +21,7 @@ tested where._ Every bullet below is a real test name from the suite — an `it()` title (frontend) or a test method name (backend), read as a sentence. Nothing here is hand-written prose: this page **is** the suite, reshaped for a business reader. 402 frontend behaviours across -8 contexts; 225 backend behaviours across 38 test +8 contexts; 231 backend behaviours across 39 test classes. ## Frontend (by context) @@ -825,6 +825,10 @@ classes. - A denied admin action is recorded - A reveal attempt is recorded +- An allowed admin action is recorded +- An admin upload delete is recorded +- A feature flag toggle records which flag changed +- A refused brief transition is recorded - No audit row carries a subjects bsn - The audit schema carries no pii @@ -1048,6 +1052,10 @@ classes. - Proefbrief is admin only - Proefbrief renders the draft template with a watermark +### ProductionIdentityProviderTests + +- Production environment with no real identity provider fails at startup + ### ProfessionsTests - A mapping is absent before its geldigVan @@ -1083,6 +1091,7 @@ classes. - Empty x medewerker falls through to the zorgverlener default - X rollen parses known tokens and drops unknown ones - X role still applies to a medewerker +- Never returns null even with no headers at all ### SubmissionRuleTests From 1fad7406068d43ca277f3cb1feda773af0920fc1 Mon Sep 17 00:00:00 2001 From: Edwin van den Houdt Date: Thu, 27 Aug 2026 14:21:26 +0200 Subject: [PATCH 4/6] fix(api): regenerate client for RB-08's 403 response shape RB-08 changed DELETE /admin/uploads/{documentId}'s 403 mapping from .Produces to .ProducesProblem, matching CasesAdmin's actual Results.Problem() response - but the generated OpenAPI doc and typed client were never regenerated alongside it, so CI's api-client-drift check (npm run gen:api && git diff --exit-code) was left red. No frontend consumes this admin-only endpoint (confirmed by grep), so this is a pure regeneration with no consumer impact. Co-Authored-By: Claude Opus 5 --- backend/swagger.json | 9 ++++++++- libs/shared/src/infrastructure/api-client.ts | 4 +++- 2 files changed, 11 insertions(+), 2 deletions(-) diff --git a/backend/swagger.json b/backend/swagger.json index af86f93..dbad12d 100644 --- a/backend/swagger.json +++ b/backend/swagger.json @@ -400,7 +400,14 @@ "description": "No Content" }, "403": { - "description": "Forbidden" + "description": "Forbidden", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } }, "404": { "description": "Not Found" diff --git a/libs/shared/src/infrastructure/api-client.ts b/libs/shared/src/infrastructure/api-client.ts index 05eb07b..3d4da57 100644 --- a/libs/shared/src/infrastructure/api-client.ts +++ b/libs/shared/src/infrastructure/api-client.ts @@ -612,7 +612,9 @@ export class ApiClient { }); } else if (status === 403) { return response.text().then((_responseText) => { - return throwException("Forbidden", status, _responseText, _headers); + 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) => { From 436e18421bc43214b605d404aab54528209efccc Mon Sep 17 00:00:00 2001 From: Edwin van den Houdt Date: Thu, 27 Aug 2026 14:21:34 +0200 Subject: [PATCH 5/6] fix(tooling): keep gen:api working under RB-09's Development-only stub RB-09 registers StubIdentityProvider only under IsDevelopment() and throws for Production. `dotnet swagger tofile` (npm run gen:api) loads the same Program.cs through .NET's design-time HostFactoryResolver, which executes the app's startup code (including the unconditional app.Services.GetRequiredService() the identity middleware already relied on) without ever setting ASPNETCORE_ENVIRONMENT - so it now defaults to Production and crashes (dotnet swagger tofile exited 134), breaking `npm run gen:api` entirely, including the "api-client drift" job in .github/workflows/ci.yml (a separate `- run:` step there, so this would fail real CI even though ci-local.sh's `cmd1 && cmd2` step shape happens to swallow a cmd1 failure under `set -e` and reports "passed" - a separate, pre-existing script fragility, not touched here). Real usage is unaffected: dotnet run already sets ASPNETCORE_ENVIRONMENT=Development via launchSettings.json, and docker-compose.yml/docker-compose.prod.yml already set Development/Production explicitly - gen:api's bare CLI invocation was the one place with no environment variable at all. Set it to Development inline, the same value launchSettings.json already uses for the real app. Co-Authored-By: Claude Opus 5 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 41c60b2..3265575 100644 --- a/package.json +++ b/package.json @@ -9,7 +9,7 @@ "format": "prettier --write .", "start": "ng serve ssp", "start:behandelportal": "ng serve behandelportal", - "gen:api": "cd backend && dotnet tool restore && dotnet build src/BigRegister.Api -v q && dotnet swagger tofile --output swagger.json src/BigRegister.Api/bin/Debug/net10.0/BigRegister.Api.dll v1 && cd .. && nswag run nswag.json", + "gen:api": "cd backend && dotnet tool restore && dotnet build src/BigRegister.Api -v q && ASPNETCORE_ENVIRONMENT=Development dotnet swagger tofile --output swagger.json src/BigRegister.Api/bin/Debug/net10.0/BigRegister.Api.dll v1 && cd .. && nswag run nswag.json", "build": "ng build ssp && ng build behandelportal", "watch": "ng build ssp --watch --configuration development", "test": "ng test ssp && ng test behandelportal && ng test shared && ng test beheer", From d089151dbdb7324c6801247e564fd64eb33bed67 Mon Sep 17 00:00:00 2001 From: Edwin van den Houdt Date: Thu, 27 Aug 2026 14:22:26 +0200 Subject: [PATCH 6/6] docs: record the gen:api regression found while verifying RB-09 Documents the dotnet swagger tofile crash discovered by actually running the affected command (not just trusting ci-local.sh's local "passed" line, which turned out to mask this exact failure via a set -e && short-circuit gotcha), its root cause, and the two follow-up fixes. Co-Authored-By: Claude Opus 5 --- .../refactor-backlog/implementation/rb-09.md | 45 +++++++++++++++++++ 1 file changed, 45 insertions(+) diff --git a/docs/project/refactor-backlog-setup/refactor-backlog/implementation/rb-09.md b/docs/project/refactor-backlog-setup/refactor-backlog/implementation/rb-09.md index 8e7fd85..d62af76 100644 --- a/docs/project/refactor-backlog-setup/refactor-backlog/implementation/rb-09.md +++ b/docs/project/refactor-backlog-setup/refactor-backlog/implementation/rb-09.md @@ -121,3 +121,48 @@ was thrown` (the stub gets registered in every environment, so the host builds f `OpenZaakIntegrationTests.Admin_cases_returns_the_seeded_zaak_mapped_through_real_HTTP_and_JWT` (needs a live OpenZaak container; fails identically on a clean tree). CI's actual filter, `dotnet test BigRegister.slnx --filter "Category!=Integration"`: **255 passed, 0 failed**. + +## A regression found by actually running `npm run ci`, and its fix + +`npm run gen:api` (`dotnet swagger tofile`) loads `BigRegister.Api.dll` through .NET's +design-time `HostFactoryResolver` — the same mechanism `dotnet ef` migrations use — which +executes this file's top-level statements, including the identity middleware's +pre-existing, unconditional `app.Services.GetRequiredService()`, without +ever setting `ASPNETCORE_ENVIRONMENT`. Unset defaults to `Production`. Before this ticket +that was harmless (`StubIdentityProvider` was registered unconditionally); after it, nothing +is registered for that default environment, so the tool crashed +(`dotnet swagger tofile` exited **134**, confirmed by running it directly, both before and +after the fix below). + +This is real breakage of a real workflow, not a false alarm from `ci-local.sh` — verified by +reading `.github/workflows/ci.yml`'s `api-client-drift` job: `npm run gen:api` and +`git diff --exit-code ...` are **two separate `- run:` steps** there, so the crash would fail +actual CI. `ci-local.sh` chains them as `npm run gen:api && git diff --exit-code ...` on one +line, and its first full run of `npm run ci` after this ticket's change **printed the crash +but still reported `✔ local CI passed`** — a bash `set -e` gotcha, not a false negative +specific to this fix: a failing command that is not the last element of an `&&`/`||` list is +exempt from triggering `errexit`, so `cmd1 && cmd2` silently "passes" whenever `cmd1` alone +fails. That is a pre-existing fragility in `ci-local.sh`'s three `step "X"; gen && git diff` +lines (snippets/behaviour-spec/api-client drift), unrelated to RB-09 and out of this +ticket's scope — flagged here rather than fixed, since fixing a local convenience script's +error handling is a different, standalone change. Running the failing command directly +(rather than trusting the local script) is what caught this. + +**Fix:** `package.json`'s `gen:api` script now sets `ASPNETCORE_ENVIRONMENT=Development` +on the `dotnet swagger tofile` invocation specifically — the same value +`backend/src/BigRegister.Api/Properties/launchSettings.json` already sets for `dotnet run`, +and the same value `docker-compose.yml` already sets for local Docker (confirmed by reading +both: `docker-compose.prod.yml` sets `Production` explicitly, `docker-compose.yml` sets +`Development` explicitly — the bare CLI tool invocation was the **one** place with no +environment variable set at all). Verified: `npm run gen:api` now exits 0 and produces the +regenerated `backend/swagger.json` / `libs/shared/src/infrastructure/api-client.ts` cleanly. + +This also surfaced a second, unrelated gap: RB-08 changed +`DELETE /admin/uploads/{documentId}`'s 403 mapping from `.Produces` to `.ProducesProblem` +but the generated OpenAPI doc/client were never regenerated for it (RB-08's own `npm run ci` +was run before this fix existed, so `gen:api` was already broken by the time RB-09 landed +and the drift went unnoticed). Regenerated and committed separately — see the two follow-up +commits **fix(api): regenerate client for RB-08's 403 response shape** and +**fix(tooling): keep gen:api working under RB-09's Development-only stub**. No frontend +consumes the admin-uploads-delete endpoint (confirmed by grep), so the client regeneration +has no consumer impact.