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:
@@ -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);
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user