diff --git a/backend/main.py b/backend/main.py index a6739ad6..296d4118 100644 --- a/backend/main.py +++ b/backend/main.py @@ -112,17 +112,19 @@ async def lifespan(app: FastAPI): # The anonymous-admin bypass, said out loud once per boot. # - # AUTH_ALLOW_ANONYMOUS_ADMIN=1 is set in every environment while OAuth is - # unconfigured, so the flag has stopped being noticed — and it is not one - # guard among several, it is the whole of the admin boundary + # AUTH_ALLOW_ANONYMOUS_ADMIN=1 is a local-development convenience that no + # deployed environment sets, so this line on a droplet means the bypass has + # been restored to one — and it is not one guard among several, it is the + # whole of the admin boundary # (core.users._derive_auth_flags, tests/test_auth.py). The public surfaces # go to some length to publish displaced receiver positions and to withhold # private nodes entirely; the admin surfaces behind that boundary serve the # true ones, and while this is set they serve them to anyone who asks. # - # A log line, not a refusal: turning it off here would take every - # environment's admin access with it, and the default is deliberately not - # this module's to change. WARNING level so it survives the default + # A log line, not a refusal: local development has no Access assertion and + # no OAuth, so refusing here would leave a laptop with no way into the + # console, and the default is deliberately not this module's to change. + # WARNING level so it survives the default # LOG_LEVEL and lands in the deploy's own logs rather than only in a # developer's terminal. from core.users import AUTH_BYPASS diff --git a/backend/tests/test_anonymous_admin_closed.py b/backend/tests/test_anonymous_admin_closed.py new file mode 100644 index 00000000..c3e2bdf1 --- /dev/null +++ b/backend/tests/test_anonymous_admin_closed.py @@ -0,0 +1,68 @@ +"""No deployed environment may hand out an anonymous administrator. + +AUTH_ALLOW_ANONYMOUS_ADMIN makes require_admin return a superuser to any caller, +on every vhost, since the same app answers all of them. ClickUp 86cb1emcx records +what that cost: 1966 irreversible deletes through /api/admin/* in one week, by +nobody in particular. + +deploy/check-env-parity.py already refuses to let the three deployed overlays +disagree about it, which catches reintroducing it to one of them. It cannot catch +reintroducing it to all three at once, and that is what this covers. +""" + +import re +from pathlib import Path + +import pytest + +_REPO = Path(__file__).resolve().parents[2] +_FLAG = "AUTH_ALLOW_ANONYMOUS_ADMIN" + +#: Everything a real user can reach. +DEPLOYED = ["docker-compose.prod.yml", "docker-compose.staging.yml", "docker-compose.test.yml"] + + +def _sets_the_flag(overlay: str) -> bool: + text = (_REPO / overlay).read_text() + # An assignment, not the name: the surrounding comment mentions it too. + return bool(re.search(rf"^\s*-\s*{_FLAG}=", text, re.M)) + + +@pytest.mark.parametrize("overlay", DEPLOYED) +def test_no_deployed_overlay_opts_into_the_anonymous_admin(overlay: str) -> None: + assert not _sets_the_flag(overlay), ( + f"{overlay} sets {_FLAG}, which serves an anonymous superuser to anyone " + f"who asks, on every vhost. If an environment genuinely needs it, that " + f"is a decision to argue for rather than a line to restore." + ) + + +@pytest.mark.parametrize("overlay", DEPLOYED) +def test_no_deployed_overlay_leaves_the_verifier_unconfigured(overlay: str) -> None: + """The other half of the same guarantee. + + With the bypass gone and no audience set, an environment refuses everybody + including the people who are supposed to get in, and the admin console is + simply unusable there. Failing closed is right, but it should not be reached + by forgetting something. + """ + text = (_REPO / overlay).read_text() + assert re.search(r"^\s*-\s*CF_ACCESS_AUD=\S", text, re.M), ( + f"{overlay} sets no CF_ACCESS_AUD, so no Access assertion can be verified " + f"there and every admin request is refused. Create that environment's " + f"Access application and pin its audience here." + ) + + +def test_local_may_keep_the_bypass() -> None: + """Pinned deliberately, so nobody 'tidies' it away. + + A laptop has no Access assertion and no OAuth, the surfaces bind to + localhost, and a developer needs the console. docker-compose.local.yml is + outside check-env-parity.py's OVERLAYS for the related reason that it renders + the template's plain-HTTP branch. + """ + assert _sets_the_flag("docker-compose.local.yml"), ( + "docker-compose.local.yml no longer opts into the anonymous admin, so " + "local development cannot reach the admin surfaces at all." + ) diff --git a/deploy/check-env-parity.py b/deploy/check-env-parity.py index 87955ce0..f0f07708 100755 --- a/deploy/check-env-parity.py +++ b/deploy/check-env-parity.py @@ -92,10 +92,10 @@ # entry, long after the fact. See backend/config/constants.py. r"^services\.server\.environment\.NODE_FORCE_RETIRE_PREFIXES$", # AUTH_ALLOW_ANONYMOUS_ADMIN and SYNTHETIC_FLEET_ENABLED are deliberately - # absent from this list: each is set to the same value in every environment, - # so a difference is drift rather than a decision, and CI should fail if one - # environment closes the bypass, or drops the simulation subsystem, without - # the others. + # absent from this list, so a difference between environments is drift + # rather than a decision. SYNTHETIC_FLEET_ENABLED is set in all three; + # AUTH_ALLOW_ANONYMOUS_ADMIN in none, and keeping it off this list is what + # fails CI if it is ever restored to one environment alone. # Published ports. Production exposes 3012 for real receiver nodes; staging # has none and closes it, so the two legitimately differ here. Recorded rather # than silently allowed: if staging ever needs node ingest, it should be diff --git a/docker-compose.prod.yml b/docker-compose.prod.yml index e8edf713..5527ac0e 100644 --- a/docker-compose.prod.yml +++ b/docker-compose.prod.yml @@ -29,11 +29,8 @@ services: # bypass onto its own flag, so production can name itself honestly and # keep all four. - RETINA_ENV=production - # The bypass itself, now explicit: no OAuth is configured yet, so every - # caller is treated as an anonymous admin. This is the open door, and the - # only thing here that should worry a reader. Closing it (OAuth keys in - # backend/.env, then drop this line) is tracked in ClickUp 86cb1emcx. - - AUTH_ALLOW_ANONYMOUS_ADMIN=1 + # This environment's Access application, on admin.retina.fm. + - CF_ACCESS_AUD=44f723ed886782d1e85a5a2cd39a4bd96fded1791b2c2fbfebbced5e217aa5f0 # The simulation subsystem, likewise asked for by name: for now the ingest # write path, in time the fleet container as well, which is why the flag is # named for the subsystem rather than for that one route. Every environment diff --git a/docker-compose.staging.yml b/docker-compose.staging.yml index c837e37b..5b5a2bb5 100644 --- a/docker-compose.staging.yml +++ b/docker-compose.staging.yml @@ -39,10 +39,8 @@ services: # in ClickUp 86cb1emcx; revert to the real environment name there, not # here. - RETINA_ENV=test - # Anonymous admin, asked for explicitly since ClickUp 86cb49d29 took it - # off RETINA_ENV. Staging has no OAuth configured, so without this line - # the dashboard and every admin route start answering 401. - - AUTH_ALLOW_ANONYMOUS_ADMIN=1 + # This environment's Access application, on staging-admin.retina.fm. + - CF_ACCESS_AUD=e81258e72360b51069e64c82ca9762b52556898d8096b20873833ff50f7eb66e # The E2E suite retires the nodes it registers, which needs force because # a registered node is live until something evicts it. Confining force to # the suite's own prefixes means a bug in the teardown cannot reach a real diff --git a/docker-compose.test.yml b/docker-compose.test.yml index c08a61f2..a98a4531 100644 --- a/docker-compose.test.yml +++ b/docker-compose.test.yml @@ -62,14 +62,7 @@ services: # replay put 20 at a tenth of the turning breaks for 20% worse Doppler RMS; # main keeps the library's 0.5 until this measures the same live. - TRACKER_PROCESS_NOISE_DOPPLER=20 - # Anonymous admin, asked for explicitly since ClickUp 86cb49d29 took it - # off RETINA_ENV. No OAuth is configured here either. - - AUTH_ALLOW_ANONYMOUS_ADMIN=1 - # Audience of this environment's Access application, on test-admin.retina.fm. - # Pinning it is what stops a token minted for one of the fleet's node - # applications being accepted here; the team domain they share is in the - # base compose file. Staging and production carry their own once their - # applications exist. + # This environment's Access application, on test-admin.retina.fm. - CF_ACCESS_AUD=e5ff9de8d1ca5fbc62b38d102d92a1fc7d910d5f89ef388caf63c83e828493b3 # The simulation subsystem, likewise asked for by name rather than # inherited from what this environment is called. This droplet is a diff --git a/docker-compose.yml b/docker-compose.yml index 8895bb6e..5f269b1e 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -82,8 +82,9 @@ services: # One team for the whole org, so it belongs here rather than in an overlay: # a per-environment copy could drift to another team and the audience check # alone would not catch it. The per-application audience does differ, and - # is CF_ACCESS_AUD in each overlay. Not a secret — it appears in the login - # URL of every Access redirect. + # is CF_ACCESS_AUD in each overlay: pinning it is what stops a token minted + # for one of the fleet's seventeen node applications opening the console. + # Not a secret — it appears in the login URL of every Access redirect. - CF_ACCESS_TEAM_DOMAIN=offworldlab.cloudflareaccess.com # bash, not sh: start.sh's supervisor uses `wait -n`, a bash builtin that # dash rejects ("Illegal option -n"), which crash-loops the server. The diff --git a/docs/architecture.md b/docs/architecture.md index 4a97d6a3..352484b9 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -132,9 +132,16 @@ per-node trust residuals, and the feed's `adsb_single_node` display section). ## Auth model Cookie-based JWT issued via OAuth (Google/GitHub), shared across surfaces on the -same origin. `AUTH_ALLOW_ANONYMOUS_ADMIN=1` with no OAuth configured grants the -anonymous-admin bypass, independent of `RETINA_ENV`; every environment currently -sets it while OAuth is unconfigured. Node ownership maps +same origin. Administrators arrive instead through Cloudflare Access: the origin +verifies the `Cf-Access-Jwt-Assertion` itself against the team's published keys, +with `aud` pinned per environment to `CF_ACCESS_AUD`, and the verified email is +the identity (`backend/core/access_identity.py`). Enforcement is backend-side +because every vhost proxies `/api/` to the same app, so gating one hostname at +the edge would protect that hostname's HTML and nothing else; it is also why +`api.retina.fm`, the fleet's ingest hostname, carries no Access application. +`AUTH_ALLOW_ANONYMOUS_ADMIN=1` still grants the anonymous-admin bypass, +independent of `RETINA_ENV`, but only `docker-compose.local.yml` sets it. Node +ownership maps `node_id → user_id`; the `/ws/aircraft/owner` feed and dashboard use it to scope data to a user's own nodes. diff --git a/docs/runbook.md b/docs/runbook.md index b936a0f6..85ae9b59 100644 --- a/docs/runbook.md +++ b/docs/runbook.md @@ -104,9 +104,6 @@ arriving on production means it is working; confirm it positively by checking that the real nodes appear in the test droplet's `/api/radar/analytics`, which names them by `node_ref` rather than by node id. -Real receiver and transmitter geometry now lands on a droplet running -`AUTH_ALLOW_ANONYMOUS_ADMIN=1`. - --- ## Server basics @@ -124,9 +121,25 @@ advertised. Get them from the DigitalOcean console or your own `~/.ssh/config`. | **Restart (no rebuild)** | `docker compose restart` | | **Rebuild and restart** | `docker compose up -d --build` (wait ~5 s before testing) | | **Health endpoint** | `curl -sk https://localhost/api/health` | -| **Metrics endpoint** | `curl -sk https://localhost/api/admin/metrics` | +| **Metrics endpoint** | `adm /api/admin/metrics` (defined below) | | **Dashboard** | `curl -sk https://localhost/api/test/dashboard` | +`/api/admin/*` requires an administrator, and a request to localhost arrives +below Cloudflare with no assertion for the origin to verify, so a bare `curl` +answers 401. Define `adm` once per shell from a browser session on the console: + +```bash +# Sign in at the admin console, then copy the CF_Authorization cookie +# (devtools, Application, Cookies). It holds the same signed assertion that +# Cloudflare injects on a proxied request, so the origin verifies it identically. +export CF_ASSERTION='' +adm() { curl -sk -H "Cf-Access-Jwt-Assertion: ${CF_ASSERTION}" "https://localhost$1"; } +``` + +It expires with the Access session and carries the email of whoever signed in, so +anything you reach with it is attributed to that person in `/api/admin/events`. +Every `adm` below assumes it. + All state is **in-memory**. A container restart loses all connected nodes, active tracks, and in-flight frame data. State is snapshotted to disk every 60 s and restored on next startup (trust scores, reputations, accuracy samples, node identities). ### Database migrations @@ -242,7 +255,7 @@ uptime monitor). Details are never exposed on the endpoint — read them from lo ```bash docker compose logs --tail=200 | grep "Health check degraded" -curl -sk https://localhost/api/admin/metrics | python3 -m json.tool +adm /api/admin/metrics | python3 -m json.tool ``` --- @@ -361,7 +374,7 @@ What does catch it is the delay residual: compare the node's published `adsb[].e **Check:** ```bash -curl -sk https://localhost/api/admin/metrics | python3 -c \ +adm /api/admin/metrics | python3 -c \ "import sys,json; m=json.load(sys.stdin); print('queue_pct:', m['solver_queue_pct'], 'drops:', m['solver_queue_drops'], 'avg_latency:', m['solver_avg_latency_s'])" ``` @@ -506,8 +519,8 @@ working, so this alert means a reading well outside even that. **Check per-node miss rates:** ```bash -curl -sk https://localhost/api/admin/leaderboard | python3 -c \ - "import sys,json; rows=json.load(sys.stdin); [print(r['node_id'], r.get('miss_rate','?')) for r in rows]" +adm /api/admin/leaderboard | python3 -c \ + "import sys,json; rows=json.load(sys.stdin)['leaderboard']; [print(r['node_ref'], r.get('miss_rate','?')) for r in rows]" ``` **Common causes:** @@ -534,7 +547,7 @@ its own history. ```bash # On server — check if backup exists on R2: # (if R2 is configured) -curl -sk https://localhost/api/admin/storage +adm /api/admin/storage ``` Server will start with empty state if snapshot is corrupt. Trust scores and reputation data need to rebuild from scratch — this takes hours under normal node load. Not a functional outage. diff --git a/frontend/e2e/api.spec.ts b/frontend/e2e/api.spec.ts index ed1e3ba0..81fafe96 100644 --- a/frontend/e2e/api.spec.ts +++ b/frontend/e2e/api.spec.ts @@ -137,20 +137,12 @@ test.describe("API admin endpoints", () => { await ctx.dispose(); }); - test("GET /api/admin/leaderboard returns per-node list", async () => { + // 401 rather than a body: the api vhost has no Access application in front of + // it, so an anonymous caller is refused by require_admin itself. See the same + // assertion in dashboard.spec.ts for why that hostname must stay ungated. + test("GET /api/admin/leaderboard refuses an anonymous caller", async () => { const res = await ctx.get(`${API}/api/admin/leaderboard`); - expect(res.status()).toBe(200); - - const body = await res.json(); - // Response shape: {leaderboard: [...], total: N} - expect(body).toHaveProperty("leaderboard"); - expect(Array.isArray(body.leaderboard)).toBe(true); - // Each entry has required fields - if (body.leaderboard.length > 0) { - const first = body.leaderboard[0]; - expect(first).toHaveProperty("node_ref"); - expect(first).toHaveProperty("name"); - } + expect(res.status()).toBe(401); }); // Deliberately on the frontend host, not API: /api/config is served by diff --git a/frontend/e2e/dashboard.spec.ts b/frontend/e2e/dashboard.spec.ts index aa487b58..7003d455 100644 --- a/frontend/e2e/dashboard.spec.ts +++ b/frontend/e2e/dashboard.spec.ts @@ -4,11 +4,14 @@ * The dashboard has two legitimate auth modes, and the server says which one * it is in on every unauthenticated GET /api/auth/me: * - * oauth — OAuth client keys are configured. /api/auth/me answers 401, - * `/` redirects to /login, and /login renders the login card. - * bypass — AUTH_ALLOW_ANONYMOUS_ADMIN=1 with no OAuth client (deployed in - * every environment while OAuth is unconfigured; see - * backend/.env.example). /api/auth/me answers 200 with the anonymous + * oauth — auth is enforced. /api/auth/me answers 401, `/` redirects to + * /login, and /login renders the login card. Every deployed + * environment is in this mode; the name predates Cloudflare Access, + * and on a droplet it is a verified Access assertion rather than an + * OAuth client that satisfies it. + * bypass — AUTH_ALLOW_ANONYMOUS_ADMIN=1 with no OAuth client, which only + * docker-compose.local.yml sets now (see backend/.env.example). + * /api/auth/me answers 200 with the anonymous * admin and `auth_enabled: false`. `/` renders the dashboard * directly, and /login is a transient page: LoginPage navigates to * `/` the moment the auth call resolves, so it shows the login card @@ -61,6 +64,32 @@ async function holdAuthUnresolved(page: Page) { await page.route("**/api/auth/me", (route) => route.abort("connectionrefused")); } +/** + * The strongest claim the server's auth mode allows about the surface on screen. + * + * Past the login card the sidebar names the surface. Short of it both hostnames + * render the same card, and reaching that card is itself the assertion: the host + * resolved, Cloudflare Access admitted this run, and nginx served the dashboard + * bundle rather than an edge error or a redirect loop. + * + * The origin is checked, not just the path. Cloudflare's own login page is + * `.cloudflareaccess.com/cdn-cgi/access/login/`, so a bare /login + * match is satisfied by the very page a run without a service token gets stuck + * on, and the failure would read as a missing login card rather than as never + * having been let in. + */ +async function expectSurface(page: Page, base: string, mode: AuthMode, name: string) { + if (mode === "oauth") { + const { origin } = new URL(base); + await page.waitForURL((url) => url.origin === origin && url.pathname.startsWith("/login"), { + timeout: 10_000, + }); + await expect(page.locator(".login-card")).toBeVisible({ timeout: 5_000 }); + return; + } + await expect(page.locator(".brand-sub")).toHaveText(name, { timeout: 10_000 }); +} + test.describe("Dashboard — unauthenticated access (real auth mode)", () => { test("/ renders what the server's auth mode says it should", async ({ page }) => { const mode = await serverAuthMode(); @@ -154,20 +183,24 @@ test.describe("Admin surface selection", () => { adminResolves ??= await resolves(ADMIN!); test.skip(!adminResolves, `${ADMIN} does not resolve`); authMode ??= await serverAuthMode(); - test.skip(authMode === "oauth", "surface is only visible past the login card"); }); // Separate tests, not two assertions in one: the dash half is the control // that tells "admin selection broke" apart from "the sidebar markup changed // and both are wrong", and a shared body would stop at the first failure. - test("the admin vhost renders the admin console", async ({ page }) => { + // + // Which surface a hostname selects is resolveSurface's answer, and is covered + // exhaustively in dashboard/src/test/surface.test.ts. What only an end-to-end + // run can show is that the vhost is reachable and serving this bundle, which + // is why these stay here once enforced auth puts a login card in the way. + test("the admin vhost serves the admin console", async ({ page }) => { await page.goto(ADMIN!); - await expect(page.locator(".brand-sub")).toHaveText("Admin Console", { timeout: 10_000 }); + await expectSurface(page, ADMIN!, authMode!, "Admin Console"); }); - test("the dash vhost renders the user dashboard", async ({ page }) => { + test("the dash vhost serves the user dashboard", async ({ page }) => { await page.goto(DASH); - await expect(page.locator(".brand-sub")).toHaveText("Node Dashboard", { timeout: 10_000 }); + await expectSurface(page, DASH, authMode!, "Node Dashboard"); }); }); @@ -202,44 +235,26 @@ test.describe("Dashboard — login card (auth call held open)", () => { }); }); -test.describe("Dashboard — admin API backing (no auth required)", () => { - test("GET /api/admin/leaderboard returns nodes array", async () => { - const ctx = await playwrightRequest.newContext(); - const res = await ctx.get(`${API}/api/admin/leaderboard`); - expect(res.status()).toBe(200); - const body = await res.json(); - // Response shape: {leaderboard: [...], total: N} - expect(body).toHaveProperty("leaderboard"); - expect(Array.isArray(body.leaderboard)).toBe(true); - await ctx.dispose(); - }); - - test("GET /api/admin/events returns event list", async () => { - const ctx = await playwrightRequest.newContext(); - const res = await ctx.get(`${API}/api/admin/events`); - expect(res.status()).toBe(200); - const body = await res.json(); - // Events response is an array or an object containing an events key - const isValid = Array.isArray(body) || (typeof body === "object" && body !== null); - expect(isValid).toBe(true); - await ctx.dispose(); - }); - - test("GET /api/admin/storage returns file_count and total_size_mb", async () => { - const ctx = await playwrightRequest.newContext(); - const res = await ctx.get(`${API}/api/admin/storage`); - // 202 = storage scan still in progress (valid startup state) - expect([200, 202]).toContain(res.status()); - if (res.status() === 200) { - const body = await res.json(); - expect(body).toHaveProperty("archive_files"); - expect(body).toHaveProperty("archive_bytes"); - expect(body).toHaveProperty("archive_mb"); - expect(typeof body.archive_files).toBe("number"); - expect(typeof body.archive_mb).toBe("number"); - } - await ctx.dispose(); - }); +// On API deliberately. api.retina.fm carries no Access application and never +// can: it is the fleet's ingest hostname, and a node cannot complete an +// interactive login. So the refusal here comes from require_admin in this +// codebase rather than from the edge, which is the point of enforcing +// backend-side — the Host header stops mattering. +// +// Response shape is no longer assertable from here, because nothing in CI can +// authenticate against this hostname. The backend suite covers it. +test.describe("Dashboard — admin API refuses anonymous callers", () => { + // leaderboard is get_current_user rather than require_admin, so it refuses a + // step earlier; anonymous sees the same 401 either way. Split them if one + // ever becomes reachable without a session. + for (const path of ["/api/admin/leaderboard", "/api/admin/events", "/api/admin/storage"]) { + test(`GET ${path} refuses an anonymous caller`, async () => { + const ctx = await playwrightRequest.newContext(); + const res = await ctx.get(`${API}${path}`); + expect(res.status()).toBe(401); + await ctx.dispose(); + }); + } // On DASH, not API: the dashboard vhost proxies /api/config to // tower-finder-service (snippets/towers-proxy.conf), while the api vhost has