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>
276 lines
19 KiB
Python
276 lines
19 KiB
Python
"""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"},
|
||
# #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
|
||
# Быстрый путь читает глобалы РОВНО ОДИН раз, в локальные переменные. Читать их
|
||
# второй раз в `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()
|