fix: gate /kong, split /health public, enforce PDP token at router level (PER-15244) - #317
Conversation
…vel (PER-15244) - Mount the enforcer router with router-level enforce_pdp_token and drop the per-route copies (notify_seen_sdk deps kept), closing the previously unauthenticated POST /kong decision endpoint. - Move GET /health to a dedicated public router so k8s/LB liveness probes keep working without the PDP token. - Default the Authorization header to None in enforce_pdp_token so a missing header returns 401 instead of 422. - Add auth regression tests for all enforcer routes, /health, and the Kong integration flow. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
🔍 Vulnerabilities of
|
| digest | sha256:5d1ce118ab160fec0b2194427815bb0beebca75187c72e97a7dce79906b99823 |
| vulnerabilities | |
| platform | linux/amd64 |
| size | 218 MB |
| packages | 247 |
📦 Base Image python:3.10-alpine3.22
| also known as |
|
| digest | sha256:c8f94b3bb77e6ea9015ccd091b7f8aec1b1fcbca95159675235d9a93788797cd |
| vulnerabilities |
Description
Description
Description
Description
Description
| ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
Description
| ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
Description
| ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
Description
|
- Pin aiohttp<3.14 in the dev requirements: aioresponses 0.7.x cannot mock aiohttp>=3.14 (ClientResponse gained a required stream_writer argument), which broke every OPA-mocking test. Runtime pin is unchanged. - Pin k3d to v5.9.0 in the pdp-tester job: the k3d-action default (v5.4.6) predates release checksums.txt assets, which the k3d install script now requires, so cluster setup 404'd before any test ran. Both breakages pre-date this branch (main last ran CI green on 2026-05-13). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
There was a problem hiding this comment.
Pull request overview
This PR closes an auth gap by enforcing PDP token authentication at the router level for enforcer endpoints (including /kong), while keeping /health publicly accessible for k8s/LB probes.
Changes:
- Split
/healthinto a dedicated public router and mount it without auth. - Apply
enforce_pdp_tokenas a router-level dependency for the enforcer router and remove per-route copies. - Add/expand tests to validate 401 behavior for missing/invalid tokens across enforcer endpoints and cover
/kongflows; pinaiohttp<3.14for test mocking compatibility and bump k3d version in CI.
Reviewed changes
Copilot reviewed 6 out of 6 changed files in this pull request and generated no comments.
Show a summary per file
| File | Description |
|---|---|
| requirements-dev.txt | Pin aiohttp<3.14 in dev/test env to keep aioresponses compatible. |
| horizon/tests/test_enforcer_api.py | Add auth-sweep tests for protected routes; add /health public test and /kong auth + integration tests. |
| horizon/pdp.py | Mount new health router publicly; enforce PDP token at enforcer router include level. |
| horizon/enforcer/api.py | Split out init_enforcer_health_router() and remove per-route PDP-token dependencies from enforcer routes. |
| horizon/authentication.py | Make Authorization header optional so missing token yields 401 instead of 422. |
| .github/workflows/tests.yml | Pin k3d version to avoid upstream install/download issues in CI. |
Comments suppressed due to low confidence (1)
horizon/authentication.py:15
authorization.split(" ")will raiseValueErrorfor malformed Authorization headers (e.g. "Bearer", extra spaces, or no space), which will surface as a 500 instead of a 401. Sinceenforce_pdp_tokenis now applied router-wide, harden parsing to always return a controlled 401 on malformed headers.
def enforce_pdp_token(authorization: Annotated[str | None, Header()] = None):
if authorization is None:
raise HTTPException(status.HTTP_401_UNAUTHORIZED, detail="Missing Authorization header")
schema, token = authorization.split(" ")
if schema.strip().lower() != "bearer" or token.strip() != get_env_api_key():
raise HTTPException(status.HTTP_401_UNAUTHORIZED, detail="Invalid PDP token")
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
The k8s/k3d-based pdp-tester job timed out at 600s with no logs and no PDP pods created — the tester's k3d/Helm orchestration never started. The pdp-tester repo added a Docker runtime backend (k8s-free) for exactly this; mirror its own CI's `pdp-tester-docker` job. Install the tester with the [docker] extra and run it as a plain process against the runner's Docker daemon. LOCAL_IMAGE + LOCAL_TAGS make the runtime launch the PR-built permitio/pdp-v2:next directly (no registry pull — aiodocker only pulls on image-not-found, and we docker-load it first). Drops k3d, Helm, the tester image build, and the earlier k3d-version pin those steps needed. The tester attaches the PDP token on every call and probes /healthy for readiness, so this exercises the router-level auth change end-to-end (incl. the health_check case asserting /health -> 200). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
zeevmoney
left a comment
There was a problem hiding this comment.
Automated review — PER-15244 (gate /kong, split /health public, router-level auth) [URGENT security]
What this PR does: Closes an unauthenticated-endpoint hole. In horizon/enforcer/api.py the enforcer routes no longer carry per-route Depends(enforce_pdp_token); instead horizon/pdp.py includes the whole enforcer router with a router-level dependencies=[Depends(enforce_pdp_token)], so every enforcer route — including /kong, which at base was mounted with NO auth dependency and was therefore publicly callable — now requires the PDP token. /health is moved to a new dependency-free init_enforcer_health_router() mounted without auth, so liveness probes (which cannot attach the token) keep working. enforce_pdp_token gains an = None default so a missing header yields the function's 401 rather than a 422. Adds thorough tests. The PR also bundles the pdp-tester CI migration and a pytests fix (finding 1).
Verdict: APPROVE. No Postable finding is HIGH or CRITICAL (severity rule: only MEDIUM/LOW postable -> approve). The security change verifies out and is provably complete:
/kongwas unauthenticated at base (git show 15689c7:horizon/enforcer/api.py— the@router.post("/kong")decorator had nodependencies); it is now gated by the router-level dep. Confirmed vuln + fix.- All 9 enforcer routes (
/authorized_users,/allowed_url,/user-permissions,/user-tenants,/allowed/all-tenants,/allowed/bulk,/allowed,/nginx_allowed,/kong) sit on the single gated router —init_enforcer_api_routeris called exactly once (pdp.py) and included once with the dep. The newtest_enforcer_endpoint_missing_token_returns_401is parametrized over exactly those 9 and asserts 401;pytestsis green, so gating is proven for every route. /healthis the only route on the public router;test_health_endpoint_is_publicasserts 200 without a token. The other app routers (local, proxy, facts x2, connectivity, system) already carried their own auth at base and are unchanged.- FastAPI applies include-level dependencies before route-level ones, so
enforce_pdp_tokenstill runs beforenotify_seen_sdk— no regression for authed routes (valid-token allow-path tests for/kong,/authorized_users,/nginx_allowedall pass).
Findings
Postable
| # | Sev | File:Line | Category | Description |
|---|---|---|---|---|
| 1 | MEDIUM | requirements-dev.txt:7 (+ .github/workflows/tests.yml) | Isolation / scope | Urgent security fix bundled with the pdp-tester CI migration (3rd divergent copy across #317/#318/#319) and a pytests fix that pins aiohttp<3.14 — a strategy contradicting #318's conftest.py shim. Recommend splitting CI/test-infra out so the security fix merges cleanly. |
Informational
| # | Sev | Ref | Category | Description |
|---|---|---|---|---|
| I1 | LOW | horizon/authentication.py:12 | Robustness (pre-existing) | authorization.split(" ") unpacked into schema, token raises ValueError -> HTTP 500 (not 401) for a header with no space or >1 space (e.g. Authorization: Bearer). Still denies, but 500; now reachable on all enforcer routes via the router-level dep. Pre-existing (line unchanged); partition(" ") / length-check would be cleaner. |
| I2 | LOW | horizon/authentication.py:14 | Security hygiene (pre-existing) | Token compared with != (not constant-time). Low practical risk for a network bearer token; pre-existing, unchanged here. |
| I3 | INFO | requirements-dev.txt:7 | Test fidelity | Dev-pinning aiohttp<3.14 means tests run against 3.13.x while the shipped image (requirements.txt aiohttp>=3.13.3,<4) resolves to 3.14.x — tests no longer exercise the prod aiohttp line. |
| I4 | INFO | docker-scout / security-snyk (CI) | Pre-existing / infra | docker-scout red (residual base-image CVEs; this branch does not bump image deps); security/snyk red on a quota limit — neither is a finding. pytests / pdp-tester / build / rust / pre-commit all green. |
| I5 | INFO | cross-PR (#321) | Interaction | #321 adds a default-deny auth MIDDLEWARE. If both land, #321's middleware and this PR's router-level dep double-gate the enforcer routes (defense-in-depth, fine) — but #321's allowlist MUST include the public /health route mounted here, or health breaks. Flagged for #321's review. |
Blast radius: Auth surface only — all enforcer routes now gated at router level; other routers unchanged (already gated); /health intentionally public. No downstream schema/data changes. The bundled tests.yml collides with #318/#319 on the same file.
Isolation / scope: Core security work is well-isolated and complete. Out-of-scope CI/test-infra bundled in (MEDIUM) — 3rd divergent tester-migration copy + a contradictory pytests fix.
| aioresponses | ||
| # aioresponses 0.7.x cannot mock aiohttp>=3.14 (ClientResponse gained a required | ||
| # stream_writer argument); keep the test env on 3.13.x until aioresponses catches up | ||
| aiohttp<3.14 |
There was a problem hiding this comment.
[MEDIUM] Urgent security fix bundled with churny CI/test-infra changes (3rd divergent copy of the tester migration; test-fix strategy contradicts #318)
Problem: This PR's load-bearing change is the security fix (gate /kong, split /health, router-level auth — commit b8a4637), which is flagged URGENT. It also drags in two unrelated CI/test-infra changes that make it slower and riskier to land:
.github/workflows/tests.yml— the pdp-tester k3d -> Docker migration. This is now the THIRD open PR carrying that same migration (fix: gate /kong, split /health public, enforce PDP token at router level (PER-15244) #317, fix: patch 18 of 24 CVEs in pdp-v2 image (PER-15358) #318, ci: run pdp-tester via Docker runtime instead of k3d #319), and all three copies have diverged: this one runspython -m pdp_tester --docker --skip-generatewithLOCAL_TAGS: '["next"]'and noSTART_TIMEOUT; fix: patch 18 of 24 CVEs in pdp-v2 image (PER-15358) #318/ci: run pdp-tester via Docker runtime instead of k3d #319 use--local --tag next+START_TIMEOUT=180; ci: run pdp-tester via Docker runtime instead of k3d #319 additionally addstimeout-minutes, pip caching, and robust non-zero-exit handling. Three variants of one change guarantee merge conflicts and lost improvements.requirements-dev.txt(this line) — pinsaiohttp<3.14to unbreak pytests. That is a different, contradictory strategy from fix: patch 18 of 24 CVEs in pdp-v2 image (PER-15358) #318, which deliberately keeps aiohttp 3.14 and adds ahorizon/tests/conftest.pyaioresponses shim precisely because (its own comment argues) pinning aiohttp down is undesirable. If both merge, the dev-pin forces 3.13.x so fix: patch 18 of 24 CVEs in pdp-v2 image (PER-15358) #318's 3.14-only shim never activates — redundant and confusing. It also means the test suite now runs against a different aiohttp than the shipped image (requirements.txtstill allows 3.14.x).
Suggestion: Split the CI/test-infra changes (tests.yml + requirements-dev.txt) out of this security PR so the urgent fix can merge on its own, and consolidate the tester migration + the pytests fix into a single dedicated PR (coordinated with #318/#319) instead of three divergent copies.
There was a problem hiding this comment.
Addressed by merging main into this branch (a044bb3) and reconciling to it, rather than splitting the PR — #318 has since landed on main, so the churn now nets out:
requirements-dev.txt— dropped theaiohttp<3.14pin. fix: patch 18 of 24 CVEs in pdp-v2 image (PER-15358) #318'shorizon/tests/conftest.pyaioresponses shim (now onmain) is the single strategy; the suite runs against aiohttp 3.14.x, matching the shipped image. This also resolves I3 (test fidelity)..github/workflows/tests.yml— reconciled tomain's version (which carries fix: patch 18 of 24 CVEs in pdp-v2 image (PER-15358) #318's Docker-runtime migration + the docker-scout VEX wiring). No longer a divergent copy:git diff main -- .github/workflows/tests.ymlis now empty.
Net result: the PR's diff against main is now only the four security-fix files (authentication.py, enforcer/api.py, pdp.py, test_enforcer_api.py) — the urgent fix is effectively isolated as you suggested, and on squash-merge the intermediate CI commits collapse out. Full horizon/tests suite green post-merge (72 passed).
- Import MockPermitPDP by basename (from test_enforcer_api) instead of horizon.tests.*: CI installs the package non-editably, so the wheel has no tests/ package and the dotted import aborted all pytest collection. Basename matches pytest's prepend import mode and also avoids a duplicate module object (second OpalClient construction) in local full-suite runs. - Accept 401 or 422 for a missing Authorization header: 422 on current main (required-param validation), 401 once PR #317 gives the param a None default. Survives either merge order; still fails on 200/500. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
aioresponses 0.7.x cannot mock aiohttp>=3.14 (ClientResponse gained a required stream_writer argument), which fails 34 enforcer/local-api tests in CI. Identical to the pin in #317 so either merge order resolves cleanly. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
PER-15358 (#318, now merged into this branch) fixes the aioresponses/ aiohttp-3.14 incompatibility with a stream_writer compat shim in horizon/tests/conftest.py, deliberately keeping the test env on the CVE-patched 3.14 line. The pin (mirrored from #317 before #318 landed) would force CI back to 3.13.x, bypass the shim, and reintroduce the dev/prod version skew. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
main's #318/#322 independently did the same k3d->Docker pdp-tester rewrite this branch had, plus docker-scout VEX waivers and CVE bumps. Resolution: - .github/workflows/tests.yml: take main's version wholesale — it is a refined superset (adds START_TIMEOUT, cleaner --local --tag next flags, and the docker-scout OpenVEX waiver wiring this branch lacked). - requirements-dev.txt: drop the `aiohttp<3.14` pin. main deliberately stays on aiohttp 3.14 (June 2026 security fixes, not backported to 3.13.x) and shims aioresponses via horizon/tests/conftest.py instead; keeping the pin would reintroduce those CVEs into the image. Net effect: the PR now diffs against main as only the PER-15244 auth change (authentication.py, enforcer/api.py, pdp.py, test_enforcer_api.py). Full horizon/tests suite: 72 passed. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…o-a-public-router-move-enforcer
…calls (PER-15246) (#320) * feat: replace legacy /update_policy* 307 redirects with direct gated calls (PER-15246) The gated aliases /update_policy and /update_policy_data 307-redirected to the canonical OPAL trigger routes. Many HTTP clients (requests, httpx, browsers) strip Authorization on redirect, so once the canonical routes are gated by the upcoming default-deny middleware (PER-15245), a legitimate SDK calling the alias with a token would get 307 -> token dropped -> 401. Call the OPAL updaters directly instead of redirecting, mirroring the canonical handlers (policy_updater.trigger_update_policy / data_updater.get_base_policy_data, 503 when the data updater is disabled). Keep the per-route enforce_pdp_token gate and drop the now-unused RedirectResponse import. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * test: address Copilot review on legacy update-route tests - Assert the 503 detail string matches the canonical data route exactly. - Use httpx `is_redirect` instead of `!= 307` so the no-redirect guard covers every redirect code (301/302/303/307/308), since clients drop Authorization on all of them. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix: restore canonical trigger log lines on legacy alias routes The canonical OPAL trigger handlers log the API-originated trigger; the direct-call rewrite dropped that, leaving SDK-triggered full re-pulls unattributable in PDP logs during incident debugging. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * test: fix CI collection break and #317 status-code collision (review) - Import MockPermitPDP by basename (from test_enforcer_api) instead of horizon.tests.*: CI installs the package non-editably, so the wheel has no tests/ package and the dotted import aborted all pytest collection. Basename matches pytest's prepend import mode and also avoids a duplicate module object (second OpalClient construction) in local full-suite runs. - Accept 401 or 422 for a missing Authorization header: 422 on current main (required-param validation), 401 once PR #317 gives the param a None default. Survives either merge order; still fails on 200/500. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * ci: pin aiohttp<3.14 in dev requirements (mirrors #317) aioresponses 0.7.x cannot mock aiohttp>=3.14 (ClientResponse gained a required stream_writer argument), which fails 34 enforcer/local-api tests in CI. Identical to the pin in #317 so either merge order resolves cleanly. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * test: correct two comment inaccuracies flagged in review - The fixture comment claimed monkeypatch mutations could leak across modules; monkeypatch reverts at teardown, so state the real rationale (defensive isolation from the shared singleton). - httpx's is_redirect covers any 3xx, not just the five common codes. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * ci: drop aiohttp<3.14 dev pin, superseded by conftest shim from main PER-15358 (#318, now merged into this branch) fixes the aioresponses/ aiohttp-3.14 incompatibility with a stream_writer compat shim in horizon/tests/conftest.py, deliberately keeping the test env on the CVE-patched 3.14 line. The pin (mirrored from #317 before #318 landed) would force CI back to 3.13.x, bypass the shim, and reintroduce the dev/prod version skew. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * docs: note deliberate canonical parity on unguarded policy_updater (review) Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
…routes always enforce the PDP token (PER-15243) The rollout toggle is no longer wanted: the update-trigger routes (/policy-updater/trigger, /data-updater/trigger, /update_policy, /update_policy_data) and /kong now enforce the PDP token unconditionally, as established by #317/#320/#321. This removes the flag, the enforce_pdp_token_operational warn-and-allow wrapper, the per-route warn throttle, the /kong router split, and the toggle-specific tests, restoring the strict gates. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Closes PER-15244
What
Closes the unauthenticated
POST /kongdecision endpoint and kills the per-route auth footgun class on the enforcer router:/healthsplit to a public router (done first, deliberately): moved out ofenforcer_routerinto a dedicatedinit_enforcer_health_router(), mounted without auth. k8s/LB liveness probes (Helm chart probesGET /healthwith no headers) are unaffected by the next step. Body unchanged.include_router(enforcer_router, dependencies=[Depends(enforce_pdp_token)])— matching every sibling router (local/proxy/facts/connectivity). This gates/kong: FastAPI runs dependencies before the handler, so auth now precedes theKONG_INTEGRATION503 check.enforce_pdp_tokencopies removed (8 routes). TheDepends(notify_seen_sdk)deps are kept where present; router-level deps run first, so effective order is preserved.enforce_pdp_tokenheader param defaults toNone: previously a missingAuthorizationheader was rejected by FastAPI param validation as 422 and the function'sis None -> 401branch was dead code. Now a missing header returns 401, per the issue's acceptance spec. Invalid-token 401 behavior unchanged. (Malformed-header 500 is intentionally untouched — that is PER-15245/PER-15250 scope.)Behavior changes
POST /kong, no/any-invalid tokenKONG_INTEGRATION=true)AuthorizationheaderGET /health, no tokenTests
/healthtokenless → 200./kong: valid token + integration disabled → 503; full enabled flow (routes table + mocked OPA) → tokenless 401, valid token 200{"result": true}./authorized_usersand/nginx_allowed(previously uncovered).horizon/tests/suite: 72 passed; ruff check + format clean at the pre-commit-pinned v0.11.6.route.dependant.dependencieson the live app): everyAPIRoutecarries the PDP-token/control-key dep except the intended public set (/health, OPAL's/,/healthcheck,/healthy,/ready) and OPAL routes with their own listener-JWT auth. The OPAL trigger routes stay open by design here — they are PER-15245/PER-15247 scope.⚠ Pre-merge check for reviewers
Confirm the Kong OPA plugin forwards
Authorization: Bearer <PDP_API_KEY>(precedent: the gated/nginx_allowedworks with its nginx caller). If Kong cannot send it,/kongneeds a dedicated credential — flag to the integrations owner.🤖 Generated with Claude Code