В prod пароль админа задавался только при первом создании: обычный bootstrap его из .env не берёт, панель не меняет — комментарии отсылали друг к другу. Теперь `python -m app.bootstrap --reset-admin-password` применяет ADMIN_PASSWORD к существующему админу через user_service.set_password: пароль проверяется, token_version растёт, и все админские сессии (в том числе чужие) отзываются. Обычный старт по-прежнему пароль не трогает. Процедура на Pi — в deploy/pi/README.md (правка .env → docker compose up -d → exec команды), кратко — в README. #73 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LqSoRj99iwVEH5U5fnZgsd
109 lines
5.1 KiB
Python
109 lines
5.1 KiB
Python
"""Идемпотентный бутстрап: справочники + учётная запись администратора.
|
|
|
|
Запуск: `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()
|