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:
@@ -0,0 +1,306 @@
|
||||
"""Where the admin credential lives, and what happens when it does not (phase 11 task 02).
|
||||
|
||||
`auth.load_auth_state` is the only code that turns a deployment's environment plus the durable
|
||||
`meta` rows into the state every request is checked against, and it owns three decisions that
|
||||
are easy to get subtly wrong and impossible to see in a screenshot:
|
||||
|
||||
* the environment **seeds** a password once — after the first boot the stored verifier wins, so
|
||||
`/settings` can change it without a redeploy and a restart cannot roll it back;
|
||||
* a server with neither a stored verifier nor `DASH_ADMIN_PASSWORD` **refuses to start**
|
||||
(fail closed) rather than serving the fleet open behind a public domain;
|
||||
* changing the password rotates the signing secret, which is what signs out every *other*
|
||||
browser when there is no session table to walk.
|
||||
|
||||
The store here is a dict. That is the point of the `MetaStore` protocol: the security-relevant
|
||||
branch has no SQLite in it, so these tests assert the decisions rather than the schema — and
|
||||
one test at the end puts the real `SampleStore` back underneath to prove the durability the
|
||||
protocol is written against.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from dataclasses import FrozenInstanceError
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
import pytest
|
||||
|
||||
from dashboards import auth
|
||||
from dashboards.store import SampleStore
|
||||
|
||||
#: Nothing in this file is about the cost; `tests/test_auth.py` pins the 600 000 default.
|
||||
CHEAP = 1_000
|
||||
|
||||
PASSWORD = "testpass"
|
||||
|
||||
|
||||
class FakeMeta:
|
||||
"""The three-method shape `load_auth_state` asks for, with no database in it."""
|
||||
|
||||
def __init__(self, rows: dict[str, str] | None = None) -> None:
|
||||
self.rows: dict[str, str] = dict(rows or {})
|
||||
|
||||
def get_meta(self, key: str) -> str | None:
|
||||
return self.rows.get(key)
|
||||
|
||||
def set_meta(self, key: str, value: str) -> None:
|
||||
self.rows[key] = value
|
||||
|
||||
def delete_meta(self, key: str) -> int:
|
||||
return 1 if self.rows.pop(key, None) is not None else 0
|
||||
|
||||
|
||||
def load(store: FakeMeta, *, admin_password: str = PASSWORD, **kwargs: Any) -> auth.AuthState:
|
||||
return auth.load_auth_state(store, admin_password=admin_password, iterations=CHEAP,
|
||||
**kwargs)
|
||||
|
||||
|
||||
def seed(*, admin_password: str = PASSWORD) -> tuple[FakeMeta, auth.AuthState]:
|
||||
"""A store that has already booted once, and the state that boot produced."""
|
||||
store = FakeMeta()
|
||||
return store, load(store, admin_password=admin_password)
|
||||
|
||||
|
||||
# --- first boot: the environment seeds ---------------------------------------------------- #
|
||||
|
||||
|
||||
def test_first_boot_seeds_the_verifier_and_persists_the_secret() -> None:
|
||||
store = FakeMeta()
|
||||
state = load(store)
|
||||
|
||||
assert state.seeded is True, "this start wrote the verifier row"
|
||||
assert state.env_ignored is False, "there was nothing stored to ignore the env with"
|
||||
assert auth.verify_password(PASSWORD, store.rows[auth.META_VERIFIER]) is True
|
||||
assert store.rows[auth.META_SESSION_SECRET] == state.secret
|
||||
assert state.iterations == CHEAP
|
||||
assert state.session_hours == auth.SESSION_HOURS
|
||||
|
||||
|
||||
def test_the_seed_is_stored_as_a_verifier_and_never_as_a_password() -> None:
|
||||
"""`AGENTS.md` invariant 6 on the way *in*: the row is PBKDF2 text, not the secret.
|
||||
|
||||
The completion criterion for the task is that `grep -c testpass` on a real database is 0;
|
||||
this is the same claim one layer up, where a regression would show.
|
||||
"""
|
||||
store = FakeMeta()
|
||||
state = load(store)
|
||||
for value in [*store.rows.values(), state.verifier, state.secret]:
|
||||
assert PASSWORD not in value
|
||||
assert store.rows[auth.META_VERIFIER].startswith(f"{auth.PBKDF2_ALGO}${CHEAP}$")
|
||||
|
||||
|
||||
def test_the_stored_rows_survive_a_reopen_of_the_real_database(tmp_path: Path) -> None:
|
||||
"""The whole reason this is in SQLite and not in process memory.
|
||||
|
||||
A restart must not re-seed, must not mint a new signing secret (that would sign every
|
||||
browser out on every deploy), and must not need `DASH_ADMIN_PASSWORD` any more.
|
||||
"""
|
||||
path = tmp_path / "dashboards.sqlite3"
|
||||
first_store = SampleStore(path)
|
||||
try:
|
||||
first = auth.load_auth_state(first_store, admin_password=PASSWORD, iterations=CHEAP)
|
||||
assert first.seeded is True
|
||||
finally:
|
||||
first_store.close()
|
||||
|
||||
reopened = SampleStore(path)
|
||||
try:
|
||||
second = auth.load_auth_state(reopened, admin_password="", iterations=CHEAP)
|
||||
finally:
|
||||
reopened.close()
|
||||
|
||||
assert second.seeded is False
|
||||
assert second.verifier == first.verifier
|
||||
assert second.secret == first.secret, "a new secret on every restart would log everyone out"
|
||||
assert auth.verify_password(PASSWORD, second.verifier) is True
|
||||
|
||||
|
||||
# --- the stored verifier wins ------------------------------------------------------------- #
|
||||
|
||||
|
||||
def test_a_stored_verifier_beats_a_conflicting_env_password(caplog: pytest.LogCaptureFixture) -> None:
|
||||
store, _first = seed()
|
||||
original = store.rows[auth.META_VERIFIER]
|
||||
|
||||
with caplog.at_level(logging.WARNING, logger="dashboards.auth"):
|
||||
second = load(store, admin_password="some-other-password")
|
||||
|
||||
assert second.env_ignored is True
|
||||
assert second.seeded is False
|
||||
assert store.rows[auth.META_VERIFIER] == original, "the env must not overwrite the row"
|
||||
assert second.verifier == original
|
||||
assert auth.verify_password(PASSWORD, second.verifier) is True
|
||||
assert auth.verify_password("some-other-password", second.verifier) is False
|
||||
|
||||
|
||||
def test_the_ignored_env_var_is_reported_once_in_fixed_words(caplog: pytest.LogCaptureFixture) -> None:
|
||||
"""The operator who set `DASH_ADMIN_PASSWORD` and saw nothing happen gets one sentence.
|
||||
|
||||
The sentence must name the variable and the row; it must not name the value, the hash, or
|
||||
any prefix of either — that line ends up in a container log, which is exactly where
|
||||
invariant 6 says a credential must not go.
|
||||
"""
|
||||
store, _first = seed()
|
||||
with caplog.at_level(logging.WARNING, logger="dashboards.auth"):
|
||||
state = load(store, admin_password="ignored-now")
|
||||
|
||||
messages = [rec.getMessage() for rec in caplog.records if rec.name == "dashboards.auth"]
|
||||
assert messages == [auth.ENV_IGNORED_MESSAGE], "exactly one line, at startup"
|
||||
line = messages[0]
|
||||
assert "DASH_ADMIN_PASSWORD is ignored" in line
|
||||
assert auth.META_VERIFIER in line
|
||||
for secret in (PASSWORD, "ignored-now", state.verifier, state.secret):
|
||||
assert secret not in line
|
||||
|
||||
|
||||
def test_the_warning_is_not_written_when_the_env_actually_seeded(caplog: pytest.LogCaptureFixture) -> None:
|
||||
"""`seeded` and `env_ignored` are mutually exclusive, and only the second one speaks."""
|
||||
with caplog.at_level(logging.WARNING, logger="dashboards.auth"):
|
||||
state = load(FakeMeta())
|
||||
assert state.env_ignored is False
|
||||
assert [r for r in caplog.records if r.name == "dashboards.auth"] == []
|
||||
|
||||
|
||||
def test_the_environment_variable_can_be_dropped_after_first_boot() -> None:
|
||||
"""The operator's wording: "configured initially with an env var, not required to change".
|
||||
|
||||
Second boot with no seed at all reuses the row, reports neither `seeded` nor
|
||||
`env_ignored`, and stays quiet.
|
||||
"""
|
||||
store, first = seed()
|
||||
second = load(store, admin_password="")
|
||||
assert second.seeded is False
|
||||
assert second.env_ignored is False
|
||||
assert second.verifier == first.verifier
|
||||
|
||||
|
||||
def test_a_blank_stored_verifier_counts_as_no_verifier_at_all() -> None:
|
||||
"""A row of whitespace is not a password — reseeding it is the only way back in."""
|
||||
store = FakeMeta({auth.META_VERIFIER: " \n "})
|
||||
state = load(store)
|
||||
assert state.seeded is True
|
||||
assert auth.verify_password(PASSWORD, store.rows[auth.META_VERIFIER]) is True
|
||||
|
||||
|
||||
# --- fail closed -------------------------------------------------------------------------- #
|
||||
|
||||
|
||||
def test_no_verifier_and_no_seed_refuses_to_start_instead_of_running_open() -> None:
|
||||
store = FakeMeta()
|
||||
with pytest.raises(RuntimeError) as caught:
|
||||
load(store, admin_password="")
|
||||
|
||||
message = str(caught.value)
|
||||
assert "DASH_ADMIN_PASSWORD" in message, "names the variable to set"
|
||||
assert auth.META_VERIFIER in message, "names the row that would have been used"
|
||||
assert auth.META_SESSION_SECRET in message
|
||||
assert "DELETE FROM meta WHERE key IN" in message, "gives the reset command"
|
||||
assert "sqlite3" in message
|
||||
assert store.rows == {}, "refusing means writing nothing, not writing and then complaining"
|
||||
|
||||
|
||||
def test_the_refusal_names_the_database_the_operator_has_to_reset() -> None:
|
||||
"""`<db>` is a placeholder nobody can run; the real path is what makes the fix copy-paste."""
|
||||
with pytest.raises(RuntimeError) as caught:
|
||||
auth.load_auth_state(FakeMeta(), admin_password="", db_path="/var/lib/dashboards/d.db")
|
||||
assert "/var/lib/dashboards/d.db" in str(caught.value)
|
||||
|
||||
with pytest.raises(RuntimeError) as bare:
|
||||
auth.load_auth_state(FakeMeta(), admin_password="")
|
||||
assert "<db>" in str(bare.value), "no path known, and no invented one either"
|
||||
|
||||
|
||||
def test_the_refusal_carries_no_password_and_no_verifier() -> None:
|
||||
"""The message reaches a terminal and a container log; it may carry instructions only."""
|
||||
store = FakeMeta()
|
||||
with pytest.raises(RuntimeError) as caught:
|
||||
load(store, admin_password="")
|
||||
assert PASSWORD not in str(caught.value)
|
||||
|
||||
|
||||
# --- the signing secret -------------------------------------------------------------------- #
|
||||
|
||||
|
||||
def test_a_usable_session_secret_is_reused_as_is() -> None:
|
||||
"""A restart must not rotate the key, or every browser is signed out for nothing."""
|
||||
existing = auth.new_secret()
|
||||
# a second boot, not a first: the verifier row exists and the secret is what is under test
|
||||
store = FakeMeta({auth.META_VERIFIER: auth.hash_password(PASSWORD, iterations=CHEAP),
|
||||
auth.META_SESSION_SECRET: existing})
|
||||
state = load(store, admin_password="")
|
||||
assert state.secret == existing
|
||||
|
||||
|
||||
@pytest.mark.parametrize("corrupt", [
|
||||
"",
|
||||
" ",
|
||||
"deadbeef", # hex, but a 16-bit key is not what we sign a 30-day cookie with
|
||||
"z" * 64, # long enough, and not hex
|
||||
"0" * 63, # one character short of 256 bits
|
||||
])
|
||||
def test_a_corrupt_session_secret_is_regenerated_and_persisted(corrupt: str) -> None:
|
||||
"""The chosen behaviour, stated: replace it and write the replacement.
|
||||
|
||||
The effect on the operator is "every browser is asked to sign in again" — a login prompt,
|
||||
not a lockout — which is the safe direction for a damaged row. Refusing to start instead
|
||||
would turn a corrupt cell into an outage of a dashboard that still has a working password.
|
||||
"""
|
||||
store, first = seed()
|
||||
store.rows[auth.META_SESSION_SECRET] = corrupt
|
||||
|
||||
state = load(store, admin_password="")
|
||||
|
||||
assert state.secret != corrupt
|
||||
assert state.secret == store.rows[auth.META_SESSION_SECRET], "the replacement is durable"
|
||||
assert len(state.secret) == auth.SECRET_MIN_BYTES * 2
|
||||
bytes.fromhex(state.secret) # raises if the replacement is not the format we mint
|
||||
assert state.verifier == first.verifier, "the password is untouched"
|
||||
# and the state is usable: a session minted from it reads back
|
||||
token = auth.mint_session(state.secret, now=1_700_000_000.0)
|
||||
assert auth.read_session(state.secret, token, now=1_700_000_000.0) is not None
|
||||
|
||||
|
||||
# --- changing the password ----------------------------------------------------------------- #
|
||||
|
||||
|
||||
def test_update_verifier_replaces_both_rows_and_returns_the_new_state() -> None:
|
||||
store, before = seed()
|
||||
old_token = auth.mint_session(before.secret, now=1_700_000_000.0)
|
||||
|
||||
after = auth.update_verifier(store, before, "horse battery staple")
|
||||
|
||||
assert after.secret != before.secret
|
||||
assert store.rows[auth.META_SESSION_SECRET] == after.secret
|
||||
assert store.rows[auth.META_VERIFIER] == after.verifier
|
||||
assert auth.verify_password("horse battery staple", after.verifier) is True
|
||||
assert auth.verify_password(PASSWORD, after.verifier) is False
|
||||
assert after.session_hours == before.session_hours
|
||||
assert after.iterations == before.iterations
|
||||
assert after.seeded is False and after.env_ignored is False
|
||||
# the browser that was not in front of the form is out: its cookie is signed by the old key
|
||||
assert auth.read_session(after.secret, old_token, now=1_700_000_000.0) is None
|
||||
# and the new state can still mint one that reads
|
||||
fresh = auth.mint_session(after.secret, now=1_700_000_000.0)
|
||||
assert auth.read_session(after.secret, fresh, now=1_700_000_000.0) is not None
|
||||
|
||||
|
||||
@pytest.mark.parametrize("blank", ["", " ", "\t", "\n \r"])
|
||||
def test_update_verifier_refuses_to_store_an_empty_password(blank: str) -> None:
|
||||
store, before = seed()
|
||||
with pytest.raises(ValueError) as caught:
|
||||
auth.update_verifier(store, before, blank)
|
||||
|
||||
assert store.rows[auth.META_VERIFIER] == before.verifier
|
||||
assert store.rows[auth.META_SESSION_SECRET] == before.secret
|
||||
# fixed text: nothing the caller typed is echoed back into a log or a traceback
|
||||
assert str(caught.value) == "a new password must not be empty or whitespace"
|
||||
|
||||
|
||||
def test_the_state_is_a_frozen_bundle_of_credentials() -> None:
|
||||
"""Nothing may mutate a live `AppContext.auth`, and nothing may treat it as display data."""
|
||||
_store, state = seed()
|
||||
with pytest.raises(FrozenInstanceError):
|
||||
state.verifier = "swapped" # type: ignore[misc]
|
||||
assert {"verifier", "secret", "session_hours", "iterations", "seeded", "env_ignored"} == {
|
||||
f.name for f in state.__dataclass_fields__.values()}
|
||||
Reference in New Issue
Block a user