Files
dashboards/.agents/phases/todo/11_admin_login/00_phase.md
T
ducoterra 046dc57b26 chore(agent): add phase 11 — single-admin login for the dashboards
Eight tasks to put a session gate in front of every page and API route: the auth primitives
(PBKDF2 verifier, HMAC-signed stateless session, session-bound CSRF token, login throttle,
origin check) in a new `auth.py`, the verifier in the store plus fail-closed startup, the
default-deny middleware with an explicit public list, `POST /api/auth/password` with session
rotation, a login page, a `/settings` page, and the dev-config/docs/browser verification pass.

Stdlib hashlib/hmac only — no new dependency, so no PLAN.md anchor moves. `/api/health`
stays public on purpose (the Containerfile HEALTHCHECK and dev-server.sh depend on it), and
one test enumerates every route and demands a session.

LLM-Generated: true
2026-10-17 12:27:40 -04:00

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_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