Manage the password in the app; close remaining audit findings

Password management moves out of the CLI entirely. The credential is now a
salted scrypt hash in the database (so it survives rebuilds, living in the /data
volume) rather than an environment variable; PARTS_PASSWORD is demoted to a
bootstrap value that stops working the moment a password is set in the UI. Every
token carries a session epoch, so changing the password — or "sign out other
devices" — invalidates outstanding cookies while keeping the browser that made
the change signed in. A banner nags until the handed-over password is replaced.
app/admin.py remains for the one case the UI cannot cover, a forgotten password.

Audit findings:
- Taxonomy update and delete scanned affected parts before taking the write
  lock, so a concurrent rename could leave the search index matching a name the
  UI no longer showed. All four routes now lock first; removing the lock again
  makes the new test fail exactly that way.
- History of a missing part returned 200 with an empty list; now 404.
- Infinity and NaN passed ge=0 and failed at the database. They are rejected as
  422 now, and the validation error handler no longer chokes trying to echo a
  non-finite value back.

Checks go from 134 to 181.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Jay
2026-08-24 10:31:06 -04:00
parent 7bdf276342
commit ab82b5e9a9
11 changed files with 626 additions and 49 deletions
+65
View File
@@ -0,0 +1,65 @@
"""Recovery CLI.
Everyday password changes happen in the UI. This exists for the one case the UI
cannot help with — a forgotten password — and for the initial handover.
docker exec -it parts python -m app.admin set-password
docker exec parts python -m app.admin show-status
"""
import getpass
import sys
from . import auth, db
def set_password(argv):
password = argv[0] if argv else None
if password is None:
password = getpass.getpass("New password: ")
if password != getpass.getpass("Repeat: "):
print("Passwords did not match.", file=sys.stderr)
return 1
problem = auth.password_problem(password)
if problem:
print(problem, file=sys.stderr)
return 1
with db.session() as conn:
auth.set_password(conn, password)
print("Password set. All existing sessions have been signed out.")
return 0
def clear_password(_argv):
"""Fall back to the PARTS_PASSWORD environment variable again."""
with db.session() as conn:
conn.execute("DELETE FROM settings WHERE key = ?", (auth.PASSWORD_KEY,))
auth.bump_epoch(conn)
print("Stored password cleared; PARTS_PASSWORD from the environment is live again.")
print("All existing sessions have been signed out.")
return 0
def show_status(_argv):
with db.session() as conn:
stored = auth.stored_hash(conn)
print("password source :", "database (set in the app)" if stored else "PARTS_PASSWORD env var")
print("session epoch :", auth.current_epoch(conn))
print("auth enabled :", auth.auth_enabled())
return 0
COMMANDS = {"set-password": set_password, "clear-password": clear_password, "show-status": show_status}
def main(argv=None):
argv = list(sys.argv[1:] if argv is None else argv)
if not argv or argv[0] not in COMMANDS:
print("usage: python -m app.admin {" + "|".join(COMMANDS) + "}", file=sys.stderr)
return 2
db.init()
return COMMANDS[argv[0]](argv[1:])
if __name__ == "__main__":
raise SystemExit(main())