"""User CRUD and permission management for Vacuum Wall authentication. All operations use DB query IDs through the Database abstract layer. Password hashing uses Argon2id via lib.password. """ from __future__ import annotations import logging import re import secrets from typing import Any from lib.auth import ( blacklist_active_refresh_token, rotate_user_secret, ) from lib.db import ( Q_DELETE_PERMISSION_SUBSYSTEM, Q_DELETE_USER, Q_INSERT_USER, Q_SELECT_PERMISSIONS, Q_SELECT_USER_BY_NAME, Q_SELECT_USERS_WITH_PERMS, Q_UPDATE_PASSWORD, Q_UPSERT_PERMISSION, get_db, ) from lib.password import hash_password, needs_rehash, verify_password logger = logging.getLogger(__name__) # Pre-computed dummy hash for constant-time verification on nonexistent users. # Generated once at module load to avoid timing leaks from per-call hash generation. _DUMMY_HASH = hash_password(secrets.token_hex(32)) # Builtin admin — hardcoded, full access, cannot be modified/deleted BUILTIN_ADMIN_USERNAME = "admin" # All subsystem names for default permission assignment ALL_SUBSYSTEMS = [ "firewall", "network", "dhcp", "proxy", "certs", "wireguard", "logs", "status", "auth", ] _USERNAME_RE = re.compile(r"^[a-zA-Z0-9_-]{3,32}$") def _get_permissions(username: str) -> dict[str, str]: """Load permissions for *username* from the database. Args: username: The username to load permissions for. Returns: Dict mapping subsystem names to permission levels. """ db = get_db() rows = db.query(Q_SELECT_PERMISSIONS, (username,)) return {row["subsystem"]: row["level"] for row in rows} def get_user(username: str) -> dict[str, Any] | None: """Get a user by username (without password hash or JWT secret). Args: username: The username to look up. Returns: User dict with id, username, permissions, or None if not found. """ user = find_user(username) if user is None: return None return { "id": user["id"], "username": user["username"], "permissions": _get_permissions(username), } def find_user(username: str) -> dict[str, Any] | None: """Find a user by username, including password hash and JWT secret. Used for password verification and token operations. Not returned through APIs. Args: username: The username to look up. Returns: User dict from the database row, or None. """ db = get_db() rows = db.query(Q_SELECT_USER_BY_NAME, (username,)) if not rows: return None return rows[0] def verify_user_password(username: str, password: str) -> dict[str, Any] | None: """Verify a user's password using Argon2id. Args: username: The username to verify. password: Plain-text password. Returns: User dict (without password hash) if the password is correct, None otherwise. """ user = find_user(username) if user is None: # Run a dummy Argon2id verification against a pre-computed hash to # prevent timing-based user enumeration. Uses module-level hash # so both paths take ~1 verify call (~200ms) instead of ~400ms. verify_password(password, _DUMMY_HASH) return None if not verify_password(password, user["password_hash"]): return None if needs_rehash(user["password_hash"]): new_hash = hash_password(password) db = get_db() db.run(Q_UPDATE_PASSWORD, (new_hash, username)) logger.info("Password hash rehashed for %r (param upgrade)", username) return { "id": user["id"], "username": user["username"], "permissions": _get_permissions(username), } def create_user( username: str, password: str, permissions: dict[str, str] | None = None, ) -> dict[str, Any]: """Create a new user with the given password and permissions. Args: username: The new username (3-32 alphanumeric chars, dash, underscore). password: Plain-text password that will be hashed with Argon2id. permissions: Dict mapping subsystem names to "read" or "rw". Returns: The created user dict with id, username, permissions. Raises: ValueError: If username is invalid or already exists. """ if not _USERNAME_RE.match(username): raise ValueError( "Username must be 3-32 characters: letters, digits, dash, underscore" ) existing = find_user(username) if existing is not None: raise ValueError(f"User {username!r} already exists") password_hash = hash_password(password) jwt_secret = secrets.token_urlsafe(32) db = get_db() with db.in_transaction() as tx: user_id = tx.run_one(Q_INSERT_USER, (username, password_hash, jwt_secret)) if permissions: for subsystem, level in permissions.items(): tx.run(Q_UPSERT_PERMISSION, (username, subsystem, level)) return { "id": user_id, "username": username, "permissions": _get_permissions(username), } def update_password(username: str, old_password: str, new_password: str) -> bool: """Update a user's password and invalidate all active tokens. Rotates the user's JWT secret, immediately invalidating all existing access and refresh tokens. Args: username: The username. old_password: Current password. new_password: New plain-text password. Returns: True if password was updated. Raises: ValueError: If old password is incorrect. """ if not verify_user_password(username, old_password): raise ValueError("Current password is incorrect") blacklist_active_refresh_token(username) new_hash = hash_password(new_password) rotate_user_secret(username) db = get_db() db.run(Q_UPDATE_PASSWORD, (new_hash, username)) return True def update_permissions(username: str, permissions: dict[str, str]) -> None: """Update a user's permissions and invalidate all existing tokens. Replaces all existing permissions with the provided mapping. Rotates the JWT secret so that permission changes take effect immediately — existing tokens with stale permissions are no longer valid. Args: username: The username. permissions: Dict mapping subsystem names to permission levels. Raises: ValueError: If attempting to modify builtin admin. """ if username == BUILTIN_ADMIN_USERNAME: raise ValueError("Cannot modify permissions for builtin admin") user = find_user(username) if user is None: raise ValueError(f"User {username!r} not found") db = get_db() with db.in_transaction() as tx: # Upsert all new permissions first, then remove stale ones. # This order ensures that if an upsert fails mid-loop, the user's # permissions remain intact (transaction rolls back) rather than # being permanently wiped. existing = { row["subsystem"]: row["level"] for row in db.query(Q_SELECT_PERMISSIONS, (username,)) } for subsystem, level in permissions.items(): tx.run(Q_UPSERT_PERMISSION, (username, subsystem, level)) for subsystem in existing: if subsystem not in permissions: tx.run(Q_DELETE_PERMISSION_SUBSYSTEM, (username, subsystem)) blacklist_active_refresh_token(username) rotate_user_secret(username) def list_users() -> list[dict[str, Any]]: """List all users with their permissions. Returns: List of user summary dicts. """ db = get_db() rows = db.query(Q_SELECT_USERS_WITH_PERMS, ()) users: dict[int, dict[str, Any]] = {} for row in rows: uid = row["id"] if uid not in users: users[uid] = { "id": uid, "username": row["username"], "permissions": {}, "created_at": row["created_at"], } if row["subsystem"] is not None: users[uid]["permissions"][row["subsystem"]] = row["level"] return list(users.values()) def delete_user(username: str) -> bool: """Delete a user and all their associated data. Permissions and WebAuthn credentials are CASCADE-deleted by the schema. Args: username: The username to delete. Returns: True if the user was deleted. Raises: ValueError: If the user does not exist. """ user = find_user(username) if user is None: raise ValueError(f"User {username!r} not found") blacklist_active_refresh_token(username) db = get_db() db.run(Q_DELETE_USER, (username,)) return True