**Verified (final pass, phase 11 — all 7 tasks already in `complete/`)**
- Gate from the wire on a restarted dev server: `/system`→`303 /login?next=/system` (`/llm?range=1h`→`/login?next=/llm%3Frange%3D1h`), `/api/summary`+`/api/config`→`401 {"ok":false,…}`, `/api/health`→`200`; signed-in `GET` of all six pages `200`, `/login`→`303 /`.
- Auth flows on a throwaway server (`:8199`, `/tmp` DB — dev `auth.verifier` untouched): wrong pw `401`, `testpass` `200`, password change `200`, **old cookie `401` / new cookie `200`**, old pw `401` / new pw `200`, short new `422`, wrong current `403`, cookie write without CSRF `403`, logout `200`; 5 failures then `429 retry-after: 60`; `login-denied`/`password-changed`/`password-denied` events; `meta` = the two auth keys.
- Fail-closed boot (no seed, empty DB): **exit 3**, message names `DASH_ADMIN_PASSWORD`, `auth.verifier`, and the `DELETE FROM meta …` reset. `dev-server.sh status` + the Containerfile `HEALTHCHECK` python one-liner both `200`/exit 0 unauthenticated.
- Live payloads unchanged (phases 01–10 walk): `/api/summary` keys + 8 alerts + hosts 3/2 live; UPS `simulated:false` with charge/load/runtime severity; deskwork `nvme0n1` 51 °C/19 159 h/11 % wear; gateway `smartctl_available:false`; driveripper `unreachable` stub.
**Tests/lint (all green)**: `bash .agents/validate.sh` → exit 0 — `1140 passed, 11 skipped`, coverage `98.62%` (floor 94), ruff `All checks passed!`, pyright `0 errors`, `node --check` clean, repo hygiene clean. `uv run pytest -m live --live` → `8 passed, 3 failed`, all `driveripper: 10.1.0.126:9120 … ConnectError` (pre-existing, auth-free; probe confirms nothing listening).
**Completion criteria**: all met except the live-suite caveat above — installing the driveripper agent needs root on that host (no `ops` grant), so per the live-infra rule I made **no live change**; task 07's five screenshots (`auth-login/-rejected/-locked-out/-signed-in-overview/-settings.png`) remain the browser evidence, and the login/rotation/lockout flows were re-proved over HTTP this pass.
**Docs**: README — fixed "only place the CSRF token is published" (login/password responses mint one), added the undocumented `DASH_RELOAD` row, stated that Sign out ends only this browser's cookie. `AGENTS.md` — new known gap 8 (stateless logout cannot revoke a copied token), gap-4 dev-store sizes dated, fleet section notes `data/` is gitignored. `api.py` — same `status` docstring imprecision fixed. Verified the 4 `DASH_*` rows, reset command, seed-wins rule and invariant 12 against `app.py`/`config.py`.
**Deviations**: no dependency bump (`uv pip list --outdated` advisory: certifi/fastapi/starlette — phase added none); verification notes appended to `data/logs/NOTES-auth.md` §10.
**Next pending phase**: `12_nut_self_test_verdict`.
8.6 KiB
Phase 11 — One admin, one password: the dashboards stop being public
Source: operator request in chat, 2026-10-17 — "password protect this dashboard for a single admin user… changeable once logged in, configured initially with an env var, not required to change… for local testing the default admin password should be testpass."
Story: n/a
Context: src/dashboards/app.py (route table, middleware, PAGES), src/dashboards/api.py (AppContext, /api/config, the one POST /api/ups/command), src/dashboards/config.py (env-only settings), src/dashboards/store.py (the existing meta table), src/dashboards/web/static/app.js (fetchJSON, initChrome), .env.example / .env.dev.example, README.md → Configuration, AGENTS.md → Non-negotiable rules
Objective
Every page and every /api route except a small, explicit public list requires a signed-in
admin session; the admin password is seeded from DASH_ADMIN_PASSWORD on first start, is
changeable from a new /settings page, and does not have to be changed. The server refuses
to start when it has neither a stored password nor the env var — there is no way to run this
thing open by accident.
Derived scope (from the operator, in chat)
- Fail closed.
DASH_ADMIN_PASSWORDunset and no stored verifier → the process exits with an operator-readable reason.testpassis the local-testing password (dev config + the hermetic suite), never a production fallback. /api/healthstays public on purpose — the ContainerfileHEALTHCHECKand./scripts/dev-server.sh statusdepend on it. Its shape must not change, and nothing new may be added to it without treating it as a public endpoint.- Most secure, least likely to break was the instruction for sessions/CSRF → stdlib
hashlib/hmaconly: PBKDF2-HMAC-SHA256 verifier, HMAC-signed stateless session cookie, a session-bound CSRF token on every write, and a default-deny gate. No new dependency, so noPLAN.mdanchor moves (itsdangerous+ StarletteSessionMiddlewarewas the alternative the operator declined by asking for "least likely to break").
Dependencies
01_severity_language…05_ups_hero_battery_glyph(complete) — the pages, the shared.mastheadchrome andDash.fetchJSONthat this phase wraps; nothing here may change what they render once a session exists.06_system_page_gpu_verdicts,07_llm_monitor_style_pages,08_events_paging_and_copy,09_disks_smartctl(todo) — no dependency in either direction. This phase touchesapp.py,config.py,store.py,app.jsand new files; those phases touchseverity.py,sources/*, page scripts. If they run first, re-read thecreate_app(call sites intests/before task 03 — that is the only overlap that can conflict.10_dev_direct_dial(todo) — no functional dependency, four shared files: it rewrites.env.dev.example, edits.env.example's NUT section, rewrites the local.env.dev, and rewrites the dev sections ofREADME.md/AGENTS.md. Run either order, with two rules: apply this phase's.env*additions by appending to whatever the file says at the time (never restore aDEV_TUNNELS/DEV_FORWARDSline that 10 deleted), and never delete aDASH_ADMIN_PASSWORDline 10 touches. Whichever phase runs second re-checks the diff of the other's doc edits rather than overwriting them.
Tasks
01_auth_primitives.md—src/dashboards/auth.py: password hash/verify (self-describing PBKDF2), session mint/read, CSRF token, login throttle, origin check. Pure logic, no HTTP.02_verifier_config_fail_closed.md—store.get_meta/set_meta/delete_meta, the fourDASH_*auth settings, andensure_admin_verifier()wired into the lifespan so a dashboard with no password anywhere refuses to start.03_session_gate_and_public_surface.md— the default-deny middleware,/loginroute,/api/auth/login|logout|status, CSRF enforcement on every write,fetchJSONattaching the token and bouncing to/loginon 401, plus the test that enumerates every route and demands a session.04_change_password.md—POST /api/auth/password: current-password check, verifier replace, session-secret rotation (every other browser is out), event row, throttle reset.05_login_page.md—pages/login.html+static/page-login.js: password field, inline accessible errors, lockout message, no CDN, theme parity with the other pages.06_settings_page.md—/settings: change-password form, sign-out, session age; a⚙link in the masthead of all four dashboards.07_dev_config_docs_browser_verification.md— env templates, the operator's gitignored.env.dev, README + AGENTS.md, then live browser proof with screenshots and the leak checks (/api/summary401 anonymous,/system303,/api/health200).
Testing & Quality
- Unit:
tests/test_auth.pyfor every primitive — wrong password, tampered cookie, expired cookie, cookie signed with a rotated secret, CSRF token from another session, throttle backoff and reset,needs_rehashwhen the configured cost rises. - Integration:
tests/test_auth_api.py— anonymous 401 on every/api/*data route, 303 →/login?next=…for HTML, login success/malfailure/lockout over HTTP, logout kills the cookie, change-password invalidates the old session and the other browser's,POST /api/ups/commandrefuses without the CSRF header even with a valid session. - The default-deny guard:
test_every_route_that_is_not_public_requires_a_sessionwalkscreate_app().routesand asserts nothing outside the public list answers anonymously. A route added by a future phase that forgets the gate fails here, not in production. tests/test_api.pykeeps passing unmodified in intent:build_client()logs in once withtestpass, so the ~200 existing HTTP assertions still assert what they assert. The nine directcreate_app(Settings(...))call sites intests/get the same password.- Coverage: >90% on new/modified code (
auth.pyshould be near 100%); the overall gate (94%, currently ~98%) must hold. - The phase is "Complete" only when the entire project test suite passes 100% (every test
in the project, not just this phase's), including
uv run pytest -m live --live.
Documentation & Consistency
- The final pass reviews
README.mdandAGENTS.mdin full against the project as it stands after this phase — fixing every inconsistency found, including pre-existing ones not caused by this phase. - Tasks update
README.md/AGENTS.mdimmediately when their own changes are user-visible (setup, commands, configuration, features, conventions). This phase is user-visible in the strongest sense: the first thing an operator meets is a login prompt.
Completion Criteria
curl -si http://127.0.0.1:8090/system→303to/login?next=/system;curl -s http://127.0.0.1:8090/api/summary→401 {"ok": false, …};curl -s http://127.0.0.1:8090/api/config→401;curl -s http://127.0.0.1:8090/api/health→200(the one deliberate public data route)- login with
testpassin a browser reaches/; a wrong password shows an inline error and, after 5 failures, the lockout message withRetry-Afterhonoured /settingschanges the password; the same browser is signed out and gets back in with the new one; a second browser (or a saved cookie) is rejected with 401- with
DASH_ADMIN_PASSWORDunset and noauth.verifierrow,uv run dashboardsexits non-zero with a message naming the variable and the reset command ./scripts/dev-server.sh statusand the ContainerfileHEALTHCHECKstill work unauthenticated (/api/healthunchanged)uv run pytestgreen,uv run pytest --cov --cov-fail-under=94green,uv run ruff check src tests agent && uv run pyrightclean,bash .agents/validate.shgreen- screenshots in
data/logs/:auth-login.png,auth-login-rejected.png,auth-locked-out.png,auth-signed-in-overview.png,auth-settings.png(AGENTS.md invariant 10 — a UI change is screenshotted, not claimed) README.mddocuments the four newDASH_*variables, the reset procedure, and the fact that a UI change wins over the env var;AGENTS.mdcarries the new invariant naming the public surface; both are accurate and consistent with the whole project (final pass)- no behavior change in completed phases: every payload, hero card, chart and gate the five completed phases shipped still renders identically once signed in