gendesign/backend/app/core/auth_db.py
bot-backend 7e59c1e5b0
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
fix(#3194): hide_parameters=True на всех движках, include_local_variables=False у scheduler
Ключ шифрования кук и сами куки уезжали в 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>
2026-08-30 14:53:41 +05:00

276 lines
19 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""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()