"""Engine + session-factory для БД `auth` — общего реестра людей (эпик «единый вход»). Отдельный модуль, а не ещё пара строк в `app.core.db`, ровно по одной причине: `app.core.db` создаёт engine НА ИМПОРТЕ (`create_engine(settings.database_url)` в теле модуля). Сделай мы так же для БД `auth` — приложение начало бы падать на старте везде, где реестр не сконфигурирован, а не сконфигурирован он сейчас ВЕЗДЕ: на проде роль `auth_app` ещё без пароля, в тестах этой БД нет вовсе. Здесь engine создаётся ЛЕНИВО, при первом реальном обращении. Контракт (⚠️ после мержа прод обязан работать ТОЧНО как сейчас): * `settings.identity_store == "tradein"` (дефолт) — в этот модуль не заходит никто: `app.services.identity_store` берёт сессию из `app.core.db`. Пустая конфигурация БД `auth` при этом не ошибка ни на импорте, ни в рантайме; ни одно соединение с БД `auth` не открывается. * `settings.identity_store == "auth"` + не сконфигурированный реестр — первое же обращение поднимает `AuthDatabaseNotConfiguredError` с внятным текстом. Именно исключение, а НЕ тихий откат на tradein-таблицы и не пустой результат: молчаливая деградация auth-пути означала бы «пользователь не найден» вместо «конфигурация сломана», то есть массовый отказ входа под видом неверных паролей — либо, в обратную сторону, анонимный доступ. Сам 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` сам по себе к серверу не ходит (connection pool ленивый), так что даже после первого обращения реальный коннект открывается только на первом запросе — но ошибку конфигурации мы обязаны отдать раньше, чем это станет похоже на сетевую проблему. """ from __future__ import annotations import threading from collections.abc import Iterator from contextlib import contextmanager from sqlalchemy import Engine, create_engine from sqlalchemy.exc import ArgumentError from sqlalchemy.orm import Session, sessionmaker from app.core.config import settings from app.core.db import DB_CONNECT_ARGS class AuthDatabaseNotConfiguredError(RuntimeError): """`IDENTITY_STORE=auth`, а DSN БД `auth` не задан/не разобрался.""" _NOT_CONFIGURED_MSG = ( "IDENTITY_STORE=auth, но реестр людей (БД `auth`) не сконфигурирован: пусты и " "AUTH_DB_PASSWORD, и AUTH_DATABASE_URL — подключаться не к чему. Задай в " ".env.runtime AUTH_DB_PASSWORD (пароль роли auth_app; остальные части DSN — " "AUTH_DB_HOST/AUTH_DB_PORT/AUTH_DB_NAME/AUTH_DB_USER — имеют прод-дефолты), " "либо целиком AUTH_DATABASE_URL, либо верни IDENTITY_STORE=tradein (старое " "поведение на tradein_users/tradein_sessions)." ) # Текст для нечитаемого 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. """ 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, # #3194: SQLAlchemy печатает ВСЕ bind-параметры в тексте StatementError — # через них в GlitchTip уезжали ключ шифрования кук и сами куки # (pgp_sym_encrypt(:cookies_json, :key)). Флаг на УРОВНЕ ДВИЖКА кроет все # сайты вызова разом, включая будущие. # НЕ закрывает: текст ошибки самого драйвера (Postgres DETAIL со значением) # и сырые psycopg-подключения мимо движков — это отдельный класс. hide_parameters=True, # #3463. Те же потолки, что у продуктового движка, — ОДНОЙ константой на оба: # потолок на одном движке и мина на втором это не починка, а половина. # Этот движок живёт на ГОРЯЧЕМ пути: `core/rbac.py` резолвит session-cookie # в middleware, синхронно на event loop'е, на КАЖДОМ запросе с cookie # (на проде IDENTITY_STORE=auth во всех трёх сервисах образа — сверено 12.09, # `printenv` в контейнерах). Без потолка `ACCESS EXCLUSIVE` на `auth.sessions` # вешает не четыре слота `/estimate`, а весь uvicorn-воркер (он один, без # --workers) — включая `/health`. # Срабатывание потолка безопасно: вызов в rbac.py уже под `except Exception` # с фолбэком на заголовочную аутентификацию, то есть отмена запроса даёт тот # же путь, что и любой другой сбой реестра, а не 500. connect_args=DB_CONNECT_ARGS, ) 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 if _engine is not None and _session_factory is not None: return _engine, _session_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.services.identity_store.identity_session()`, он один знает, какая БД сейчас является реестром. """ factory = get_auth_session_factory() with factory() as db: yield db 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()