All checks were successful
CI / changes (pull_request) Successful in 9s
CI Trade-In / browser-tests (pull_request) Has been skipped
CI Trade-In / frontend-checks (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI Trade-In / changes (pull_request) Successful in 7s
CI / openapi-codegen-check (pull_request) Successful in 3m10s
CI Trade-In / backend-tests (pull_request) Successful in 5m47s
CI / backend-tests (pull_request) Successful in 18m3s
Ключ шифрования кук и сами куки уезжали в GlitchTip: сервисы сессий передают их bind-параметрами в pgp_sym_encrypt(:cookies_json, :key), а SQLAlchemy при ошибке печатает ВСЕ параметры в тексте StatementError. Правка на уровне движка (backend + tradein-mvp: db.py, auth_db.py, alembic/env.py) кроет все сайты вызова разом, включая четвёртую копию в scraper-kit и всё будущее. scheduler_main.py был единственным из трёх sentry_sdk.init без include_local_variables=False — процесс скрейпера, в кадрах лежат прокси-креды. НЕ закрывает: текст ошибки самого драйвера (Postgres DETAIL со значением) и сырые psycopg-подключения мимо движков — отдельный класс. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
173 lines
10 KiB
Python
173 lines
10 KiB
Python
"""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
|
||
|
||
|
||
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,
|
||
)
|
||
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()
|