phase: 11_admin_login

**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`.
This commit is contained in:
2026-10-19 07:45:44 -04:00
parent eb4788cba1
commit 1734d81e48
75 changed files with 6941 additions and 121 deletions
@@ -0,0 +1,110 @@
# 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_PASSWORD` unset **and** no stored verifier → the process exits
with an operator-readable reason. `testpass` is the local-testing password (dev config +
the hermetic suite), never a production fallback.
- **`/api/health` stays public** on purpose — the Containerfile `HEALTHCHECK` and
`./scripts/dev-server.sh status` depend 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`/`hmac` only: 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 no `PLAN.md` anchor moves (`itsdangerous` + Starlette `SessionMiddleware` was 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
`.masthead` chrome and `Dash.fetchJSON` that 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 touches
`app.py`, `config.py`, `store.py`, `app.js` and new files; those phases touch
`severity.py`, `sources/*`, page scripts. If they run first, re-read the `create_app(` call
sites in `tests/` 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 of `README.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 a `DEV_TUNNELS`/`DEV_FORWARDS` line that 10 deleted), and never delete a
`DASH_ADMIN_PASSWORD` line 10 touches. Whichever phase runs second re-checks the diff of the
other's doc edits rather than overwriting them.
## Tasks
1. `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.
2. `02_verifier_config_fail_closed.md` — `store.get_meta`/`set_meta`/`delete_meta`, the four `DASH_*` auth settings, and `ensure_admin_verifier()` wired into the lifespan so a dashboard with no password anywhere refuses to start.
3. `03_session_gate_and_public_surface.md` — the default-deny middleware, `/login` route, `/api/auth/login|logout|status`, CSRF enforcement on every write, `fetchJSON` attaching the token and bouncing to `/login` on 401, plus the test that enumerates **every** route and demands a session.
4. `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.
5. `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.
6. `06_settings_page.md` — `/settings`: change-password form, sign-out, session age; a `⚙` link in the masthead of all four dashboards.
7. `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/summary` 401 anonymous, `/system` 303, `/api/health` 200).
## Testing & Quality
- Unit: `tests/test_auth.py` for every primitive — wrong password, tampered cookie, expired
cookie, cookie signed with a rotated secret, CSRF token from another session, throttle
backoff and reset, `needs_rehash` when 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/command` refuses without the CSRF header even **with** a valid session.
- The default-deny guard: `test_every_route_that_is_not_public_requires_a_session` walks
`create_app().routes` and 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.py` keeps passing unmodified in intent: `build_client()` logs in once with
`testpass`, so the ~200 existing HTTP assertions still assert what they assert. The nine
direct `create_app(Settings(...))` call sites in `tests/` get the same password.
- Coverage: **>90%** on new/modified code (`auth.py` should 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.md` and `AGENTS.md` in 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.md` immediately 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` → `303` to `/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 `testpass` in a browser reaches `/`; a wrong password shows an inline error
and, after 5 failures, the lockout message with `Retry-After` honoured
- [ ] `/settings` changes 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_PASSWORD` unset **and** no `auth.verifier` row, `uv run dashboards`
exits non-zero with a message naming the variable and the reset command
- [ ] `./scripts/dev-server.sh status` and the Containerfile `HEALTHCHECK` still work
unauthenticated (`/api/health` unchanged)
- [ ] `uv run pytest` green, `uv run pytest --cov --cov-fail-under=94` green,
`uv run ruff check src tests agent && uv run pyright` clean, `bash .agents/validate.sh`
green
- [ ] 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.md` documents the four new `DASH_*` variables, the reset procedure, and the fact
that a UI change wins over the env var; `AGENTS.md` carries 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**