From f8aa86510b74d8f9593dcd349d2e176c7c37d766 Mon Sep 17 00:00:00 2001 From: Babissimo Date: Mon, 14 Sep 2026 12:43:02 +0100 Subject: [PATCH 1/2] Close the anonymous admin bypass in every deployed environment 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 --- backend/main.py | 14 ++-- backend/tests/test_anonymous_admin_closed.py | 68 ++++++++++++++++++++ deploy/check-env-parity.py | 8 +-- docker-compose.prod.yml | 7 +- docker-compose.staging.yml | 6 +- docker-compose.test.yml | 9 +-- docker-compose.yml | 5 +- docs/architecture.md | 13 +++- docs/runbook.md | 31 ++++++--- 9 files changed, 120 insertions(+), 41 deletions(-) create mode 100644 backend/tests/test_anonymous_admin_closed.py 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. From 3543ccdc5bf20b4486ed739a70c9b62b0ca3d165 Mon Sep 17 00:00:00 2001 From: Babissimo Date: Mon, 14 Sep 2026 12:43:11 +0100 Subject: [PATCH 2/2] Flip the e2e admin assertions to the refusal they now get These hit api.retina.fm, which carries no Access application and never can: it is the fleet's ingest hostname and a node cannot complete an interactive login. The 401 therefore comes from require_admin in this codebase rather than from the edge, which is the whole reason enforcement is backend-side. Response shape is no longer assertable from here, because nothing in CI can authenticate against that hostname; the backend suite still covers it. The admin surface-selection block would otherwise have stopped running altogether. It skipped itself once the server reported enforced auth, and with a login card in the way the sidebar that names the surface is never reached, so the assertion PR #335 added would have vanished quietly rather than failed. It now makes the strongest claim each auth mode allows: the surface name where the bypass is still on, and otherwise that the host resolved, Access admitted the run and nginx served this bundle. Which surface a hostname selects is resolveSurface's answer and stays covered in dashboard/src/test/surface.test.ts. Co-Authored-By: Claude Opus 5 --- frontend/e2e/api.spec.ts | 18 ++---- frontend/e2e/dashboard.spec.ts | 111 +++++++++++++++++++-------------- 2 files changed, 68 insertions(+), 61 deletions(-) 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