Spaces:
Running
Running
deploy: main@3f5fd0f (works + reading + passage/multi-span notes; superset of f1c0d35)
bb36f39 verified | """DB-backed user store for the low-privilege ``reader`` role. | |
| This is the store for the preview / Reading-Room accounts an admin creates and | |
| manages from the UI. It is **deliberately separate** from the env/TOML | |
| credentials in :mod:`src.lib.auth.repo` (``get_credentials``): | |
| - Admins (and any bootstrap ``viewer``) come from ``AUTH_CREDENTIALS_TOML`` / | |
| Streamlit secrets / ``auth/credentials.toml`` — the bootstrap layer. A bad | |
| or empty ``app_users`` table can therefore never lock the operator out. | |
| - The create path forces the role to ``reader`` (see :func:`create_app_user`) | |
| — no caller can mint an admin through creation. Promotion to ``admin``/ | |
| ``viewer`` happens ONLY through :func:`set_app_user_role`, which is gated | |
| by the admin router's last-admin lock and audit log. The stored role is | |
| now honored at login (see :func:`get_app_user_for_auth`); safety comes | |
| from bootstrap-first ordering in :func:`src.lib.auth.repo.authenticate`, | |
| not from forcing ``reader`` here. | |
| Passwords are PBKDF2 hashes (:func:`src.lib.auth.passwords.hash_password`) — | |
| never plaintext. ``authenticate()`` in ``repo.py`` checks the env/TOML source | |
| FIRST and only falls back to this table for usernames not found there. | |
| Schema lives in ``src/stage1_inventory/schema.py`` (``app_users``); the table | |
| is created via ``CREATE TABLE IF NOT EXISTS`` by ``init_schema``, so these | |
| accessors never run DDL themselves. | |
| """ | |
| from __future__ import annotations | |
| from dataclasses import dataclass | |
| from src.stage1_inventory.db import connect | |
| from .models import User | |
| from .passwords import hash_password | |
| class AppUser: | |
| """One ``app_users`` row, projected for the admin management UI. | |
| Never carries the password hash — the admin endpoints must not put it on | |
| the wire. Use :func:`get_app_user_for_auth` for the auth path that needs | |
| the stored hash. | |
| """ | |
| username: str | |
| role: str | |
| display_name: str | None | |
| disabled: bool | |
| created_at: str | None | |
| _VALID_ROLES = ("reader", "viewer", "admin") | |
| def _normalize_username(username: str | None) -> str: | |
| """Lowercase + strip, mirroring the env/TOML credential rule in repo.py.""" | |
| return (username or "").strip().lower() | |
| def list_app_users() -> list[AppUser]: | |
| """Every DB-backed account, newest first. No password hashes.""" | |
| with connect() as conn: | |
| rows = conn.execute( | |
| """ | |
| SELECT username, role, display_name, disabled, created_at | |
| FROM app_users | |
| ORDER BY created_at DESC, username | |
| """ | |
| ).fetchall() | |
| return [ | |
| AppUser( | |
| username=r["username"], | |
| role=r["role"], | |
| display_name=r["display_name"], | |
| disabled=bool(r["disabled"]), | |
| created_at=r["created_at"], | |
| ) | |
| for r in rows | |
| ] | |
| def app_user_exists(username: str) -> bool: | |
| uname = _normalize_username(username) | |
| if not uname: | |
| return False | |
| with connect() as conn: | |
| row = conn.execute( | |
| "SELECT 1 FROM app_users WHERE username = ?", (uname,) | |
| ).fetchone() | |
| return row is not None | |
| def create_app_user( | |
| username: str, | |
| password: str, | |
| display_name: str | None = None, | |
| ) -> AppUser: | |
| """Insert a new reader account and return its (hash-free) projection. | |
| The username is lowercased like the env/TOML credentials. Empty username | |
| or password is rejected with ``ValueError``; a duplicate username raises | |
| ``ValueError`` too (the admin router maps that to a 409). The password is | |
| hashed with PBKDF2 before storage — never plaintext. | |
| The role is always ``reader`` — this is a reader-only table by construction. | |
| The ``role`` parameter has been removed so no caller can accidentally or | |
| maliciously create an admin/viewer row via this path. | |
| """ | |
| import sqlite3 as _sqlite3 | |
| uname = _normalize_username(username) | |
| if not uname: | |
| raise ValueError("username must not be empty") | |
| if not password: | |
| raise ValueError("password must not be empty") | |
| if app_user_exists(uname): | |
| raise ValueError(f"user {uname!r} already exists") | |
| pw_hash = hash_password(password) | |
| display = (display_name or "").strip() or None | |
| try: | |
| with connect() as conn: | |
| conn.execute( | |
| """ | |
| INSERT INTO app_users (username, password_hash, role, display_name, disabled) | |
| VALUES (?, ?, 'reader', ?, 0) | |
| """, | |
| (uname, pw_hash, display), | |
| ) | |
| conn.commit() | |
| except _sqlite3.IntegrityError as exc: | |
| # Local SQLite backend: duplicate primary key raises IntegrityError. | |
| if app_user_exists(uname): | |
| raise ValueError(f"user {uname!r} already exists") from exc | |
| raise | |
| except Exception as exc: | |
| # Production (Turso/libsql) backend: the libsql client does NOT | |
| # subclass sqlite3 — a duplicate primary-key INSERT raises a bare | |
| # builtins.ValueError with message "UNIQUE constraint failed: …". | |
| # Catching sqlite3.IntegrityError alone misses this, so the raw SQL | |
| # error text would propagate to the router and be leaked to the client | |
| # as a 400 rather than the intended 409 USER_EXISTS (no SQL internals). | |
| # We match on the message instead of the exception class. | |
| msg = str(exc).lower() | |
| if "unique constraint" in msg or "already exists" in msg: | |
| if app_user_exists(uname): | |
| raise ValueError(f"user {uname!r} already exists") from exc | |
| # Concurrent-delete race: the uniqueness signal fired but the row | |
| # is gone by re-check time. Still a conflict — raise a scrubbed | |
| # message so no SQL internals (table/column names) reach the client. | |
| raise ValueError("could not create the account due to a conflict") from exc | |
| raise | |
| return AppUser( | |
| username=uname, | |
| role="reader", | |
| display_name=display, | |
| disabled=False, | |
| created_at=None, | |
| ) | |
| def delete_app_user(username: str) -> None: | |
| """Hard-delete an account. Idempotent (missing user is a no-op).""" | |
| uname = _normalize_username(username) | |
| if not uname: | |
| return | |
| with connect() as conn: | |
| conn.execute("DELETE FROM app_users WHERE username = ?", (uname,)) | |
| conn.commit() | |
| def set_app_user_disabled(username: str, disabled: bool) -> None: | |
| """Enable/disable an account. A disabled account is refused at login.""" | |
| uname = _normalize_username(username) | |
| if not uname: | |
| return | |
| with connect() as conn: | |
| conn.execute( | |
| "UPDATE app_users SET disabled = ? WHERE username = ?", | |
| (1 if disabled else 0, uname), | |
| ) | |
| conn.commit() | |
| def reset_app_user_password(username: str, password: str) -> None: | |
| """Replace an account's password with a fresh PBKDF2 hash.""" | |
| uname = _normalize_username(username) | |
| if not uname: | |
| return | |
| if not password: | |
| raise ValueError("password must not be empty") | |
| with connect() as conn: | |
| conn.execute( | |
| "UPDATE app_users SET password_hash = ? WHERE username = ?", | |
| (hash_password(password), uname), | |
| ) | |
| conn.commit() | |
| def get_app_user_for_auth(username: str) -> User | None: | |
| """Resolve an ENABLED DB account to a :class:`User` for the login path. | |
| Returns ``None`` for unknown usernames AND for disabled accounts (so a | |
| suspended account can't log in). The returned ``User.password`` is the | |
| stored PBKDF2 hash — :func:`src.lib.auth.repo.authenticate` runs the | |
| constant-time :func:`verify_password` against it. | |
| The stored DB role is now honored (validated against | |
| :data:`_VALID_ROLES`, defaulting to ``reader`` for an unknown/garbage | |
| value). Safety no longer comes from forcing ``reader`` here; it comes | |
| from (1) bootstrap-first ordering in | |
| :func:`src.lib.auth.repo.authenticate` (an env/TOML admin is always | |
| checked FIRST, so a DB row can never shadow/impersonate it), and (2) the | |
| controlled promote path in the admin router, guarded by the last-admin | |
| lock. A tampered DB row can still write ``role='admin'`` directly in the | |
| table, but it cannot impersonate a bootstrap admin (username collision | |
| resolves to the bootstrap account) and promotion is audited. | |
| """ | |
| uname = _normalize_username(username) | |
| if not uname: | |
| return None | |
| with connect() as conn: | |
| row = conn.execute( | |
| "SELECT username, password_hash, role, display_name, disabled " | |
| "FROM app_users WHERE username = ?", | |
| (uname,), | |
| ).fetchone() | |
| if row is None or bool(row["disabled"]): | |
| return None | |
| role = row["role"] if row["role"] in ("reader", "viewer", "admin") else "reader" | |
| return User( | |
| username=row["username"], | |
| password=row["password_hash"], | |
| role=role, | |
| display_name=row["display_name"], | |
| ) | |
| def set_app_user_role(username: str, role: str) -> None: | |
| """Set an existing account's role. Validates role; no-op on missing user.""" | |
| if role not in _VALID_ROLES: | |
| raise ValueError(f"invalid role {role!r}") | |
| uname = _normalize_username(username) | |
| with connect() as conn: | |
| conn.execute("UPDATE app_users SET role = ? WHERE username = ?", (role, uname)) | |
| conn.commit() | |
| def get_app_user_role(username: str) -> str | None: | |
| """Stored role for a DB account, or None if no such row.""" | |
| uname = _normalize_username(username) | |
| with connect() as conn: | |
| row = conn.execute("SELECT role FROM app_users WHERE username = ?", (uname,)).fetchone() | |
| return row["role"] if row else None | |
| def display_names_for(usernames) -> dict[str, str]: | |
| """Map each username that HAS a non-empty ``app_users.display_name`` to it. | |
| One grouped ``IN (…)`` query for a batch of usernames (the passage-notes | |
| router resolves author/reply display names this way). Usernames absent from | |
| ``app_users`` — every bootstrap admin/viewer, whose credentials live in | |
| env/TOML, not this table — are simply not in the result, so the caller | |
| falls back to the raw username. Rows with a NULL/empty ``display_name`` are | |
| likewise omitted (same fallback). De-dupes and drops blanks first. | |
| """ | |
| unames = sorted({u for u in usernames if u}) | |
| if not unames: | |
| return {} | |
| ph = ",".join("?" for _ in unames) | |
| with connect() as conn: | |
| rows = conn.execute( | |
| f"SELECT username, display_name FROM app_users WHERE username IN ({ph})", | |
| tuple(unames), | |
| ).fetchall() | |
| return {r["username"]: r["display_name"] for r in rows if r["display_name"]} | |
| def count_db_admins() -> int: | |
| """Enabled DB accounts with role='admin'.""" | |
| with connect() as conn: | |
| row = conn.execute( | |
| "SELECT COUNT(*) AS n FROM app_users WHERE role='admin' AND disabled=0" | |
| ).fetchone() | |
| return int(row["n"]) | |