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
This commit is contained in:
@@ -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**
|
||||
Reference in New Issue
Block a user