"""Persistent, operator-authorized first-install setup. Initialize the marker before the main schema: an existing users table identifies an upgraded installation, while a new database must finish the setup wizard. The marker and first administrator are protected by SQLite write transactions. """ from datetime import datetime, timezone import hmac from math import ceil from time import time from typing import Literal from .. import db from ..config import settings from ..security import hash_password, validate_password_policy SetupStep = Literal["administrator", "apps", "preferences", "review"] SETUP_STEPS = ("administrator", "apps", "preferences", "review") BOOTSTRAP_WINDOW_SECONDS = 15 * 60 BOOTSTRAP_IP_ATTEMPTS = 5 BOOTSTRAP_GLOBAL_ATTEMPTS = 30 class SetupUnavailableError(ValueError): """Setup has finished, or another administrator already exists.""" class InvalidSetupTokenError(ValueError): """The operator's setup token was absent or did not match.""" def initialize_setup_state() -> None: """Run once before init_db; subsequent calls preserve progress.""" with db._connect() as conn: conn.execute("BEGIN IMMEDIATE") existing_install = conn.execute( "SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = 'users'" ).fetchone() is not None conn.execute( """CREATE TABLE IF NOT EXISTS installation_setup ( id INTEGER PRIMARY KEY CHECK (id = 1), completed INTEGER NOT NULL CHECK (completed IN (0, 1)), step TEXT NOT NULL, completed_at TEXT )""" ) conn.execute( """CREATE TABLE IF NOT EXISTS installation_setup_attempts ( scope TEXT NOT NULL, key_hash TEXT NOT NULL, occurred_at REAL NOT NULL )""" ) conn.execute( """INSERT OR IGNORE INTO installation_setup (id, completed, step, completed_at) VALUES (1, ?, ?, ?)""", ( int(existing_install), "review" if existing_install else "administrator", datetime.now(timezone.utc).isoformat() if existing_install else None, ), ) def get_setup_state() -> dict: with db._connect() as conn: # Old databases and isolated callers without startup initialization are # already installed. A missing marker must never open public bootstrap. table = conn.execute( "SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = 'installation_setup'" ).fetchone() row = conn.execute( "SELECT completed, step, completed_at FROM installation_setup WHERE id = 1" ).fetchone() if table else None if row is None: return {"completed": True, "step": "review", "completed_at": None} return {"completed": bool(row[0]), "step": row[1], "completed_at": row[2]} def is_setup_required() -> bool: return not get_setup_state()["completed"] def get_public_setup_status() -> dict: required = is_setup_required() return {"setup_required": required, "needs_admin": required and not db.has_admin_user()} def setup_token_configured() -> bool: """Reject missing values and obvious examples, without claiming to measure entropy.""" token = str(getattr(settings, "setup_token", "") or "").strip() placeholder = token.casefold().replace("_", "-") return ( len(token) >= 32 and len(set(token)) > 1 and not placeholder.startswith(("replace-with-", "replace-me", "change-me", "changeme", "your-setup-token")) ) def consume_bootstrap_attempt(client_ip: str) -> int | None: """Atomically reserve one attempt; return Retry-After when limited. The IP is keyed using the existing HMAC helper, never stored in clear text. A shared cap limits distributed attempts and expensive password hashing. """ now = time() cutoff = now - BOOTSTRAP_WINDOW_SECONDS limits = ( ("setup-ip", db._rate_limit_key_hash(client_ip), BOOTSTRAP_IP_ATTEMPTS), ("setup-global", db._rate_limit_key_hash("bootstrap"), BOOTSTRAP_GLOBAL_ATTEMPTS), ) with db._connect() as conn: conn.execute("BEGIN IMMEDIATE") conn.execute( "DELETE FROM installation_setup_attempts WHERE occurred_at < ?", (cutoff,), ) retry_after = 0 for scope, key, maximum in limits: count, oldest = conn.execute( """SELECT COUNT(*), MIN(occurred_at) FROM installation_setup_attempts WHERE scope = ? AND key_hash = ? AND occurred_at >= ?""", (scope, key, cutoff), ).fetchone() if count >= maximum: retry_after = max(retry_after, ceil(BOOTSTRAP_WINDOW_SECONDS - (now - oldest)), 1) if retry_after: return retry_after conn.executemany( "INSERT INTO installation_setup_attempts (scope, key_hash, occurred_at) VALUES (?, ?, ?)", [(scope, key, now) for scope, key, _ in limits], ) return None def bootstrap_administrator(setup_token: str, username: str, password: str) -> None: """Claim fresh setup exactly once using the deployment's setup token.""" expected = str(getattr(settings, "setup_token", "") or "") if not setup_token_configured() or not hmac.compare_digest( setup_token.encode("utf-8"), expected.encode("utf-8") ): raise InvalidSetupTokenError("Invalid setup token.") username = username.strip() if not username or len(username) > 100 or any( character.isspace() or ord(character) < 32 or ord(character) == 127 for character in username ): raise ValueError("Username must contain 1 to 100 characters without spaces or control characters.") if len(password) > 1024: raise ValueError("Password must contain no more than 1024 characters.") password = validate_password_policy(password) if not is_setup_required() or db.has_admin_user(): raise SetupUnavailableError("Initial administrator setup is no longer available.") password_hash = hash_password(password) with db._connect() as conn: conn.execute("BEGIN IMMEDIATE") setup = conn.execute("SELECT completed FROM installation_setup WHERE id = 1").fetchone() admin = conn.execute("SELECT 1 FROM users WHERE LOWER(role) = 'admin' LIMIT 1").fetchone() if setup is None or setup[0] or admin: raise SetupUnavailableError("Initial administrator setup is no longer available.") if any(str(row[0]).strip().casefold() == username.casefold() for row in conn.execute("SELECT username FROM users")): raise SetupUnavailableError("That username already exists.") conn.execute( """INSERT INTO users (username, password_hash, role, auth_provider, created_at) VALUES (?, ?, 'admin', 'local', ?)""", (username, password_hash, datetime.now(timezone.utc).isoformat()), ) conn.execute("UPDATE installation_setup SET step = 'apps' WHERE id = 1") def update_setup_step(step: SetupStep) -> dict: if step not in SETUP_STEPS: raise ValueError("Invalid setup step.") if not is_setup_required(): return get_setup_state() with db._connect() as conn: conn.execute( "UPDATE installation_setup SET step = ? WHERE id = 1 AND completed = 0", (step,) ) return get_setup_state() def complete_setup() -> dict: if not is_setup_required(): return get_setup_state() with db._connect() as conn: conn.execute("BEGIN IMMEDIATE") if not conn.execute("SELECT 1 FROM users WHERE LOWER(role) = 'admin' LIMIT 1").fetchone(): raise SetupUnavailableError("Create an administrator before completing setup.") conn.execute( """UPDATE installation_setup SET completed = 1, step = 'review', completed_at = ? WHERE id = 1 AND completed = 0""", (datetime.now(timezone.utc).isoformat(),), ) return get_setup_state()