"""SAC UI users: password hashing, authentication, bootstrap.""" from __future__ import annotations import logging from passlib.context import CryptContext from sqlalchemy import func, select from sqlalchemy.orm import Session from app.config import get_settings from app.models.user import USER_ROLE_ADMIN, USER_ROLE_MONITOR, USER_ROLES, User logger = logging.getLogger(__name__) _pwd_context = CryptContext(schemes=["pbkdf2_sha256"], deprecated="auto") def hash_password(plain: str) -> str: return _pwd_context.hash(plain) def verify_password(plain: str, password_hash: str) -> bool: try: return _pwd_context.verify(plain, password_hash) except ValueError: return False def get_user_by_username(db: Session, username: str) -> User | None: normalized = username.strip() if not normalized: return None return db.scalar( select(User).where( func.lower(User.username) == normalized.lower(), User.is_active.is_(True), ) ) def authenticate_user(db: Session, username: str, password: str) -> User | None: user = get_user_by_username(db, username) if user is None: return None if not verify_password(password, user.password_hash): return None return user def create_user( db: Session, *, username: str, password: str, role: str = USER_ROLE_MONITOR, ) -> User: normalized = username.strip() if not normalized: raise ValueError("username is required") if role not in USER_ROLES: raise ValueError(f"invalid role: {role}") if len(password) < 8: raise ValueError("password must be at least 8 characters") existing = db.scalar(select(User).where(func.lower(User.username) == normalized.lower())) if existing is not None: raise ValueError("username already exists") user = User( username=normalized, password_hash=hash_password(password), role=role, is_active=True, ) db.add(user) db.flush() return user def bootstrap_admin_user(db: Session) -> None: """Create first admin from SAC_ADMIN_* when sac_users table is empty.""" try: count = db.scalar(select(func.count()).select_from(User)) or 0 except Exception as exc: logger.warning("SAC users bootstrap skipped (run alembic upgrade head): %s", exc) db.rollback() return if count > 0: return settings = get_settings() if not settings.sac_admin_password: logger.warning("SAC users table empty and SAC_ADMIN_PASSWORD not set — UI login disabled until a user is created") return username = (settings.sac_admin_username or "admin").strip() or "admin" create_user(db, username=username, password=settings.sac_admin_password, role=USER_ROLE_ADMIN) db.commit() logger.info("SAC bootstrap: created admin user %r from SAC_ADMIN_* env", username)