# 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**