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<Program> 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 <noreply@anthropic.com>
This commit is contained in:
eho
2026-08-27 14:06:49 +02:00
co-authored by Claude Opus 5
parent 494cee9d08
commit 8b8b522052
5 changed files with 191 additions and 5 deletions
+26 -3
View File
@@ -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<IIdentityProvider, StubIdentityProvider>();
//
// 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<IIdentityProvider, StubIdentityProvider>();
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<IIdentityProvider>();
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);
});