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
@@ -4,9 +4,14 @@ namespace BigRegister.Domain.Authorization;
/// Resolves the acting <see cref="CallerIdentity"/> 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). <see cref="StubIdentityProvider"/> is
/// the only implementation today.
/// the only implementation today, and is registered only in Development (<c>Program.cs</c>,
/// RB-09/BIO-002).
/// </summary>
public interface IIdentityProvider
{
CallerIdentity Resolve(HttpContext ctx);
/// <summary>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 (<c>Program.cs</c>)
/// turns a null into a 401 instead of a silent identity substitution.</summary>
CallerIdentity? Resolve(HttpContext ctx);
}
@@ -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 <see cref="CallerIdentity"/> carries over unchanged once that
/// swap happens.
///
/// Registered only in Development (<c>Program.cs</c>, 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
/// <see cref="IIdentityProvider.Resolve"/>, this stub never has "no identity" to report.
/// </summary>
public sealed class StubIdentityProvider : IIdentityProvider
{
+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);
});
@@ -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<Program>()
.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<Exception>(() => factory.CreateClient());
}
}