PR #28 fixed the cookie collision, the 5-minute session and the missing metrics
listener in one renderer function, and deliberately declined `--api-route`
because the config model could not express the difference between two apps.
This adds the hook it was missing and sets the flag only where a measurement
says to.
THE DEFECT. oauth2-proxy answers an unauthenticated request with a 302 to
Keycloak. Its AJAX guard would send 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 SSO session, and resolves 200 carrying the app's HTML where
the JavaScript expected JSON. 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, on two apps and two different paths -- which is the whole argument:
jackett 2026-09-30: 33 x 302 vs 2 x 401 on /api/ paths, and 4 of 6 POSTs
answered by the proxy and never reaching Jackett (upstream `-`).
radarr 2026-10-01, 23h after PR #28 deployed (pod radarr-7d55cbffb9-42hmh,
0 restarts): 232 unauthenticated requests in the pod's whole life and
ALL 232 are the same call -- POST /signalr/messages/negotiate from a
browser, 302, upstream `-`. A left-open tab whose session the cookie
rename orphaned, polling forever, silently redirected every time and
NEVER re-prompting. The reported symptom, still in production.
So radarr's broken path is /signalr/ and jackett's is /api/, and PIHOLE SERVES
NO /api/ AT ALL. One function renders all five sidecars, so the cheap fix is a
constant -- and a constant applies one app's measurement to four apps nobody
measured. `auth` was `additionalProperties: false`, so there was no per-app
hook. Adding one named, documented key is the change; the block stays closed.
schemas/servicedefinition.schema.json 1.8.0 -> 1.9.0: optional
`auth.apiRoutes`, a list (radarr needs two, and --api-route is natively
repeatable) of anchored path regexes. Plus the shape invariant that a
non-empty list requires mode: forward-auth, since no sidecar is rendered
for oidc/native/none and declaring it there would be a SILENT NO-OP.
catalog/render.py emits one --api-route per entry, in order, and RAISES on
an unanchored entry. The flag is SINGULAR -- verified against the pinned
v7.8.1 binary's own --help; --api-routes does not exist and the proxy
refuses to start with it.
catalog/{jackett,radarr}.yaml the two measured apps.
catalog/{pihole,sonarr,qbittorrent}.yaml comments recording that they get
NOTHING, and why. Sonarr and qBittorrent were measured (0 unauthenticated
requests in 23h) and left alone: that is evidence of no TRAFFIC, not of no
need, and sonarr being nearly radarr's codebase is what makes the guess
tempting and still a guess.
apps/wave-0/jackett.yaml, apps/wave-1/radarr.yaml regenerated. Only TWO
goldens change; pihole/qbittorrent/sonarr are byte-identical to main,
which is the per-app property proving itself on the real tree.
BREADTH IS THE DANGEROUS DIRECTION AND IT IS NOT SYMMETRIC. A missing
--api-route is a silent 302. A too-broad one answers a plain PAGE LOAD with
401, so a logged-out operator gets a bare error page and is never offered a
sign-in -- the service looks permanently broken. Each entry must therefore
match `^\^/[A-Za-z0-9._~/-]+(\.\*|\$)?$`: anchored at ^/ then literal path
characters. `^/`, `^/.*`, `.*`, `/api/` and `^/api/|^/` are all rejected, all
five provoked. Alternation is refused rather than supported because the flag is
repeatable, so one entry per route costs nothing.
NEW GATE CHECK: conformance/tests/l0-static/oauth2-proxy-api-routes-per-app
(whole-repo scope, so ci/l0.sh's --include-scope union runs it on every
invocation; no bespoke stage, which whole-repo-invariants-wired would fail).
It is a SIBLING of oauth2-proxy-cookie-names-distinct, not an extension of it:
that check's subject is cookie IDENTITY and shares neither inputs nor failure
mode with this, so bundling them would make its name a lie and let a future
api-route decision be expressed only by weakening a COOKIE check.
The check exists for one reason the other gates cannot cover: render-golden
compares the render to the goldens, so a RENDERER CONSTANT plus regenerated
goldens AGREE WITH EACH OTHER while both being wrong. Provoked: the regen exits
0 -- render-golden is happy -- and this check exits 1 with 15 failures naming
pihole, qbittorrent, radarr and sonarr individually. It also asserts the
anchoring pattern is the SAME in the schema and the renderer, because three
surfaces guard this and if they drift the loosest wins silently.
Tested in both directions on throwaway trees outside the repo: compliant tree
rc 0 / 43 assertions / 6 sidecars; pre-change tree rc 1 / 11 failures; and four
targeted mutations (hand-edited golden, widened regex, renderer constant,
deleted schema pattern) each firing their own assertion class and nothing else.
ALSO FOLDED IN (coordinator's request; defect is in the file I own). _selftest()
reached _cookie_name() through a bare `next(genexpr)` with NO default, so
deleting the --cookie-name emission -- the exact mutation that assertion exists
to catch -- raised StopIteration, which escaped _selftest() BEFORE its collected
`SELFTEST FAIL:` lines were printed. Right verdict, zero diagnostics, and it
masked every other assertion the same mutation broke (found by Desmond Oyelaran
while wiring the selftest into the render-golden stage; his PR #33 is not in this
base -- origin/main @ cdf3e14 drives the selftest from no stage, checked). Fixed
with `next(..., None)` plus an explicit check(), so a missing flag reports as a
NAMED assertion. Provoking it exposed three more instances of the same class in
the same block -- `side = fa_containers[1]`, `side["env"][0]`, and the fa_svc
lookup all raise rather than assert when the sidecar is absent -- so all four are
now None-tolerant, and the two fixtures added for --api-route were written that
way from the start. Measured on throwaway copies:
--cookie-name deleted: before rc 1 / 0 FAIL lines / StopIteration
after rc 1 / 3 FAIL lines / no traceback
sidecar not appended: before rc 1 / 0 FAIL lines / IndexError
after rc 1 / 20 FAIL lines / no traceback
Goldens are unchanged by this part; it touches _selftest() only.
NOT verified, in full in the handoff entry: no browser round-trip by anyone for
this flag -- that a 401 REPAIRS the operator's experience is reasoned from HTTP
semantics, not watched. `^/api/` on radarr is PR #30's measurement of radarr's
pre-fix pod, not mine; the current pod has zero /api/ traffic. Sonarr and
qBittorrent are UNMEASURED, not cleared. --cookie-refresh is still unexercised
(0 AuthSuccess on all five pods). Nothing was applied; the live pods are still
the pre-change spec.
Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
135 lines
9.5 KiB
YAML
135 lines
9.5 KiB
YAML
# =============================================================================
|
|
# 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
|