Portaal-authenticatie — waarom de identity-header te vertrouwen is
Het portaal op iso.commonground.nu heeft geen eigen login. Er staat
oauth2-proxy voor, die de auditor
tegen Keycloak authenticeert (realm commonground,
https://iam.commonground.nu). Keycloak brokert op zijn beurt Google als identity
provider, zodat auditors met hun Google-account inloggen terwijl de app maar met
één identity-provider integreert — één integratie om te auditen, niet twee.
browser ──▶ oauth2-proxy ──OIDC──▶ Keycloak ──brokert──▶ Google
│ (zet X-Forwarded-Email / X-Forwarded-User)
▼
FastAPI-app (127.0.0.1:8081) ── faalt gesloten zonder die header
Dit patroon is 1-op-1 overgenomen van openwoo-app-provisioner
(openwoo-app-config/webgui/auth/README.md). Bewust: er is één trust-model in de
organisatie, niet één per applicatie.
Het trust-model
De app vertrouwt X-Forwarded-Email niet omdat de header er staat, maar omdat
twee dingen buiten de applicatiecode het afdwingen.
1. Topologie. De app bindt 127.0.0.1. oauth2-proxy is de enige
netwerk-listener in de pod, en een NetworkPolicy laat pod-ingress alleen toe uit de
ingress-nginx-namespace op de proxy-poort. Er is dus geen pad waarlangs iets
anders de header kan zetten.
2. Fail closed. REQUIRE_AUTH staat default aan. Een request zonder
identity-header krijgt 403, met als enige uitzondering het probe-endpoint
/healthz. Een verkeerd geconfigureerde ingress degradeert daarmee naar "op slot",
niet naar "open".
Die twee samen zijn het anker. Eén van de twee is niet genoeg: zonder de localhost-bind is de header spoofbaar en is de fail-closed-check schijnzekerheid; zonder de fail-closed-stand is een ontbrekende proxy een open portaal.
Middleware, geen dependency per route
De gate zit in iso_audit.api.auth_gate als HTTP-middleware, niet als een
Depends() op elke route. Reden: een dependency moet bij élk nieuw endpoint
opnieuw worden aangezet, en vergeten betekent stil een open route. Middleware dekt
alles wat er is en alles wat er nog bij komt; een nieuw endpoint is beschermd
tenzij iemand het expliciet in OPEN_PADEN zet. De veilige default is de
default.
Een onbekende waarde in REQUIRE_AUTH (een typfout als maybe) betekent
aan. Alleen expliciete uit-waarden zetten de gate uit. Een typfout mag geen
portaal openzetten.
De identiteit landt in de audit-trail
De gate zet de vastgestelde identiteit op request.state.identiteit, en
POST /findings/{id} geeft die door als actor aan AuditSession.apply_triage().
Daarmee is elke regel in triage_log.jsonl toewijsbaar aan een mens.
Dat was eerder niet zo: het actor-veld bestond al en werd al weggeschreven, maar
de API gaf het niet mee, waardoor élke trail-regel de default "auditor" bevatte.
Een append-only trail zonder toewijsbare actor beantwoordt de eerste vraag van een
auditor niet — wie heeft deze bevinding gevalideerd of verworpen.
Draait de app met REQUIRE_AUTH=false, dan is de actor de onmiskenbare waarde
dev:auth-uitgeschakeld. Bewust greppbaar: als dat ooit in een echte trail
opduikt, is het direct herkenbaar als een dev-run in plaats van te worden
aangezien voor een mens.
Wat dit model niet oplost
De sessie-cookie draagt de identiteit zelf (session_cookie_minimal), zodat de
cookie klein blijft. Gevolg: een Keycloak-account uitzetten maakt een al uitgegeven
cookie niet ongeldig. Toegang eindigt dan pas bij cookie-verval. Bij
offboarding is het uitzetten van het account dus niet genoeg — zie
verify-portal-auth voor de handeling die
toegang direct beëindigt, en de change iso-portal voor de vastgelegde maximale
restduur.
Verwant
- Fail-closed verifiëren zonder cluster
openspec/changes/iso-portal/specs/portal-auth/spec.md— de eisenopenspec/changes/iso-portal/design.md— waarom dit patroon en niet een eigen auth-laag