Verify Cloudflare Access assertions at the origin - #372
Merged
Conversation
This comment has been minimized.
This comment has been minimized.
Nothing yet calls this; it is the piece the admin gate needs before require_admin can refuse anyone, landed on its own so the security-critical part can be read without the wiring around it. Verifying rather than trusting the header matters twice here. Once for the reason the node code gives: an Access application deleted or misconfigured leaves the hostname open, and this is the only thing that would notice. And once for a reason particular to this backend, that every vhost proxies to the same app, so an assertion is the only thing distinguishing an administrator from any caller who found api.retina.fm. Gating a hostname cannot do that job. The audience check is the load-bearing one and the easiest to omit, because a token failing it is still perfectly signed: the team runs Access on seventeen node hostnames, and without it any engineer's node session would open the console. It was driven out by a test that genuinely failed first, as were the issuer, expiry, signature and alg=none refusals. Async on httpx rather than the node's blocking requests, since this is awaited from a dependency on the request path and a cold cache would otherwise stall the event loop for the whole 10s timeout. PyJWKSetError is caught explicitly: it descends from PyJWTError but not InvalidTokenError, so the obvious handler lets a malformed key set escape as a 500 instead of failing closed. pyjwt becomes a direct dependency. It was already imported by test_auth.py while arriving only as a transitive of fastapi-users, which pins this exact version, so the pin is forced rather than chosen. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The verifier landed with nothing calling it. This is the seam: get_current_user and require_admin try a verified assertion before anything else, and otherwise behave exactly as they did. A verified assertion is sufficient on its own, deliberately not also checked against AUTH_ADMIN_EMAILS. The Access policy is already the membership list, and a second list means adding a colleague takes two edits in two systems that will drift apart; the audience pin is what makes that safe, since a token minted for any of the seventeen node applications fails it. Access is tried ahead of the bypass rather than after. Where both are available the real person is the better answer: these are the endpoints that retire hardware, and "Admin (no auth)" is not an attribution. The id is derived from the email with uuid5 rather than allocated, so the same person is the same id across requests and restarts without a row having to exist and admin events stay attributable. An unconfigured verifier is never consulted, so the header is not even read where CF_ACCESS_AUD is unset. Every environment that has not been given an audience, this test suite included, behaves precisely as before. /api/auth/me stops short-circuiting on its own copy of AUTH_BYPASS and delegates instead. It was the one place that would have disagreed with the dependencies, reporting "Admin (no auth)" on the same request that require_admin attributed to a real person, and the dashboard asks it who it is talking to. auth_enabled now derives from whether the identity actually is the anonymous one, so every existing case reports what it always did. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The seam reads two values and nothing was supplying them. This wires the team domain and the audience through compose, and teaches the parity checker that one of them is meant to differ. The team domain goes in the base file rather than being repeated per overlay: there is one Zero Trust team for the org, and a per-environment copy could drift to a different one without the audience check noticing, since that check only proves a token was minted for this application, not by whom. The audience is genuinely per environment, one tag per Access application, which is exactly the shape HOST_ADMIN already has. So it gets the same treatment: an ALLOWED_DIVERGENCE entry, because without one the second environment to be given a tag fails the parity check and no deploy passes. Confirmed load-bearing by removing it, whereupon the check fails naming the key. Only the test environment carries a tag, because only test-admin.retina.fm has an application so far. Staging and production are deliberately still unset, which leaves the verifier unconfigured there and therefore never consulted: those applications gate their hostnames the moment they exist, and staging's is probed by CI, so they wait for the smoke-test changes. The AUTH_ALLOW_ANONYMOUS_ADMIN exclusion gains a test rather than an edit. It keeps working after the flag is dropped from all three overlays, since it is what makes CI refuse a change that reintroduces the anonymous admin to one environment on its own, and it should not quietly stop covering that. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Babissimo
force-pushed
the
feat/admin-access-gate
branch
from
September 14, 2026 11:15
2306b44 to
840dc6a
Compare
|
Claude finished @Babissimo's task in 2m 22s —— View job Code review
No issues found. The diff is unchanged from the prior review on this PR (same 3 commits, same 11 files, same line counts), so this pass re-verified the same code rather than reviewing new changes. What I checked:
Nothing to flag — this holds up as a careful, narrowly-scoped implementation matching its own design note. |
Babissimo
added a commit
that referenced
this pull request
Sep 14, 2026
AUTH_ALLOW_ANONYMOUS_ADMIN made require_admin hand a superuser to any caller on every vhost, since one FastAPI app answers all of them. With the origin-side verifier in place (#372) and an Access application now in front of all three admin hostnames, this flag was the last thing holding the door open. All three deployed overlays lose it in a single commit because check-env-parity.py refuses to let them disagree about it, and that guard is right: closing the bypass in one environment and not the others is drift rather than a decision. docker-compose.local.yml keeps it deliberately, since a laptop has no Access assertion and no OAuth, and local sits outside the checker's scope. Staging and production gain the audience of the application created for each, so the verifier is configured everywhere the bypass has gone. An environment with neither would refuse everybody, including the people meant to get in, and failing closed should be reached on purpose rather than by omission. test_anonymous_admin_closed.py covers what parity cannot. Parity catches the flag returning to one environment; it cannot catch it returning to all three at once. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
The origin half of ClickUp 123zgec1gtf.
Lands inert: nothing refuses anyone yet, because no deployed environment has an
audience except the test droplet, and the anonymous-admin bypass is untouched.
Closing it is a separate change that needs the staging and production Access
applications to exist first.
Independent of #371 and touches no file it does. Either order.
Why the fix is here and not at the edge
Every vhost in
deploy/nginx/nginx.conf.templateproxies/api/to the sameFastAPI app, and FastAPI does not read the Host header, so
https://api.retina.fm/api/admin/node-ownersreaches the same handler as theadmin hostname does. An Access application on
admin.retina.fmprotects thathostname's HTML and nothing else. Enforcement therefore has to be backend-side,
and Access becomes the human doorway rather than the control. That is also why
api.retina.fmneeds no application: it is the fleet's ingest hostname, andnodes cannot complete an interactive login.
What is checked
Signature against the team's published keys,
audpinned to this environment'sapplication,
issthe team domain,expwith 30s leeway, and every one of themrequired. The audience is the load-bearing one and the easiest to omit, because a
token failing it is still perfectly signed: the team runs Access on seventeen node
hostnames, and without it any engineer's node session would open the console. It
was driven out by a test that genuinely failed first, as were the issuer, expiry,
signature and
alg=nonerefusals.CF-Access-Authenticated-User-Emailis deliberately not read. There is a testthat fails if it ever is.
The seam
get_current_userandrequire_admintry a verified assertion before anythingelse and otherwise behave exactly as they did. A verified assertion is sufficient
on its own, not additionally checked against
AUTH_ADMIN_EMAILS: the Accesspolicy is already the membership list, and a second list means adding a colleague
takes two edits in two systems that drift. The id is derived from the email with
uuid5rather than allocated, so one person keeps one id across restarts withouta database row and admin events stay attributable.
An unconfigured verifier is never consulted, so every environment without
CF_ACCESS_AUD, this test suite included, behaves precisely as before./api/auth/mestops short-circuiting on its own copy ofAUTH_BYPASS. It was theone place that would have disagreed with the dependencies, reporting
"Admin (no auth)" on the same request
require_adminattributed to a real person,and the dashboard asks it who it is talking to.
Outage behaviour, which is deliberate
Access at the edge and the endpoint publishing the signing keys are different
systems, so the outage worth designing for is the one where Access admits the
team normally and only the refresh fails. Refusing then would take the console
down on our own account using keys we are still holding. So a held key set keeps
verifying for six hours past its TTL when refresh is failing, then stops: long
past any plausible outage, short beside Cloudflare's rotation cadence of weeks.
Three properties hold on every failing path, and each has a test: at most one
fetch per interval, at most one log line per interval, and a refusal caused by an
outage never reported as a bad key id. The last matters because
kidcomes fromthe unverified header, so without it an unauthenticated caller could drive one
Cloudflare fetch per request while holding the lock every admin request waits on.
Verification
pre-commit run --all-filesclean,deploy/check-env-parity.pyclean.the guard was removed and the expected tests confirmed to fail.
pyjwt[crypto]==2.8.0becomes a direct dependency. It was already imported bytest_auth.pywhile arriving only as a transitive offastapi-users, whichpins this exact version, so the pin is forced rather than chosen.
🤖 Generated with Claude Code