# ============================================================================= # L0 STATIC — `--api-route` is PER APP, ANCHORED, and DECLARED # Catalog: docs/14 L0 row ("Pre-apply, in CI") — runs WITHOUT a cluster. # # THE DEFECT THIS CLOSES. oauth2-proxy answers an unauthenticated request with a 302 to # Keycloak. Its built-in AJAX guard would send a 401 instead, but it fires only on an EXACT # `Accept: application/json`, which these apps' JavaScript does not consistently send. So a # browser XHR gets a 302, FOLLOWS IT INVISIBLY, has it satisfied silently by the live Keycloak # SSO session, and resolves 200 carrying the app's HTML where the JavaScript expected JSON — no # prompt, no console error, nothing logged as a failure. On a WRITE it is worse: a browser # following a 302 re-issues a POST as a GET and DROPS THE BODY, so the operator's change is # discarded and the UI reports success. # # MEASURED, twice, on two different apps and two different paths: # jackett 2026-09-30 — on /api/ paths: 33 x 302 against 2 x 401, and 4 of 6 POSTs answered BY # THE PROXY, never reaching Jackett (access-log upstream field `-`, not `/`). That is # the operator's whole report: "GET works, most PUT/POST to add indexers fail, # reloading never fixes it, and I am never re-prompted for login". # radarr 2026-10-01, 23h after the cookie fix rolled (pod radarr-7d55cbffb9-42hmh, 0 # restarts) — 232 unauthenticated requests in the pod's whole lifetime and ALL 232 are # the same call: `POST /signalr/messages/negotiate` from a browser UA, 302, upstream # `-`. A left-open tab whose session the cookie rename orphaned, polling SignalR # forever, silently redirected every time, NEVER re-prompting. The reported symptom, # still running in production, on a service nobody was watching. # # WHY IT CANNOT BE A RENDERER CONSTANT — AND WHY THAT NEEDS A GATE. The path shape belongs to # the APP, not to the forward-auth pattern: radarr's broken path is `/signalr/`, jackett's is # `/api/`, and PIHOLE SERVES NO `/api/` AT ALL. One function renders all five sidecars # (catalog/render.py::_oauth2_sidecar_container), so the cheap fix is a constant — and a # constant applies one app's measurement to four apps nobody measured. The gate that already # exists cannot see that mistake: `ci/render-golden.sh` compares the render to the goldens, and # a constant plus regenerated goldens AGREE WITH EACH OTHER while both being wrong. Hence # assertion (2) below, which compares the rendered flag against each service's OWN # ServiceDefinition rather than against the renderer's output. # # WHY BREADTH IS THE DANGEROUS DIRECTION, AND IT IS NOT SYMMETRIC. A MISSING --api-route is a # silent 302 — bad, invisible, recoverable. A TOO BROAD one answers a plain PAGE LOAD with 401: # a logged-out operator gets a bare error page and is NEVER OFFERED A SIGN-IN, so the service # looks permanently broken and no reload recovers it. That is a worse outcome than the defect # the flag fixes, which is why the anchoring rule is asserted on three independent surfaces # (schema items.pattern, the renderer, and this check) and why assertion (5) fails if they drift # apart — if they do, the loosest one wins silently. # # GIVEN every oauth2-proxy sidecar this repo DEPLOYS (a container in any manifest at any depth: # the rendered sidecars in apps/, crafty's hand-authored one in platform/), the # ServiceDefinitions in catalog/ that generate five of them, and the renderer itself, # WHEN each sidecar's `--api-route` values are collected (the flag is REPEATABLE, so all of # them, in order) and each onboarded forward-auth service is paired with its golden, # THEN (1) every rendered --api-route is anchored at `^/` and continues with literal path # characters — `^/`, `^/.*`, `.*`, an unanchored `/api/` and any alternation FAIL; # (2) every onboarded forward-auth service's rendered route list EQUALS its own # ServiceDefinition's `auth.apiRoutes`, in order — which fails both a hand-edited # golden and a renderer constant, neither of which render-golden can see; # (3) BOTH cases are live in this tree: at least one service declares routes and at least # one declares none and renders none (the pihole case), so neither path is an # unexercised seam; # (4) `_oauth2_sidecar_container()` reads `auth.apiRoutes` and contains NO literal path # regex — a constant is the defect, back in the one place that reintroduces it # fleet-wide in a single edit; # (5) the anchoring pattern in the renderer and in the schema are the SAME pattern this # check enforces; # (6) the SHIPPED renderer, run as a subprocess on synthetic ServiceDefinitions, emits # exactly the declared routes in order, emits NOTHING for a service declaring none or # an empty list, and REFUSES all five breadth shapes. # # WHY ALSO A SCHEMA PATTERN, GIVEN THIS CHECK. The schema is the only surface that rejects a # too-broad regex WHERE A HUMAN TYPES IT — `ci/l0.sh` validates `catalog/*.yaml` against it on # every gate run, so the operator is told at the authoring surface rather than finding out from # a 401 on a page load. The renderer's own guard is there because `render-golden` runs even on a # gate invocation where the schema stage skipped. This check is there because neither of those # can see a renderer constant. Three surfaces, three different blind spots. # # WHY THIS IS A SIBLING AND NOT PART OF oauth2-proxy-cookie-names-distinct. That check's subject # is cookie IDENTITY: its inputs are names and domains, its failure mode is mutual session # eviction, its register is the shared proxy's claim on the default name. `--api-route` shares # none of those. Bundling them would make that check's name a lie and couple two unrelated # ratchets, so a future api-route decision could only be expressed by weakening a COOKIE check — # the same discipline that check's own header argues for, applied to this change. # # WHAT THIS DOES NOT ESTABLISH — read with the handoff entry, not instead of it: # - That a 401 FIXES anything in a browser. No browser round-trip has been made, by anyone, # for this flag. The 302/401 split, the dropped POST bodies and the orphaned SignalR poll # are all read from access logs; the repair is reasoned from HTTP semantics. # - That the regexes are RIGHT for these two apps beyond the measured paths. `^/api/` on # radarr is justified by a measurement of radarr taken on its PRE-FIX pod by another # engineer (decisions/2026-09-30-jackett-oauth2-proxy-session.md: 62 x /api/v3/command, # 46 x /api/v3/queue/details); the current pod has logged zero /api/ traffic. # - Anything about sonarr, qbittorrent or pihole. Their logs were read (2026-10-01) and # contain ZERO unauthenticated requests — no traffic, which is not the same as no need. # Assertion (3) therefore proves the "declares none" PATH works; it does not claim those # three apps are correctly configured. # - That the LIVE pods carry what the tree says. git is not the cluster (CLAUDE.md §2b). # - It does not read ../home-network-k3s. The spec is read-only (R-TEST-06). # ============================================================================= apiVersion: chainsaw.kyverno.io/v1alpha1 kind: Test metadata: name: oauth2-proxy-api-routes-per-app labels: conformance.home.k3s/R-IDP-02: "true" annotations: conformance.home.k3s/requirements: "R-IDP-02" conformance.home.k3s/layer: "L0" # WHOLE-REPO SCOPE — the subject spans apps/wave-0, apps/wave-1, platform/crafty, every # ServiceDefinition in catalog/, the renderer that generates five of the sidecars, and the # ServiceDefinition schema. No single requirement path-maps to that set, and the renderer # maps to no identity requirement at all, so a requirement-scoped selection would skip this # check on precisely the change that can reintroduce the defect fleet-wide in one edit. conformance.home.k3s/scope: "whole-repo" conformance.home.k3s/safety-critical: "false" conformance.home.k3s/executable: "true" conformance.home.k3s/intent: >- An unauthenticated XHR is answered 401, not 302, on exactly the paths each app was MEASURED to serve as a machine API: every rendered --api-route is anchored at ^/ and continues with literal path characters, every onboarded forward-auth service's rendered routes equal its own auth.apiRoutes in order (so neither a hand-edited golden nor a renderer constant can pass), an app declaring none renders none, the anchoring pattern is the same in the schema and the renderer, and the shipped renderer is proven on synthetic ServiceDefinitions in both directions — including refusing the five breadth shapes that would answer a page load with 401. spec: steps: - name: assert-api-route-is-per-app-anchored-and-declared description: >- Inventory every oauth2-proxy sidecar's repeatable --api-route values, assert each is an anchored path regex, cross-check each onboarded forward-auth service's rendered routes against its own ServiceDefinition, require both the declares-routes and the declares-none cases to be live in the tree, assert the renderer derives from auth.apiRoutes and hardcodes no path regex, assert the schema and renderer share this check's anchoring pattern, and prove the shipped renderer on synthetic ServiceDefinitions in both directions. try: - script: content: bash ./check.sh