"""Engine + session-factory для БД `auth` — общего реестра людей (эпик «единый вход»). Отдельный модуль, а не ещё пара строк в `app.core.db`, ровно по одной причине: `app.core.db` создаёт engine НА ИМПОРТЕ (`create_engine(settings.database_url)` в теле модуля, db.py:8). Сделай мы так же для БД `auth` — приложение начало бы падать на старте везде, где реестр не сконфигурирован: локально, в pytest и на любом стенде, где переменных AUTH_* нет. Здесь engine создаётся ЛЕНИВО, при первом реальном обращении. Контракт (⚠️ после мержа прод обязан работать ТОЧНО как сейчас — Caddy basic_auth ещё стоит и снимается последним PR эпика): * `AUTH_MODE=legacy` (ДЕФОЛТ; `settings.auth_session_enabled is False`) — в этот модуль не заходит никто: `app.main.rbac_guard` в этом режиме куку не читает вовсе. Пустая конфигурация БД `auth` при этом не ошибка ни на импорте, ни в рантайме; ни одно соединение с БД `auth` не открывается. * Режим включён (`dual`/`db_only`) + не сконфигурированный реестр — обращение поднимает `AuthDatabaseNotConfiguredError` с внятным текстом. Именно исключение, а НЕ тихий возврат «сессия не найдена»: молчаливая деградация означала бы, что все владельцы валидных кук выглядят как анонимы, то есть массовый отказ доступа под видом «просто не залогинен» — либо, если guard в этот момент откатывается на trusted-header, наоборот, раздача прав в обход реестра (включая аккаунты с access_state 'disabled'). Оба исхода обязаны быть громкими. «Птица» реестр только ЧИТАЕТ: сессии выдаёт и отзывает единственная форма входа — у «Меры». Здесь нет и не должно появиться ни create-, ни revoke-пути. Сам DSN этот модуль НЕ выбирает и НЕ склеивает — берёт готовый у `settings.resolved_auth_database_url` (явный `AUTH_DATABASE_URL`, иначе сборка из `AUTH_DB_PASSWORD` + частей хоста/порта/базы/пользователя, иначе пусто). ⚠️ В DSN — пароль роли `auth_app`. Он не логируется и не попадает в текст исключений НИ В ОДНОЙ ветке этого модуля: сообщения ниже — константы, а ошибку разбора URL от SQLAlchemy (её текст содержит исходную строку) мы перехватываем и заменяем своей, обрывая цепочку `from None`, чтобы исходник не всплыл в traceback. Добавляешь сюда `logger`/`raise ... {dsn}` — не добавляй. `create_engine` сам по себе к серверу не ходит (пул коннектов ленивый) — то есть одна лишь сборка engine доказывает только «DSN не пуст и парсится». Поэтому `require_auth_db_configured` (fail-fast старта) дополнительно ОТКРЫВАЕТ соединение и делает `SELECT 1`: неверный пароль, опечатка в хосте, отсутствующая БД и отозванная роль обязаны ронять деплой, а не превращаться в «ни у кого нет сессии». Зеркало по подходу: tradein-mvp/backend/app/core/auth_db.py («Мера»). Синхронизация руками — стеки разные, общего кода между ними нет и заводить его этот эпик не собирается. """ from __future__ import annotations import threading from collections.abc import Iterator from contextlib import contextmanager from sqlalchemy import Engine, create_engine, text from sqlalchemy.exc import ArgumentError from sqlalchemy.orm import Session, sessionmaker from app.core.config import settings class AuthDatabaseNotConfiguredError(RuntimeError): """`AUTH_MODE` не `legacy`, а DSN БД `auth` не задан/не разобрался.""" class AuthDatabaseUnreachableError(RuntimeError): """DSN синтаксически корректен, но соединиться по нему не удалось (старт приложения).""" _NOT_CONFIGURED_MSG = ( "Приём сессионной куки включён (AUTH_MODE=dual|db_only), но реестр людей " "(БД `auth`) не сконфигурирован: пусты и AUTH_DB_PASSWORD, и AUTH_DATABASE_URL — " "подключаться не к чему. Задай в backend/.env.runtime AUTH_DB_PASSWORD (пароль " "роли auth_app; остальные части DSN — AUTH_DB_HOST/AUTH_DB_PORT/AUTH_DB_NAME/" "AUTH_DB_USER — имеют прод-дефолты), либо целиком AUTH_DATABASE_URL, либо верни " "AUTH_MODE=legacy (сегодняшнее поведение: Caddy basic_auth + заголовок " "X-Authenticated-User)." ) _UNREACHABLE_MSG = ( "Приём сессионной куки включён (AUTH_MODE=dual|db_only), DSN разобрался, но " "соединиться с БД `auth` не удалось (см. причину ниже: хост/порт/база/роль/пароль " "или сеть). Старт прерван намеренно: иначе сломанная конфигурация выглядела бы как " "«ни у кого нет сессии» — сутками, при живом приложении и 200-х в ответах. Проверь " "AUTH_DB_* в backend/.env.runtime и пароль роли auth_app (data/sql/auth/002), либо " "верни AUTH_MODE=legacy." ) # Текст для нечитаемого DSN. БЕЗ подстановки самого DSN — там пароль; исходную # ошибку SQLAlchemy (она цитирует строку целиком) гасим `from None`. _MALFORMED_DSN_MSG = ( "DSN БД `auth` не разобрался SQLAlchemy. Проверь AUTH_DATABASE_URL (если задан " "явно) либо части AUTH_DB_HOST/AUTH_DB_PORT/AUTH_DB_NAME/AUTH_DB_USER. Схема " "обязана быть postgresql+psycopg:// (psycopg v3). Сам DSN сюда намеренно НЕ " "подставлен: в нём пароль роли auth_app." ) # Кеш engine/factory + защита от гонки: rbac_guard будет резолвить сессию на каждом # non-public запросе, а uvicorn обслуживает их из нескольких потоков (sync-роуты # уходят в threadpool). Без лока два одновременных первых запроса создали бы два # engine — то есть два независимых пула коннектов, один из которых потеряется. _LOCK = threading.Lock() _engine: Engine | None = None _session_factory: sessionmaker[Session] | None = None def _build() -> tuple[Engine, sessionmaker[Session]]: """Создаёт engine + session-factory по текущему DSN. Нет DSN → явная ошибка. DSN резолвит `settings` (явный AUTH_DATABASE_URL или сборка из AUTH_DB_*) — здесь только «пусто или нет» и создание engine. `pool_size`/`max_overflow` не переопределяем: дефолтов SQLAlchemy (5+10) хватает с запасом — на запрос приходится один короткий SELECT, а раз в 5 минут ещё и UPDATE sliding-refresh. А вот таймауты переопределяем, и это не тюнинг, а требование: реестр — НЕ критический путь «Птицы», его сбой обязан деградировать за секунды, а не за минуты (в dual-режиме деградация — уход на легаси-заголовок, в db_only — 401). * `connect_timeout=3` (libpq, секунды). Без него дропнутые SYN (хост поднят, но недоступен по сети / фаервол молча глотает пакеты) держат попытку соединения до TCP-таймаута ОС — на Linux порядка 130 с. `pool_pre_ping=True` делает такую попытку на КАЖДОМ checkout'е. * `statement_timeout=3000` (мс, серверный). Ограничивает уже установленное соединение: залипший SELECT/UPDATE в auth-пути не имеет права висеть дольше. * `pool_timeout=3` — ожидание свободного коннекта в пуле. Дефолтные 30 с в auth-пути не нужны никогда: лучше быстро сдаться. Резолв сессии в rbac_guard уходит в threadpool (`run_in_threadpool`), так что эти ожидания не блокируют event loop, — но они всё равно держат worker-поток и время ответа, поэтому короткие. """ dsn = settings.resolved_auth_database_url if not dsn: raise AuthDatabaseNotConfiguredError(_NOT_CONFIGURED_MSG) try: engine = create_engine( dsn, pool_pre_ping=True, future=True, pool_timeout=3, connect_args={"connect_timeout": 3, "options": "-c statement_timeout=3000"}, ) except (ArgumentError, ValueError): # ValueError — не паранойя: на «почти URL» разбор SQLAlchemy доходит до # `int(port)` и падает с `invalid literal for int() with base 10: 'w'`, где # 'w' — КУСОК ПАРОЛЯ, съехавший на позицию порта. `from None` обязателен: он # гасит цепочку, иначе исходная ошибка (а с ней и этот кусок) печатается в # traceback как «During handling of...». raise AuthDatabaseNotConfiguredError(_MALFORMED_DSN_MSG) from None factory = sessionmaker(autocommit=False, autoflush=False, bind=engine, expire_on_commit=False) return engine, factory def _ensure_built() -> tuple[Engine, sessionmaker[Session]]: global _engine, _session_factory # Быстрый путь читает глобалы РОВНО ОДИН раз, в локальные переменные. Читать их # второй раз в `return` нельзя: между проверкой и возвратом может вклиниться # `reset_auth_db()` (обнуляет оба под локом) — и функция вернула бы (None, None), # то есть вызывающий упал бы на `factory()` → `TypeError: 'NoneType' object is not # callable` прямо в auth-пути. engine, factory = _engine, _session_factory if engine is not None and factory is not None: return engine, factory with _LOCK: if _engine is None or _session_factory is None: _engine, _session_factory = _build() return _engine, _session_factory def get_auth_engine() -> Engine: """Engine БД `auth` (создаётся при первом вызове). Raises: AuthDatabaseNotConfiguredError: реестр не сконфигурирован (нет ни AUTH_DATABASE_URL, ни AUTH_DB_PASSWORD) либо DSN не разобрался. """ engine, _ = _ensure_built() return engine def get_auth_session_factory() -> sessionmaker[Session]: """Session-factory БД `auth` (создаётся при первом вызове). Raises: AuthDatabaseNotConfiguredError: реестр не сконфигурирован (нет ни AUTH_DATABASE_URL, ни AUTH_DB_PASSWORD) либо DSN не разобрался. """ _, factory = _ensure_built() return factory @contextmanager def auth_session() -> Iterator[Session]: """Сессия к БД `auth`, закрывается на выходе из блока. Это НЕ `app.core.db.get_db`: там продуктовая БД gendesign, где таблиц `users`/`sessions` реестра нет. Прямой вызов из роутов не предполагается — ходи через `app.services.auth_session.resolve_session_token()`. """ factory = get_auth_session_factory() with factory() as db: yield db def _probe_connection(engine: Engine) -> None: """Открывает соединение и делает `SELECT 1`. Вынесено функцией ради тестов. Отдельная функция, а не две строки в `require_auth_db_configured`: тестам нужна точка подмены, чтобы проверять ветвление старта, не поднимая Postgres. """ with engine.connect() as conn: conn.execute(text("SELECT 1")) def require_auth_db_configured() -> None: """Fail-fast для старта приложения: включённый режим обязан иметь РАБОЧИЙ реестр. Вызывается из `lifespan` (`app/main.py:111`). Смысл проверки именно на старте: если сломанная конфигурация обнаружится только в rbac_guard, там её поймает общий `except` вокруг резолва сессии, и она будет выглядеть как «ни у кого нет сессии» — сутками, потому что продуктовая БД жива и приложение работоспособно, а сигнал остаётся только в логах. Дешевле не стартовать. Проверяется ИМЕННО СОЕДИНЕНИЕ, а не только синтаксис DSN. `create_engine` к серверу не ходит вовсе (пул ленивый), поэтому одна лишь сборка engine отлавливала бы ровно два случая — «DSN пуст» и «DSN не парсится», — а весь класс вероятных ошибок (неверный AUTH_DB_PASSWORD, опечатка в хосте, не созданная БД `auth`, отозванная роль auth_app, нет сетевой связности) проходил бы мимо и материализовался как та самая тихая деградация, ради которой эта функция и заведена. Проба короткая: `connect_timeout=3` в `_build`. Цена — контейнер не поднимется, пока БД `auth` недоступна. Это осознанно: реестр живёт на ТОМ ЖЕ сервере, что и продуктовая БД (сервис `postgres` корневого docker-compose.prod.yml, см. `app/core/config.py`), так что «реестр недоступен, а продукт работоспособен» — состояние вырожденное, а `restart: unless-stopped` поднимет контейнер, как только Postgres вернётся. Режим `legacy` (ДЕФОЛТ) → no-op: ни проверки DSN, ни создания engine, ни коннекта. Дефолтное поведение обязано оставаться ровно сегодняшним. Raises: AuthDatabaseNotConfiguredError: режим не `legacy`, но DSN пуст или не разобрался. AuthDatabaseUnreachableError: DSN разобрался, но соединиться не удалось. """ if not settings.auth_session_enabled: return engine, _ = _ensure_built() try: _probe_connection(engine) except Exception as exc: # Исходную ошибку СОХРАНЯЕМ в цепочке (`from exc`): в ней хост/порт/роль и # причина отказа — то, ради чего проверка и делается. Пароля libpq в тексте # ошибок не печатает, а наш DSN сюда не подставляется (см. модульный докстринг). raise AuthDatabaseUnreachableError(_UNREACHABLE_MSG) from exc def reset_auth_db() -> None: """Сбрасывает закешированные engine/factory (смена DSN в рантайме, тесты). Старый engine `dispose()`-ится вне лока: закрытие пула может блокировать, а держать в это время лок незачем — ссылки на него уже сняты. """ global _engine, _session_factory with _LOCK: stale = _engine _engine = None _session_factory = None if stale is not None: stale.dispose()