Two backlog trees are complete: `docs/project/backlog/` (75 files, every WP done) and `docs/project/refactor-backlog-setup/` (the arc before it). Move both under `docs/project/archive/` with `git mv`, so history stays intact through `git log --follow`. `SHOWCASE-ROADMAP.md` moves with them, because it points at the now-archived backlog README. Add `docs/project/archive/README.md`. It states that these trees are historical and names the two directories that are still live. Repoint every inbound reference named in RD-30's Files table: CLAUDE.md, the root README, both backend READMEs, `LetterHtml.cs`, `a11y.mdx`, the `document-feature` and `new-ssp` skills, and the readable-codebase PLAN, README, and RD-19 ticket. Fix two upward-relative links inside the moved WP files (WP-68, WP-69) that gained a directory level and would otherwise break. Repoint `.prettierignore`'s two agent-prompt exclusions to their new path, so prettier keeps leaving those files' exact wording alone. Mark RD-30 done and check off its acceptance criteria; flip its README row to done. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
14 KiB
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.CanBeoordeleniscaller is MedewerkerCaller, so a zorgverlener caller is alwaysfalseregardless 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|resetall resolvectx.Zorgverlener().Bsnto the seeded citizen's BSN. An employee with no employee identity was granted a citizen's own read/write rights. - Holds
CanRevealBigNummer— that capability isRole == PrincipalRole.Drafter, anddrafteris 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 staysCallerIdentity, notCallerIdentity?. 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 cleandotnet build(0 warnings). This kept every existingStubIdentityProviderTestscall 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 hypotheticalStaging) falls through neither branch, registers noIIdentityProviderat all, and would still fail — one line later, whenapp.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 (whereGetRequiredServicealready 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. TestWebApplicationFactoryneeded no change.WebApplicationFactory<T>defaults its test host to theDevelopmentenvironment 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 ownWebApplicationFactory<Program>().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 (<a href> 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 confirmingStubIdentityProvideris 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) includingUploadAccessTests— 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
IIdentityProviderexists. 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<IIdentityProvider, StubIdentityProvider>()unconditional, left the new tests in place) and ranProductionIdentityProviderTests: it failed red —Assert.ThrowsAny() Failure: No exception was thrown(the stub gets registered in every environment, so the host builds fine andfactory.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-existingOpenZaakIntegrationTests.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<IIdentityProvider>(), 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.