"""Идемпотентный бутстрап: справочники + учётная запись администратора. Запуск: `python -m app.bootstrap` (вызывается из entrypoint.sh после миграций). Ротация пароля админа из .env: `python -m app.bootstrap --reset-admin-password` (#73). """ from __future__ import annotations import argparse from sqlmodel import Session, select from app.core.config import settings from app.core.security import hash_password, verify_password from app.db.session import engine from app.models import User from app.seed.reference_data import seed_reference_data from app.services import user_service def _ensure_admin(session: Session) -> None: if not settings.admin_bootstrap_enabled: return password = (settings.admin_password or "").strip() username = (settings.admin_username or "admin").strip() existing = session.exec(select(User).where(User.role == "admin")).first() if existing is None: if not password: raise RuntimeError("Отказ создавать администратора без пароля (ADMIN_PASSWORD пуст).") # Логин администратора = его никнейм (отдельной таблицы админов нет). session.add( User( nickname=username, role="admin", auth_provider="local", password_hash=hash_password(password), ) ) session.commit() print(f"[bootstrap] Создан администратор: {username}") return # Администратор уже существует. if not settings.is_development: # В prod обычный старт пароль НЕ перезаписывает: смена ADMIN_PASSWORD в .env сама # по себе ничего не делает — применить её можно только осознанно, командой # ротации (reset_admin_password), которая заодно отзывает админские сессии. return # DEV: подтягиваем логин/пароль из .env (env — источник истины в деве). changed: list[str] = [] if password and not verify_password(password, existing.password_hash or ""): existing.password_hash = hash_password(password) changed.append("пароль") if username and existing.nickname != username: existing.nickname = username changed.append("логин") if changed: session.add(existing) session.commit() print(f"[bootstrap] DEV: администратор обновлён из .env ({', '.join(changed)})") def reset_admin_password(session: Session) -> User: """Записать существующему админу пароль из ADMIN_PASSWORD (#73) — в любом окружении. Через панель пароль админа не меняется, а обычный старт в prod его из .env не берёт: это единственный штатный способ, и он требует доступа к серверу. set_password проверяет пароль и увеличивает token_version — все выданные админские сессии (в том числе чужая, если пароль утёк) перестают действовать. Логин не трогаем.""" if not settings.admin_bootstrap_enabled: raise RuntimeError( "ADMIN_BOOTSTRAP_ENABLED=false — администратором управляют вручную, ротация отключена." ) password = (settings.admin_password or "").strip() if not password: raise RuntimeError("ADMIN_PASSWORD пуст — нечего применять.") admin = session.exec(select(User).where(User.role == "admin")).first() if admin is None: raise RuntimeError("Администратора ещё нет — запустите обычный bootstrap, он его создаст.") return user_service.set_password(session, admin, password) def bootstrap() -> None: with Session(engine) as session: seed_reference_data(session) # идемпотентно _ensure_admin(session) def main(argv: list[str] | None = None) -> None: ap = argparse.ArgumentParser(prog="python -m app.bootstrap", description=__doc__) ap.add_argument( "--reset-admin-password", action="store_true", help="применить ADMIN_PASSWORD из .env к существующему админу и завершить его сессии", ) args = ap.parse_args(argv) if not args.reset_admin_password: bootstrap() return with Session(engine) as session: admin = reset_admin_password(session) print(f"[bootstrap] Пароль администратора {admin.nickname} обновлён из .env; " "все его сессии завершены.") if __name__ == "__main__": main()