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