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]>
434 lines
23 KiB
Bash
Executable File
434 lines
23 KiB
Bash
Executable File
#!/usr/bin/env bash
|
|
# =============================================================================
|
|
# L0 STATIC CHECK — `--api-route` is PER APP, ANCHORED, and DECLARED.
|
|
# See chainsaw-test.yaml's header for WHY this exists and what it measured. In one paragraph:
|
|
#
|
|
# oauth2-proxy answers an unauthenticated request with a 302 to Keycloak. A browser XHR follows
|
|
# that redirect invisibly, the live SSO session satisfies it silently, and the call resolves 200
|
|
# carrying the app's HTML where its JavaScript expected JSON — and a POST following a 302 is
|
|
# re-issued as a GET with its BODY DROPPED. `--api-route=<regex>` makes matching paths answer
|
|
# 401 instead. The flag is right for jackett (`/api/`) and radarr (`/signalr/`, `/api/`) and
|
|
# WRONG for pihole, which serves no `/api/` at all — so it cannot be a renderer constant, and
|
|
# the point of this check is that it never becomes one again.
|
|
#
|
|
# The breadth direction is the dangerous one and it is NOT symmetric with the bug. 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. That is a worse outcome than the defect, which is why the anchoring rule
|
|
# is asserted here as well as in the schema.
|
|
#
|
|
# Exit 0 = all assertions pass. Exit 1 = a failure. Exit 3 (honest SKIP, which the runner turns
|
|
# into rc 3 = UNWIRED, never green, because this test declares the whole-repo scope) only if the
|
|
# tree contains no forward-auth oauth2-proxy sidecar at all — genuinely nothing to audit.
|
|
# =============================================================================
|
|
set -euo pipefail
|
|
DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
|
ROOT="$(cd "${DIR}/../../../.." && pwd)" # repo root
|
|
PYTHON="${PYTHON:-python3}"
|
|
|
|
# Honest SKIP (never green for a scope-declaring test) if the forward-auth pattern has left the
|
|
# repo. Anchored on `image:` for the same reason as the sibling cookie check: a bare
|
|
# `oauth2-proxy/oauth2-proxy:` also matches platform/oauth2-proxy/forward-auth-cec.yaml's Envoy
|
|
# cluster reference (`serviceName: oauth2-proxy/oauth2-proxy:80`), which runs no proxy.
|
|
if ! grep -rlE 'image:[[:space:]]*[^[:space:]]*oauth2-proxy' \
|
|
"${ROOT}/apps" "${ROOT}/platform" >/dev/null 2>&1; then
|
|
echo "check SKIPPED: no oauth2-proxy container image found under apps/ or platform/ — the forward-auth sidecar pattern is gone and there is nothing to audit" >&2
|
|
exit 3
|
|
fi
|
|
|
|
cd "${ROOT}"
|
|
"${PYTHON}" - "${ROOT}" <<'PY'
|
|
"""Static audit: --api-route is per-app, anchored, and declared in the ServiceDefinition.
|
|
|
|
PyYAML (the same dependency as catalog/render.py and the sibling l0-static checks) rather than
|
|
line-based parsing: a container's args live in a nested structure, and a line-based reader of
|
|
one is precisely the approximate parser that silently stops matching.
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
import re
|
|
import subprocess
|
|
import sys
|
|
import tempfile
|
|
from pathlib import Path
|
|
|
|
try:
|
|
import yaml
|
|
except ImportError: # pragma: no cover
|
|
print("check FAIL: PyYAML is required (same dependency as catalog/render.py)",
|
|
file=sys.stderr)
|
|
raise SystemExit(1)
|
|
|
|
ROOT = Path(sys.argv[1])
|
|
sys.dont_write_bytecode = True # keep __pycache__ out of the tracked tree
|
|
|
|
FAILURES: list[str] = []
|
|
CHECKS = 0
|
|
|
|
|
|
def check(cond, msg):
|
|
global CHECKS
|
|
CHECKS += 1
|
|
if not cond:
|
|
FAILURES.append(msg)
|
|
|
|
|
|
# THE BREADTH RATCHET, stated once and asserted against three independent surfaces below (the
|
|
# rendered tree, the authored catalog, the shipped renderer). Each entry must be anchored at
|
|
# `^/` and continue with LITERAL path characters. This rejects:
|
|
# `^/` and `^/.*` — match every page load, so the UI answers 401 and never signs in
|
|
# `/api/` — unanchored; oauth2-proxy would match it mid-path
|
|
# `.*` — everything
|
|
# `^/api/|^/` — alternation smuggles breadth past a prefix-only reading
|
|
# Alternation is REFUSED rather than supported because `--api-route` is repeatable: one entry
|
|
# per route costs nothing and each entry stays readable. Kept byte-identical to
|
|
# catalog/render.py's OAUTH2_API_ROUTE_PATTERN and to the schema's items.pattern; assertion (5)
|
|
# below fails if they drift apart.
|
|
ANCHORED = re.compile(r"^\^/[A-Za-z0-9._~/-]+(\.\*|\$)?$")
|
|
SCHEMA_PATTERN = r"^\^/[A-Za-z0-9._~/-]+(\.\*|\$)?$"
|
|
|
|
|
|
def read(p: Path) -> str:
|
|
try:
|
|
return p.read_text()
|
|
except (OSError, UnicodeDecodeError):
|
|
return ""
|
|
|
|
|
|
def is_test_fixture(p: Path) -> bool:
|
|
"""conformance/tests/**/{golden,fixtures}/** is assertion DATA, not deployed manifests."""
|
|
parts = p.parts
|
|
return "conformance" in parts and ("golden" in parts or "fixtures" in parts)
|
|
|
|
|
|
def iter_containers(node):
|
|
"""Yield every container mapping under any `containers`/`initContainers` list, at any depth.
|
|
|
|
Depth-agnostic on purpose: the same container shape appears under a Deployment, a DaemonSet,
|
|
a CronJob's jobTemplate or a bare Pod, and a reader that hardcoded one path would silently
|
|
stop seeing an instance the day one moved.
|
|
"""
|
|
if isinstance(node, dict):
|
|
for key, val in node.items():
|
|
if key in ("containers", "initContainers") and isinstance(val, list):
|
|
for c in val:
|
|
if isinstance(c, dict):
|
|
yield c
|
|
else:
|
|
yield from iter_containers(val)
|
|
elif isinstance(node, list):
|
|
for item in node:
|
|
yield from iter_containers(item)
|
|
|
|
|
|
def api_routes_of(args: list) -> list:
|
|
"""Every `--api-route=<v>` / `--api-route <v>` value, in order. The flag is REPEATABLE, so
|
|
this must collect ALL of them — a single-value reader would audit the first and wave the
|
|
rest through, which is the shape of reader bug this file's header warns about."""
|
|
out = []
|
|
for i, a in enumerate(args):
|
|
if not isinstance(a, str):
|
|
continue
|
|
if a.startswith("--api-route="):
|
|
out.append(a.split("=", 1)[1])
|
|
elif a == "--api-route" and i + 1 < len(args) and isinstance(args[i + 1], str):
|
|
out.append(args[i + 1])
|
|
return out
|
|
|
|
|
|
# ============================================================ (0) INVENTORY: THE RENDERED TREE
|
|
# Every oauth2-proxy container this repo DEPLOYS, keyed by file + owner, with its api-routes.
|
|
rendered: dict[str, list] = {}
|
|
for p in sorted(ROOT.rglob("*.yaml")):
|
|
relparts = p.relative_to(ROOT).parent.parts
|
|
# NESTED WORKTREES: skip dot-DIRECTORIES (.git, .claude) while still matching dot-FILES such
|
|
# as .sops.yaml. .claude/worktrees/agent-*/ holds full repo copies for isolated agent runs,
|
|
# so a bare rglob audits up to 18 duplicates of every file — which both invents FAILs and,
|
|
# worse, can satisfy a "something declares X" check from a copy rather than from real source.
|
|
if any(part.startswith(".") for part in relparts) or is_test_fixture(p) or ".sops." in p.name:
|
|
continue
|
|
txt = read(p)
|
|
if "oauth2-proxy" not in txt:
|
|
continue
|
|
rel = str(p.relative_to(ROOT))
|
|
try:
|
|
docs = [d for d in yaml.safe_load_all(txt) if isinstance(d, dict)]
|
|
except yaml.YAMLError as exc:
|
|
check(False, f"{rel} is not parseable YAML ({exc}) — any oauth2-proxy instance in it "
|
|
f"went unaudited, so a too-broad --api-route could be hiding there")
|
|
continue
|
|
for d in docs:
|
|
for c in iter_containers(d):
|
|
image = str(c.get("image") or "")
|
|
name = str(c.get("name") or "")
|
|
if "oauth2-proxy" not in image and name != "oauth2-proxy":
|
|
continue
|
|
owner = (d.get("metadata") or {}).get("name") or "?"
|
|
args = list(c.get("args") or []) + list(c.get("command") or [])
|
|
rendered[f"{rel}:{owner}/{name or 'oauth2-proxy'}"] = api_routes_of(args)
|
|
|
|
check(len(rendered) >= 5,
|
|
f"only {len(rendered)} oauth2-proxy container(s) parsed out of the tree. This repo "
|
|
f"deploys more than that (five rendered sidecars plus crafty's hand-authored one), so the "
|
|
f"inventory has stopped seeing instances and every assertion below would pass vacuously "
|
|
f"while an unanchored --api-route went unaudited")
|
|
|
|
# ============================================================ (1) EVERY ROUTE IS ANCHORED
|
|
for key, routes in sorted(rendered.items()):
|
|
for r in routes:
|
|
check(bool(ANCHORED.match(r)),
|
|
f"{key} declares --api-route={r!r}, which is not an anchored path regex.\n"
|
|
f" WHY THIS IS WORSE THAN THE BUG IT FIXES: --api-route makes matching paths "
|
|
f"answer 401 instead of 302ing to Keycloak. A regex that also matches a path a "
|
|
f"BROWSER NAVIGATES to — `^/`, `^/.*`, `.*`, or an alternation that reaches the "
|
|
f"UI root — answers a plain PAGE LOAD with 401. A logged-out operator then sees a "
|
|
f"bare error page and is NEVER OFFERED A SIGN-IN: the service looks permanently "
|
|
f"broken rather than merely unauthenticated, and no reload can recover it.\n"
|
|
f" FIX: anchor it at '^/' followed by literal path characters "
|
|
f"(e.g. '^/api/', '^/signalr/', '^/api/v3/.*'), one entry per route — the flag is "
|
|
f"repeatable, so alternation is never needed. For a service rendered from catalog/, "
|
|
f"do NOT hand-edit the golden: declare auth.apiRoutes and re-render "
|
|
f"(ci/render-golden.sh's header has the canonical command)")
|
|
|
|
# ============================================================ (2) DERIVED FROM THE CATALOG
|
|
# The five rendered sidecars are OUTPUTS. Auditing only them would let someone "fix" a golden by
|
|
# hand (reverted by the next render) or make the renderer emit a CONSTANT and regenerate every
|
|
# golden to match — which render-golden cannot see, because render and golden would agree.
|
|
# So: every onboarded forward-auth service's rendered route list must EQUAL its own
|
|
# ServiceDefinition's auth.apiRoutes, in order.
|
|
catalog_dir = ROOT / "catalog"
|
|
declared: dict[str, list] = {}
|
|
forward_auth: list[str] = []
|
|
for sd_path in sorted(catalog_dir.glob("*.yaml")):
|
|
try:
|
|
sd = yaml.safe_load(read(sd_path))
|
|
except yaml.YAMLError as exc:
|
|
check(False, f"catalog/{sd_path.name} is not parseable YAML ({exc})")
|
|
continue
|
|
if not isinstance(sd, dict) or not sd.get("name"):
|
|
continue
|
|
auth = sd.get("auth") or {}
|
|
routes = auth.get("apiRoutes") or []
|
|
declared[sd["name"]] = list(routes)
|
|
if auth.get("mode") == "forward-auth" and (sd.get("ingressRoutes") or []):
|
|
forward_auth.append(sd["name"])
|
|
for r in routes:
|
|
check(bool(ANCHORED.match(str(r))),
|
|
f"catalog/{sd_path.name}: auth.apiRoutes entry {r!r} is not anchored at '^/' "
|
|
f"followed by literal path characters. See assertion (1) for why breadth here is "
|
|
f"the dangerous direction. The schema rejects this too (items.pattern); this "
|
|
f"assertion exists because the schema stage can be SKIPPED and this one cannot")
|
|
if routes:
|
|
check(auth.get("mode") == "forward-auth",
|
|
f"catalog/{sd_path.name} declares auth.apiRoutes with auth.mode="
|
|
f"{auth.get('mode')!r}. --api-route renders onto the per-app oauth2-proxy sidecar, "
|
|
f"and no sidecar is rendered for oidc/native/none — so this is a SILENT NO-OP: the "
|
|
f"operator believes the app's XHRs now answer 401 and nothing changed")
|
|
|
|
matched = 0
|
|
for name in sorted(forward_auth):
|
|
golden = next((p for p in sorted(ROOT.glob(f"apps/wave-*/{name}.yaml"))), None)
|
|
if golden is None:
|
|
continue # catalog service not onboarded to apps/ yet — no golden
|
|
key = next((k for k in rendered if k.startswith(str(golden.relative_to(ROOT)) + ":")), None)
|
|
check(key is not None,
|
|
f"apps/{golden.name} is the golden for forward-auth service {name!r} but no "
|
|
f"oauth2-proxy container was parsed out of it. Its --api-route configuration is "
|
|
f"UNAUDITED")
|
|
if key is None:
|
|
continue
|
|
matched += 1
|
|
check(rendered[key] == declared.get(name, []),
|
|
f"{name}: the rendered sidecar declares --api-route {rendered[key]!r} but "
|
|
f"catalog/{name}.yaml declares auth.apiRoutes {declared.get(name, [])!r}.\n"
|
|
f" These must be IDENTICAL and in order. A mismatch means one of two things, "
|
|
f"both bad: a golden was hand-edited (the next re-render silently reverts it), or the "
|
|
f"renderer is emitting --api-route from somewhere other than this service's own "
|
|
f"ServiceDefinition — i.e. a constant or a per-service table. A constant is the defect "
|
|
f"this field exists to prevent: it applies ONE app's path shape to all five, and "
|
|
f"pihole serves no /api/ at all, so it would answer pihole's page loads with 401. "
|
|
f"render-golden cannot catch that, because render and golden would agree with each "
|
|
f"other while both being wrong")
|
|
|
|
check(matched >= 4,
|
|
f"only {matched} onboarded forward-auth service(s) were cross-checked against their "
|
|
f"ServiceDefinition. This repo onboards five, so the catalog<->golden pairing has stopped "
|
|
f"resolving and assertion (2) is passing vacuously")
|
|
|
|
# ============================================================ (3) BOTH CASES EXIST
|
|
# Once the tree is consistent, (2) is satisfied by any pairing — including one where NOTHING
|
|
# declares a route (vacuous) or one where EVERYTHING does (so the "renders nothing" path is
|
|
# never exercised). Both halves are asserted to be live, because the per-app field only earns
|
|
# its complexity if both outcomes actually occur in this tree.
|
|
with_routes = [n for n in forward_auth if declared.get(n)]
|
|
without_routes = [n for n in forward_auth if not declared.get(n)]
|
|
check(bool(with_routes),
|
|
"no onboarded forward-auth service declares auth.apiRoutes, so assertion (2) cannot "
|
|
"distinguish a working renderer from one that emits the flag nowhere. If the flag is "
|
|
"genuinely not needed anywhere, delete the field rather than leaving an unexercised seam")
|
|
check(bool(without_routes),
|
|
"EVERY onboarded forward-auth service declares auth.apiRoutes, so nothing in this tree "
|
|
"exercises the 'declares none => renders no flag' path. That path is the whole reason the "
|
|
"field is per-app: pihole serves no /api/, and a renderer-wide default would answer its "
|
|
"page loads with 401")
|
|
for name in sorted(without_routes):
|
|
golden = next((p for p in sorted(ROOT.glob(f"apps/wave-*/{name}.yaml"))), None)
|
|
if golden is None:
|
|
continue
|
|
key = next((k for k in rendered if k.startswith(str(golden.relative_to(ROOT)) + ":")), None)
|
|
if key is None:
|
|
continue
|
|
check(rendered[key] == [],
|
|
f"{name} declares NO auth.apiRoutes but its rendered sidecar carries "
|
|
f"--api-route {rendered[key]!r}. An app that declares nothing must render NOTHING")
|
|
|
|
# ============================================================ (4) THE RENDERER DERIVES
|
|
render_py = ROOT / "catalog" / "render.py"
|
|
check(render_py.is_file(),
|
|
"catalog/render.py is missing — the generator of five of these sidecars cannot be audited")
|
|
if render_py.is_file():
|
|
src = read(render_py)
|
|
m = re.search(r"def _oauth2_sidecar_container\(.*?\n(?=\n*def |\n*# =)", src, re.S)
|
|
fn = m.group(0) if m else ""
|
|
check(bool(fn),
|
|
"could not locate _oauth2_sidecar_container() in catalog/render.py. It was renamed or "
|
|
"removed, so assertion (4) cannot run and the generator is unaudited")
|
|
if fn:
|
|
check("--api-route=" in fn,
|
|
"catalog/render.py's _oauth2_sidecar_container() emits no --api-route at all, so "
|
|
"every forward-auth app's XHRs are answered with a 302 the browser follows "
|
|
"invisibly — measured as 33x302-vs-2x401 on jackett and 232x302 on radarr, with "
|
|
"POST bodies dropped. auth.apiRoutes would then be a field with no consumer")
|
|
check(not re.search(r'--api-route=\^/[A-Za-z0-9]', fn),
|
|
"catalog/render.py hardcodes a literal path regex into --api-route. Whatever path "
|
|
"that is, EVERY forward-auth app now renders it: one app's path shape applied to "
|
|
"five. pihole serves no /api/, so a constant answers its page loads with 401 and "
|
|
"the service looks permanently broken to a logged-out operator. Render the routes "
|
|
"declared in each service's own auth.apiRoutes instead")
|
|
check("apiRoutes" in fn,
|
|
"catalog/render.py's _oauth2_sidecar_container() does not read auth.apiRoutes, so "
|
|
"the per-app declaration has no effect on what is rendered")
|
|
|
|
# ============================================================ (5) ONE PATTERN, THREE SURFACES
|
|
# The ratchet is written in three places (this check, the renderer, the schema) because each
|
|
# guards a surface the others cannot. If they drift apart, the loosest one wins silently.
|
|
if render_py.is_file():
|
|
# The renderer writes the pattern as a raw string literal, so the source text contains it
|
|
# verbatim. A substring test is deliberate: it also catches a rename of the constant.
|
|
check(SCHEMA_PATTERN in read(render_py),
|
|
f"catalog/render.py no longer contains the anchoring pattern {SCHEMA_PATTERN!r} that "
|
|
f"this check and the schema enforce. Three surfaces guard --api-route breadth (the "
|
|
f"authoring surface via the schema, the render via the renderer, the tree via this "
|
|
f"check); if they drift apart the loosest wins and the ratchet is decorative")
|
|
|
|
# The schema's pattern is read from the PARSED JSON, not from the file text: in the file its
|
|
# backslashes are JSON-escaped, so a substring test against the raw bytes would compare the
|
|
# wrong string and pass or fail for the wrong reason.
|
|
schema_file = ROOT / "schemas" / "servicedefinition.schema.json"
|
|
schema_pat = None
|
|
if schema_file.is_file():
|
|
try:
|
|
schema_pat = (((((json.loads(read(schema_file)).get("properties") or {})
|
|
.get("auth") or {}).get("properties") or {})
|
|
.get("apiRoutes") or {}).get("items") or {}).get("pattern")
|
|
except (ValueError, AttributeError) as exc:
|
|
check(False, f"schemas/servicedefinition.schema.json could not be read as JSON ({exc}) — "
|
|
f"the schema stage that rejects a too-broad --api-route cannot be audited")
|
|
check(schema_pat == SCHEMA_PATTERN,
|
|
f"schemas/servicedefinition.schema.json constrains auth.apiRoutes items with "
|
|
f"{schema_pat!r}, not {SCHEMA_PATTERN!r}. The schema is the only surface that rejects a "
|
|
f"too-broad regex WHERE A HUMAN TYPES IT (ci/l0.sh validates catalog/*.yaml on every gate "
|
|
f"run); if it is absent or looser than the renderer's, the operator finds out from a 401 "
|
|
f"on a page load")
|
|
|
|
# ============================================================ (6) PROVE THE SHIPPED RENDERER
|
|
# Once the tree is compliant, (1)-(3) have nothing left to catch and their passing is evidence
|
|
# of nothing. This runs the SHIPPED renderer as a SUBPROCESS on synthetic ServiceDefinitions and
|
|
# asserts the behaviour directly, in BOTH directions, so the guard stays falsifiable.
|
|
def _sd(name: str, port: int, routes) -> str:
|
|
auth = {"mode": "forward-auth", "groups": ["platform-admins"]}
|
|
if routes is not None:
|
|
auth["apiRoutes"] = routes
|
|
return yaml.safe_dump({
|
|
"name": name,
|
|
"kind": "container",
|
|
"source": {"image": "example/app:1"},
|
|
"exposure": "internal",
|
|
"ingressRoutes": [{"host": f"{name}.home.keithjackson.dev", "port": port}],
|
|
"auth": auth,
|
|
}, sort_keys=False)
|
|
|
|
|
|
def _render(name: str, port: int, routes):
|
|
with tempfile.TemporaryDirectory() as td:
|
|
f = Path(td) / f"{name}.yaml"
|
|
f.write_text(_sd(name, port, routes))
|
|
return subprocess.run([sys.executable, str(render_py), str(f)],
|
|
capture_output=True, text=True)
|
|
|
|
|
|
def _sidecar_args(rendered_yaml: str) -> list:
|
|
for doc in yaml.safe_load_all(rendered_yaml):
|
|
if not isinstance(doc, dict) or doc.get("kind") != "Deployment":
|
|
continue
|
|
for c in iter_containers(doc):
|
|
if c.get("name") == "oauth2-proxy":
|
|
return list(c.get("args") or [])
|
|
return []
|
|
|
|
|
|
if render_py.is_file():
|
|
# (6a) DECLARED ROUTES RENDER, IN ORDER, AND NOTHING ELSE DOES.
|
|
proc = _render("apirtdecl", 8121, ["^/api/", "^/signalr/"])
|
|
check(proc.returncode == 0,
|
|
f"the shipped catalog/render.py failed on a synthetic forward-auth fixture declaring "
|
|
f"two apiRoutes: rc={proc.returncode} {proc.stderr.strip()[:300]}")
|
|
if proc.returncode == 0:
|
|
got = api_routes_of(_sidecar_args(proc.stdout))
|
|
check(got == ["^/api/", "^/signalr/"],
|
|
f"the shipped renderer turned auth.apiRoutes ['^/api/', '^/signalr/'] into "
|
|
f"--api-route {got!r}. It must emit one repeatable --api-route per entry, in "
|
|
f"declared order. Note the flag is SINGULAR: --api-routes does not exist in "
|
|
f"oauth2-proxy v7.8.1 and the proxy REFUSES TO START with it, so a plural would "
|
|
f"crashloop every sidecar it rendered")
|
|
|
|
# (6b) AN APP THAT DECLARES NOTHING RENDERS NOTHING. The pihole case, proven on the
|
|
# generator rather than inferred from one committed golden.
|
|
for label, routes in (("absent", None), ("empty", [])):
|
|
proc = _render(f"apirtnone{label}", 8122, routes)
|
|
check(proc.returncode == 0,
|
|
f"the shipped renderer failed on a synthetic forward-auth fixture with apiRoutes "
|
|
f"{label}: rc={proc.returncode} {proc.stderr.strip()[:300]}")
|
|
if proc.returncode == 0:
|
|
got = api_routes_of(_sidecar_args(proc.stdout))
|
|
check(got == [],
|
|
f"the shipped renderer emitted --api-route {got!r} for a synthetic service "
|
|
f"whose auth.apiRoutes is {label}. A newly-onboarded forward-auth app must "
|
|
f"render NO --api-route: pihole serves no /api/, so a default would answer "
|
|
f"its page loads with 401 and the service would look permanently broken to a "
|
|
f"logged-out operator — a worse failure than the 302 bug the flag fixes")
|
|
|
|
# (6c) THE RENDERER REFUSES BREADTH. Asserted on the generator because render-golden runs
|
|
# on every gate invocation while the schema stage can be skipped.
|
|
for bad in ("^/", "^/.*", ".*", "/api/", "^/api/|^/"):
|
|
proc = _render("apirtbad", 8123, [bad])
|
|
check(proc.returncode != 0,
|
|
f"the shipped renderer ACCEPTED auth.apiRoutes {bad!r} and rendered a sidecar. "
|
|
f"That regex matches paths a browser NAVIGATES to, so a plain page load answers "
|
|
f"401 and the operator is never offered a sign-in. The renderer must refuse it, "
|
|
f"so a breadth mistake fails render-golden even on a run where the schema stage "
|
|
f"did not execute")
|
|
|
|
# ============================================================ VERDICT
|
|
inv = " | ".join(f"{k} -> {v or '(none)'}" for k, v in sorted(rendered.items()))
|
|
print(f"oauth2-proxy sidecars audited ({len(rendered)}): {inv}")
|
|
if FAILURES:
|
|
print(f"\nCHECK FAIL (oauth2-proxy-api-routes-per-app): {len(FAILURES)} failed, "
|
|
f"{CHECKS - len(FAILURES)} passed", file=sys.stderr)
|
|
for f in FAILURES:
|
|
print(f" FAIL {f}", file=sys.stderr)
|
|
raise SystemExit(1)
|
|
print(f"check OK: oauth2-proxy-api-routes-per-app — {CHECKS} assertions (R-IDP-02)")
|
|
PY
|