feat(tradein): DSN к БД auth собирается из AUTH_DB_PASSWORD, а не дублирует секрет #2619

Merged
lekss361 merged 1 commit from feat/auth-dsn-from-password into main 2026-08-02 09:09:55 +00:00
4 changed files with 680 additions and 19 deletions

View file

@ -3,23 +3,33 @@
Отдельный модуль, а не ещё пара строк в `app.core.db`, ровно по одной причине: Отдельный модуль, а не ещё пара строк в `app.core.db`, ровно по одной причине:
`app.core.db` создаёт engine НА ИМПОРТЕ (`create_engine(settings.database_url)` в `app.core.db` создаёт engine НА ИМПОРТЕ (`create_engine(settings.database_url)` в
теле модуля). Сделай мы так же для БД `auth` приложение начало бы падать на теле модуля). Сделай мы так же для БД `auth` приложение начало бы падать на
старте везде, где `AUTH_DATABASE_URL` не задан, а не задан он сейчас ВЕЗДЕ: на старте везде, где реестр не сконфигурирован, а не сконфигурирован он сейчас
проде роль `auth_app` ещё без пароля, в тестах этой БД нет вовсе. Здесь engine ВЕЗДЕ: на проде роль `auth_app` ещё без пароля, в тестах этой БД нет вовсе.
создаётся ЛЕНИВО, при первом реальном обращении. Здесь engine создаётся ЛЕНИВО, при первом реальном обращении.
Контракт ( после мержа прод обязан работать ТОЧНО как сейчас): Контракт ( после мержа прод обязан работать ТОЧНО как сейчас):
* `settings.identity_store == "tradein"` (дефолт) в этот модуль не заходит * `settings.identity_store == "tradein"` (дефолт) в этот модуль не заходит
никто: `app.services.identity_store` берёт сессию из `app.core.db`. Пустой никто: `app.services.identity_store` берёт сессию из `app.core.db`. Пустая
`AUTH_DATABASE_URL` при этом не ошибка ни на импорте, ни в рантайме; ни одно конфигурация БД `auth` при этом не ошибка ни на импорте, ни в рантайме; ни
соединение с БД `auth` не открывается. одно соединение с БД `auth` не открывается.
* `settings.identity_store == "auth"` + пустой DSN первое же обращение * `settings.identity_store == "auth"` + не сконфигурированный реестр первое
поднимает `AuthDatabaseNotConfiguredError` с внятным текстом. Именно же обращение поднимает `AuthDatabaseNotConfiguredError` с внятным текстом.
исключение, а НЕ тихий откат на tradein-таблицы и не пустой результат: Именно исключение, а НЕ тихий откат на tradein-таблицы и не пустой результат:
молчаливая деградация auth-пути означала бы «пользователь не найден» вместо молчаливая деградация 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 ленивый), так что `create_engine` сам по себе к серверу не ходит (connection pool ленивый), так что
даже после первого обращения реальный коннект открывается только на первом даже после первого обращения реальный коннект открывается только на первом
запросе но ошибку конфигурации мы обязаны отдать раньше, чем это станет запросе но ошибку конфигурации мы обязаны отдать раньше, чем это станет
@ -33,19 +43,32 @@ from collections.abc import Iterator
from contextlib import contextmanager from contextlib import contextmanager
from sqlalchemy import Engine, create_engine from sqlalchemy import Engine, create_engine
from sqlalchemy.exc import ArgumentError
from sqlalchemy.orm import Session, sessionmaker from sqlalchemy.orm import Session, sessionmaker
from app.core.config import settings from app.core.config import settings
class AuthDatabaseNotConfiguredError(RuntimeError): class AuthDatabaseNotConfiguredError(RuntimeError):
"""`IDENTITY_STORE=auth`, но `AUTH_DATABASE_URL` пуст — идентичность негде читать.""" """`IDENTITY_STORE=auth`, а DSN БД `auth` не задан/не разобрался."""
_NOT_CONFIGURED_MSG = ( _NOT_CONFIGURED_MSG = (
"IDENTITY_STORE=auth, но AUTH_DATABASE_URL пуст: подключаться к общему реестру " "IDENTITY_STORE=auth, но реестр людей (БД `auth`) не сконфигурирован: пусты и "
"людей (БД `auth`) не к чему. Задай DSN роли auth_app в .env.runtime — либо " "AUTH_DB_PASSWORD, и AUTH_DATABASE_URL — подключаться не к чему. Задай в "
"верни IDENTITY_STORE=tradein (старое поведение на tradein_users/tradein_sessions)." ".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 резолвит сессию на каждом # Кеш engine/factory + защита от гонки: rbac_guard резолвит сессию на каждом
@ -58,11 +81,23 @@ _session_factory: sessionmaker[Session] | None = None
def _build() -> tuple[Engine, sessionmaker[Session]]: def _build() -> tuple[Engine, sessionmaker[Session]]:
"""Создаёт engine + session-factory по текущему DSN. Пустой DSN → явная ошибка.""" """Создаёт engine + session-factory по текущему DSN. Нет DSN → явная ошибка.
dsn = settings.auth_database_url.strip()
DSN резолвит `settings` (явный AUTH_DATABASE_URL или сборка из AUTH_DB_*)
здесь только «пусто или нет» и создание engine.
"""
dsn = settings.resolved_auth_database_url
if not dsn: if not dsn:
raise AuthDatabaseNotConfiguredError(_NOT_CONFIGURED_MSG) raise AuthDatabaseNotConfiguredError(_NOT_CONFIGURED_MSG)
engine = create_engine(dsn, pool_pre_ping=True, future=True) try:
engine = create_engine(dsn, pool_pre_ping=True, future=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) factory = sessionmaker(autocommit=False, autoflush=False, bind=engine, expire_on_commit=False)
return engine, factory return engine, factory
@ -81,7 +116,8 @@ def get_auth_engine() -> Engine:
"""Engine БД `auth` (создаётся при первом вызове). """Engine БД `auth` (создаётся при первом вызове).
Raises: Raises:
AuthDatabaseNotConfiguredError: `AUTH_DATABASE_URL` пуст. AuthDatabaseNotConfiguredError: реестр не сконфигурирован (нет ни
AUTH_DATABASE_URL, ни AUTH_DB_PASSWORD) либо DSN не разобрался.
""" """
engine, _ = _ensure_built() engine, _ = _ensure_built()
return engine return engine
@ -91,7 +127,8 @@ def get_auth_session_factory() -> sessionmaker[Session]:
"""Session-factory БД `auth` (создаётся при первом вызове). """Session-factory БД `auth` (создаётся при первом вызове).
Raises: Raises:
AuthDatabaseNotConfiguredError: `AUTH_DATABASE_URL` пуст. AuthDatabaseNotConfiguredError: реестр не сконфигурирован (нет ни
AUTH_DATABASE_URL, ни AUTH_DB_PASSWORD) либо DSN не разобрался.
""" """
_, factory = _ensure_built() _, factory = _ensure_built()
return factory return factory

View file

@ -1,10 +1,35 @@
"""Минимальный settings для standalone trade-in MVP.""" """Минимальный settings для standalone trade-in MVP."""
from typing import Literal from typing import Literal
from urllib.parse import quote
from pydantic import Field from pydantic import Field, SecretStr, field_validator
from pydantic_settings import BaseSettings, SettingsConfigDict from pydantic_settings import BaseSettings, SettingsConfigDict
# ── Дефолтные части DSN БД `auth` (общий реестр людей, эпик «единый вход») ──────
# Вынесены константами, потому что используются ДВАЖДЫ: как `Field(default=...)`
# и как запасное значение, если переменная окружения задана пустой строкой
# (`AUTH_DB_HOST=` в .env.runtime не должен давать DSN вида `...@:5432/auth`).
#
# ⚠️ ХОСТ — главная ловушка. Внутри стека «Меры» имя `postgres` резолвится в ЕЁ
# СОБСТВЕННЫЙ контейнер: tradein-mvp/docker-compose.prod.yml объявляет сервис
# `postgres` (container_name `tradein-postgres`, сети `tradein-net` +
# `gendesign_shared`) и собирает им продуктовый DATABASE_URL —
# `postgresql+psycopg://...@postgres:5432/tradein`. БД `auth` живёт НЕ там, а на
# постгресе главного стека: корневой docker-compose.prod.yml вешает своему
# сервису `postgres` в сети `shared` (external, name `gendesign_shared`) алиас
# `gendesign-postgres`. tradein-backend к `gendesign_shared` подписан, поэтому
# `gendesign-postgres:5432` из него резолвится, а `postgres:5432` увело бы в
# чужую (свою же продуктовую) БД — там ни роли auth_app, ни таблиц реестра.
# Порт 5432 — ВНУТРИСЕТЕВОЙ порт контейнера; публикация `127.0.0.1:5432:5432` в
# корневом compose существует только ради SSH-туннеля с хоста и к этому пути
# отношения не имеет.
_AUTH_DB_DEFAULT_HOST = "gendesign-postgres"
_AUTH_DB_DEFAULT_PORT = 5432
_AUTH_DB_DEFAULT_NAME = "auth"
# Роль приложения из data/sql/auth/002_auth_app_role.sql (least privilege).
_AUTH_DB_DEFAULT_USER = "auth_app"
class Settings(BaseSettings): class Settings(BaseSettings):
model_config = SettingsConfigDict(env_file=".env", env_file_encoding="utf-8", extra="ignore") model_config = SettingsConfigDict(env_file=".env", env_file_encoding="utf-8", extra="ignore")
@ -81,7 +106,127 @@ class Settings(BaseSettings):
# к этой БД не обращается ни одна строка кода: engine не создаётся, # к этой БД не обращается ни одна строка кода: engine не создаётся,
# соединение не открывается, пустой DSN на старте ничего не роняет — см. # соединение не открывается, пустой DSN на старте ничего не роняет — см.
# app.core.auth_db (ленивое создание engine). ENV: AUTH_DATABASE_URL. # app.core.auth_db (ленивое создание engine). ENV: AUTH_DATABASE_URL.
#
# Задавать его РУКАМИ больше не обязательно — см. `resolved_auth_database_url`
# ниже: при пустом AUTH_DATABASE_URL и заданном AUTH_DB_PASSWORD DSN собирается
# из частей. Явное значение, если оно есть, по-прежнему выигрывает.
auth_database_url: str = Field(default="", validation_alias="AUTH_DATABASE_URL") auth_database_url: str = Field(default="", validation_alias="AUTH_DATABASE_URL")
# ── Части DSN БД `auth` — чтобы пароль жил в ОДНОМ месте ────────────────────
# Пароль роли auth_app уже лежит в .env.runtime отдельной переменной
# AUTH_DB_PASSWORD: её читает .forgejo/workflows/deploy.yml, чтобы выполнить
# ALTER ROLE (ops/db-bootstrap/set_auth_app_password.sql). Требовать вдобавок
# целиковый AUTH_DATABASE_URL значило бы держать ОДИН секрет в ДВУХ местах:
# сменили пароль роли, забыли переписать DSN — и вход ложится молча и целиком
# (аутентификация к БД `auth` отваливается для всех сразу).
#
# ⚠️ ops-нюанс: deploy.yml делает ALTER ROLE, читая AUTH_DB_PASSWORD из
# backend/.env.runtime ГЛАВНОГО стека, а этот контейнер читает
# tradein-mvp/backend/.env.runtime (env_file в tradein-mvp/docker-compose.prod.yml).
# Файлы разные — переменная должна быть в обоих. Зато их значение сравнимо
# глазами, чего нельзя сказать про пароль, замурованный внутрь DSN.
#
# Пусто по умолчанию — как и AUTH_DATABASE_URL: в дефолтном режиме
# IDENTITY_STORE=tradein ничего из этого не читается. ENV: AUTH_DB_PASSWORD.
#
# SecretStr, а не str: это единственное поле-секрет, добавленное здесь, и
# обёртка бесплатно закрывает канал утечки, которого не видно глазами —
# `repr(settings)` и `settings.model_dump()` печатают обычные str-поля
# ДОСЛОВНО. Сегодня их никто не рендерит (grep по app: ни дампа env, ни
# `/debug`; sentry_sdk в app/main.py идёт с include_local_variables=False),
# но появиться такой рендер может в любой момент и тихо — с SecretStr он
# напечатает `SecretStr('**********')`. Значение достаётся ровно в одном
# месте — `.get_secret_value()` в резолвере ниже.
# ⚠️ Соседние секреты (database_url, telegram_bot_token, …) остались str —
# это предсуществующее положение, а не «здесь безопасно, а там нет».
auth_db_password: SecretStr = Field(default=SecretStr(""), validation_alias="AUTH_DB_PASSWORD")
# Остальные части — с дефолтами, верными для прод-стека (см. константы выше).
# Переопределяются через ENV для dev/локального запуска (напр. AUTH_DB_HOST=
# localhost + AUTH_DB_PORT=15432 поверх SSH-туннеля).
# ENV: AUTH_DB_HOST, AUTH_DB_PORT, AUTH_DB_NAME, AUTH_DB_USER.
auth_db_host: str = Field(default=_AUTH_DB_DEFAULT_HOST, validation_alias="AUTH_DB_HOST")
auth_db_port: int = Field(default=_AUTH_DB_DEFAULT_PORT, validation_alias="AUTH_DB_PORT")
auth_db_name: str = Field(default=_AUTH_DB_DEFAULT_NAME, validation_alias="AUTH_DB_NAME")
auth_db_user: str = Field(default=_AUTH_DB_DEFAULT_USER, validation_alias="AUTH_DB_USER")
@field_validator("auth_db_port", mode="before")
@classmethod
def _blank_port_means_default(cls, value: object) -> object:
"""`AUTH_DB_PORT=` (пустая строка) → прод-дефолт, а не падение на импорте.
Симметрия с host/name/user, у которых пустое значение переменной падает
обратно на дефолт в резолвере. Для порта того же добиться нельзя: он
типизирован `int` и валидируется pydantic'ом ДО всякой нашей логики, а
`settings = Settings()` выполняется на уровне модуля то есть
`AUTH_DB_PORT=` в .env.runtime роняло бы ValidationError на импорте
конфига и уводило контейнер в restart-loop. Причём В ЛЮБОМ режиме,
включая дефолтный IDENTITY_STORE=tradein, где к БД `auth` не идёт ни
одного обращения ровно тот инвариант «дефолт не трогаем», который
держит остальной код.
Сценарий не гипотетический: ops копирует блок AUTH_DB_* в .env.runtime и
заполняет только пароль остальные строки остаются пустыми намеренно.
`mode="before"` потому что вмешаться надо ДО приведения к int.
Непустой мусор (`AUTH_DB_PORT=abc`) по-прежнему валится, и правильно:
это опечатка со смыслом, а не «оставил пустым».
"""
if isinstance(value, str) and not value.strip():
return _AUTH_DB_DEFAULT_PORT
return value
@property
def resolved_auth_database_url(self) -> str:
"""DSN БД `auth` — единственный источник правды для `app.core.auth_db`.
Приоритет:
1. `AUTH_DATABASE_URL`, если задан выигрывает всегда. Обратная
совместимость (так настроено «до») плюс аварийный обход: если DSN
понадобился нестандартный (другой хост, sslmode, пул-байпас), его
можно вписать целиком, не трогая код.
2. Иначе, если задан `AUTH_DB_PASSWORD` DSN собирается из частей.
3. Иначе пустая строка, то есть «не сконфигурировано». Это НЕ ошибка
сама по себе: при `IDENTITY_STORE=tradein` (дефолт) сюда не заходит
никто. Ошибку явную, а не тихий фолбэк поднимает `app.core.auth_db`
и только когда реестр реально понадобился.
Возвращаемое значение СОДЕРЖИТ ПАРОЛЬ: не логировать, не класть в текст
исключений, не отдавать наружу (`/health`, `/debug`, метрики).
Пароль экранируется `quote(..., safe="")`: спецсимвол (`@`, `:`, `/`, `?`,
`#`, `%`) внутри пароля иначе порвал бы URL по своей грамматике — `@`
сдвинул бы границу host, `/` открыл бы path. Разбор дал бы либо ошибку,
либо, что хуже, МОЛЧА другой хост/базу. По той же причине экранируется
имя пользователя.
А вот имя БД и хост НЕ экранируются, и это не забывчивость: SQLAlchemy
раскодирует обратно только userinfo (user/password), а path отдаёт как
есть. Прогони мы имя БД через `quote`, в сервер уехало бы литеральное
`c%2Fd` вместо `c/d` (проверено round-trip'ом в тестах). Хосту
%-кодирование тоже только мешает оно поломало бы IPv6-скобки.
"""
explicit = self.auth_database_url.strip()
if explicit:
return explicit
# `.strip()` только для ПРОВЕРКИ «задан ли»: пробельная строка в .env — это
# опечатка, а не пароль. В сам DSN идёт значение КАК ЕСТЬ (не стриппится):
# ведущий/хвостовой пробел может быть частью настоящего пароля.
# Единственная точка распаковки SecretStr во всём коде — см. поле выше.
password = self.auth_db_password.get_secret_value()
if not password.strip():
return ""
user = quote(self.auth_db_user.strip() or _AUTH_DB_DEFAULT_USER, safe="")
secret = quote(password, safe="")
host = self.auth_db_host.strip() or _AUTH_DB_DEFAULT_HOST
port = self.auth_db_port
name = self.auth_db_name.strip() or _AUTH_DB_DEFAULT_NAME
# Схема — ровно та же, что у продуктового DATABASE_URL (psycopg v3;
# `postgresql://` без суффикса увёл бы SQLAlchemy на psycopg2, которого в
# зависимостях нет).
return f"postgresql+psycopg://{user}:{secret}@{host}:{port}/{name}"
# Где живут identity (люди + сессии): # Где живут identity (люди + сессии):
# "tradein" (ДЕФОЛТ) — БД tradein, таблицы tradein_users/tradein_sessions # "tradein" (ДЕФОЛТ) — БД tradein, таблицы tradein_users/tradein_sessions
# (ровно сегодняшний прод, поведение не меняется); # (ровно сегодняшний прод, поведение не меняется);

View file

@ -0,0 +1,469 @@
"""DSN БД `auth` из частей: один секрет — одно место (эпик «единый вход»).
Зачем это вообще. Пароль роли `auth_app` уже лежит в `.env.runtime` отдельной
переменной `AUTH_DB_PASSWORD` её читает `.forgejo/workflows/deploy.yml`, чтобы
сделать `ALTER ROLE`. Требовать вдобавок целиковый `AUTH_DATABASE_URL` с тем же
паролем внутри значило бы держать ОДИН секрет в ДВУХ местах: ротировали пароль
роли, забыли переписать DSN и вход ложится молча и целиком, у всех сразу.
Поэтому DSN собирается из частей, а явный `AUTH_DATABASE_URL` остаётся
приоритетным аварийным обходом.
Что пинят тесты ниже:
1. Дефолтный режим (`IDENTITY_STORE=tradein`) НЕ требует ни одной новой
переменной прод после мержа работает ровно как сейчас.
2. Хост по умолчанию `gendesign-postgres`, НЕ `postgres`. Внутри стека
«Меры» имя `postgres` резолвится в её собственный контейнер (см. коммент у
констант в `app/core/config.py`), и дефолт `postgres` увёл бы аутентификацию
в продуктовую БД, где нет ни роли, ни таблиц реестра.
3. Явный `AUTH_DATABASE_URL` бьёт сборку из частей.
4. Пароль экранируется: спецсимвол внутри него не имеет права порвать URL
иначе разбор молча уедет на другой хост/базу.
5. Пароль НЕ попадает ни в текст исключения, ни в traceback и ни в
`repr(settings)` / `model_dump()` (поле `SecretStr`).
6. Ни одна новая переменная не способна уронить СТАРТ процесса: пустое
значение любой части (включая `int`-порт, который валидируется на импорте)
падает обратно на дефолт, а не в ValidationError.
Все «пароли» в этом файле синтетические строки для проверки экранирования,
не секреты (настоящий живёт только в `.env.runtime` на VPS).
"""
from __future__ import annotations
import os
import traceback
os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test")
import pytest
from pydantic import SecretStr
from sqlalchemy.engine.url import make_url
from app.core import auth_db, config
from tests.support.identity_modes import use_identity_mode
# Набор символов, каждый из которых имеет СВОЙ смысл в грамматике URL:
# `@` — граница userinfo/host, `:` — граница user/password и host/port,
# `/` — начало пути (имени БД), `?` — начало query, `#` — начало фрагмента,
# `%` — начало процентной последовательности.
_SPECIALS_PASSWORD = "p@ss:w/o?rd#1%"
# Все переменные, которые новый код читает из окружения: в тестах, пинящих
# ДЕФОЛТЫ КОДА, их нужно снести — на дев-машине/CI они могут быть заданы.
_AUTH_ENV_VARS = (
"AUTH_DATABASE_URL",
"AUTH_DB_PASSWORD",
"AUTH_DB_HOST",
"AUTH_DB_PORT",
"AUTH_DB_NAME",
"AUTH_DB_USER",
"IDENTITY_STORE",
)
@pytest.fixture(autouse=True)
def _clean_auth_config(monkeypatch: pytest.MonkeyPatch):
"""Чистая конфигурация реестра до и после каждого теста.
Engine БД `auth` живёт в module-global, а не в `settings`, поэтому
monkeypatch его не откатывает сбрасываем явно с обеих сторон, иначе
построенный здесь engine утёк бы в соседние тесты сьюта.
"""
auth_db.reset_auth_db()
monkeypatch.setattr(config.settings, "identity_store", "tradein")
monkeypatch.setattr(config.settings, "auth_database_url", "")
monkeypatch.setattr(config.settings, "auth_db_password", SecretStr(""))
yield
auth_db.reset_auth_db()
def _fresh_settings(monkeypatch: pytest.MonkeyPatch, **env: str) -> config.Settings:
"""Настройки, собранные ЗАНОВО из чистого окружения + *env*.
`_env_file=None` не читать локальный `.env` (дев-машина и CI держат там
своё): пиним то, что записано литералом в `Settings`, а не окружение.
"""
for name in _AUTH_ENV_VARS:
monkeypatch.delenv(name, raising=False)
for name, value in env.items():
monkeypatch.setenv(name, value)
return config.Settings(_env_file=None) # type: ignore[call-arg]
def _set_password(monkeypatch: pytest.MonkeyPatch, value: str) -> None:
"""Подменить пароль на ЖИВОМ `settings` (для тестов, идущих через auth_db).
Обязательно через `SecretStr`: поле объявлено секретным, а `validate_assignment`
у `Settings` выключен `monkeypatch.setattr` кладёт объект КАК ЕСТЬ, без
приведения типа. Голая строка тихо прошла бы присваивание и упала бы уже в
резолвере на `.get_secret_value()`.
"""
monkeypatch.setattr(config.settings, "auth_db_password", SecretStr(value))
# ---------------------------------------------------------------------------
# Дефолт: новая механика ничего не требует
# ---------------------------------------------------------------------------
def test_nothing_configured_means_empty_dsn(monkeypatch: pytest.MonkeyPatch) -> None:
"""Ни одной переменной — DSN пуст, и это не ошибка.
Главный инвариант обратной совместимости: прод сегодня живёт с
`IDENTITY_STORE=tradein` и без всяких AUTH_*-переменных. Появление сборки из
частей не имеет права ни сделать что-то обязательным, ни начать угадывать
пароль.
"""
fresh = _fresh_settings(monkeypatch)
assert fresh.identity_store == "tradein"
assert fresh.auth_db_password.get_secret_value() == ""
assert fresh.resolved_auth_database_url == "", (
"без AUTH_DATABASE_URL и без AUTH_DB_PASSWORD реестр обязан считаться "
"несконфигурированным — иначе дефолтный режим полез бы в БД `auth`"
)
def test_default_mode_never_builds_engine_even_with_password(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""`AUTH_DB_PASSWORD` сам по себе НЕ включает новый реестр.
Переменная может приехать в `.env.runtime` заранее (deploy.yml ставит ею
пароль роли задолго до переключения) это не должно ничего активировать.
Переключатель ровно один: `IDENTITY_STORE`.
"""
_set_password(monkeypatch, _SPECIALS_PASSWORD)
assert config.settings.identity_store == "tradein"
assert auth_db._engine is None
assert auth_db._session_factory is None
# ---------------------------------------------------------------------------
# Сборка из частей
# ---------------------------------------------------------------------------
def test_dsn_assembled_from_password_and_prod_defaults(monkeypatch: pytest.MonkeyPatch) -> None:
"""Задан только пароль — остальное берётся из прод-дефолтов."""
fresh = _fresh_settings(monkeypatch, AUTH_DB_PASSWORD="parts-only")
assert (
fresh.resolved_auth_database_url
== "postgresql+psycopg://auth_app:parts-only@gendesign-postgres:5432/auth"
)
def test_default_host_is_shared_alias_not_own_postgres(monkeypatch: pytest.MonkeyPatch) -> None:
"""Хост по умолчанию — алиас чужого стека, а НЕ `postgres`.
Самая дорогая из возможных ошибок дефолта. `postgres` внутри «Меры»
это её собственный контейнер (`tradein-mvp/docker-compose.prod.yml` собирает
им продуктовый DATABASE_URL), а БД `auth` живёт на постгресе главного стека,
видном по алиасу `gendesign-postgres` в сети `gendesign_shared` (корневой
`docker-compose.prod.yml`). Подключение по `postgres` не упало бы «не тем»
хостом оно бы ушло в живую БД, где просто нет ни роли, ни таблиц реестра.
"""
url = make_url(_fresh_settings(monkeypatch, AUTH_DB_PASSWORD="x").resolved_auth_database_url)
assert url.host == "gendesign-postgres"
assert url.host != "postgres"
assert url.port == 5432
assert url.database == "auth"
assert url.username == "auth_app"
def test_scheme_matches_product_dsn_psycopg_v3(monkeypatch: pytest.MonkeyPatch) -> None:
"""Схема — та же, что у основного DATABASE_URL: psycopg v3.
`postgresql://` без суффикса увёл бы SQLAlchemy на psycopg2, которого нет в
зависимостях (`ModuleNotFoundError` на первом же обращении к реестру).
"""
dsn = _fresh_settings(monkeypatch, AUTH_DB_PASSWORD="x").resolved_auth_database_url
assert dsn.startswith("postgresql+psycopg://")
assert make_url(dsn).drivername == make_url(config.settings.database_url).drivername
def test_parts_are_overridable_via_env(monkeypatch: pytest.MonkeyPatch) -> None:
"""Каждая часть переопределяется своей переменной (dev / SSH-туннель)."""
fresh = _fresh_settings(
monkeypatch,
AUTH_DB_PASSWORD="tunnel",
AUTH_DB_HOST="localhost",
AUTH_DB_PORT="15432",
AUTH_DB_NAME="auth_copy",
AUTH_DB_USER="reader",
)
assert (
fresh.resolved_auth_database_url
== "postgresql+psycopg://reader:tunnel@localhost:15432/auth_copy"
)
def test_blank_part_falls_back_to_default(monkeypatch: pytest.MonkeyPatch) -> None:
"""`AUTH_DB_HOST=` (пустая строка в .env) — опечатка, а не «хост пустой».
Без этого получился бы DSN `...@:5432/auth`, который разберётся и уедет
коннектиться в непредсказуемое место вместо внятной ошибки.
ПОРТ здесь же и намеренно: он единственный из частей типизирован `int`, и
правило «пусто дефолт» держится для него отдельным валидатором. Читатель
обоснованно распространяет правило на всю семью AUTH_DB_* пусть тест это и
подтверждает, а не только host/user.
"""
fresh = _fresh_settings(
monkeypatch,
AUTH_DB_PASSWORD="x",
AUTH_DB_HOST=" ",
AUTH_DB_USER="",
AUTH_DB_PORT="",
AUTH_DB_NAME=" ",
)
url = make_url(fresh.resolved_auth_database_url)
assert url.host == "gendesign-postgres"
assert url.username == "auth_app"
assert url.port == 5432
assert url.database == "auth"
def test_blank_port_does_not_break_default_mode(monkeypatch: pytest.MonkeyPatch) -> None:
"""`AUTH_DB_PORT=` не имеет права ронять КОНФИГ — тем более в режиме tradein.
Тут пинится не DSN, а старт процесса. `settings = Settings()` выполняется
на уровне модуля `app/core/config.py`, а `int`-поле валидируется pydantic'ом
ДО всякой логики резолвера: без `_blank_port_means_default` пустая строка
давала бы ValidationError НА ИМПОРТЕ то есть не отказ auth-пути, а
restart-loop контейнера. И это при `IDENTITY_STORE=tradein`, где новая
механика не должна читаться вообще.
Сценарий ровно тот, ради которого дефолты и заводились: ops кладёт в
.env.runtime шаблон блока AUTH_DB_*, заполняя только пароль.
"""
fresh = _fresh_settings(monkeypatch, AUTH_DB_PORT="")
assert fresh.auth_db_port == 5432
assert fresh.identity_store == "tradein"
assert fresh.resolved_auth_database_url == ""
def test_garbage_port_still_fails_loudly(monkeypatch: pytest.MonkeyPatch) -> None:
"""`AUTH_DB_PORT=abc` обязан падать: это опечатка со смыслом, не «пусто».
Граница послабления: пустую строку мы прощаем (её оставляют намеренно),
непустой мусор нет, иначе тихо уехали бы на 5432 мимо того порта, который
человек имел в виду.
"""
with pytest.raises(ValueError):
_fresh_settings(monkeypatch, AUTH_DB_PORT="abc")
def test_whitespace_only_password_is_not_configured(monkeypatch: pytest.MonkeyPatch) -> None:
"""Пароль из одних пробелов = не задан (симметрично пустому DSN)."""
assert _fresh_settings(monkeypatch, AUTH_DB_PASSWORD=" ").resolved_auth_database_url == ""
# ---------------------------------------------------------------------------
# Приоритет явного DSN
# ---------------------------------------------------------------------------
def test_explicit_dsn_wins_over_parts(monkeypatch: pytest.MonkeyPatch) -> None:
"""Явный `AUTH_DATABASE_URL` выигрывает — обратная совместимость + обход.
Кто уже настроил стек по-старому, не должен ничего менять; и остаётся
аварийный путь вписать нестандартный DSN (другой хост, `sslmode`, пул-байпас)
без правки кода.
"""
explicit = "postgresql+psycopg://other:whole-dsn@elsewhere:6432/auth?sslmode=require"
fresh = _fresh_settings(
monkeypatch,
AUTH_DATABASE_URL=explicit,
AUTH_DB_PASSWORD="parts-must-lose",
AUTH_DB_HOST="ignored-host",
)
assert fresh.resolved_auth_database_url == explicit
def test_explicit_dsn_is_stripped_and_blank_falls_through_to_parts(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""DSN из одних пробелов = не задан → сборка из частей, а не пустота.
Иначе `AUTH_DATABASE_URL=` (оставленная в файле пустая строка) заблокировала
бы работающий пароль и дала бы отказ входа на пустом месте.
"""
fresh = _fresh_settings(monkeypatch, AUTH_DATABASE_URL=" ", AUTH_DB_PASSWORD="fallback")
assert make_url(fresh.resolved_auth_database_url).password == "fallback"
# ---------------------------------------------------------------------------
# Экранирование
# ---------------------------------------------------------------------------
def test_special_chars_in_password_survive_roundtrip(monkeypatch: pytest.MonkeyPatch) -> None:
"""`@ : / ? # %` в пароле не рвут URL — разбор возвращает его дословно.
Каждый из этих символов разделитель в грамматике URL. Без экранирования
`@` сдвинул бы границу хоста, `/` открыл бы имя БД, `%` дал бы битую
процентную последовательность. Проверка round-trip через тот же парсер,
которым пользуется SQLAlchemy при создании engine.
"""
fresh = _fresh_settings(monkeypatch, AUTH_DB_PASSWORD=_SPECIALS_PASSWORD)
dsn = fresh.resolved_auth_database_url
assert "p%40ss%3Aw%2Fo%3Frd%231%25" in dsn, "пароль обязан быть percent-encoded"
assert _SPECIALS_PASSWORD not in dsn, "сырой пароль в DSN = незаэкранированные разделители"
url = make_url(dsn)
assert url.password == _SPECIALS_PASSWORD
# И, главное, разделители из пароля не увели разбор в другое место:
assert (url.username, url.host, url.port, url.database) == (
"auth_app",
"gendesign-postgres",
5432,
"auth",
)
def test_special_chars_in_user_are_escaped(monkeypatch: pytest.MonkeyPatch) -> None:
"""Имя пользователя экранируется по той же причине, что и пароль.
`@` в имени роли иначе сдвинул бы границу userinfo/host и коннект молча
пошёл бы не туда.
"""
fresh = _fresh_settings(monkeypatch, AUTH_DB_PASSWORD="x", AUTH_DB_USER="a@b")
url = make_url(fresh.resolved_auth_database_url)
assert url.username == "a@b"
assert url.host == "gendesign-postgres"
def test_dbname_is_passed_through_unescaped(monkeypatch: pytest.MonkeyPatch) -> None:
"""Имя БД НЕ percent-энкодится — иначе в сервер уедет литеральное `%2F`.
Асимметрия не случайна и легко читается как баг: SQLAlchemy раскодирует
обратно только userinfo (user/password), а path отдаёт как есть. Пропусти мы
имя БД через `quote`, `c/d` превратилось бы в `c%2Fd` уже НА СТОРОНЕ
ПОСТГРЕСА (`database "c%2Fd" does not exist`). Тест пинит именно round-trip.
"""
fresh = _fresh_settings(monkeypatch, AUTH_DB_PASSWORD="x", AUTH_DB_NAME="c/d")
assert make_url(fresh.resolved_auth_database_url).database == "c/d"
def test_engine_from_parts_carries_exact_password(monkeypatch: pytest.MonkeyPatch) -> None:
"""Сквозная проверка: engine строится из частей и несёт ИМЕННО тот пароль.
`create_engine` к серверу не ходит (пул ленивый), поэтому живая БД не нужна
но URL внутри engine уже разобран SQLAlchemy, то есть это проверка всей
цепочки «части экранирование разбор», а не только строки.
"""
use_identity_mode(monkeypatch, "auth")
_set_password(monkeypatch, _SPECIALS_PASSWORD)
engine = auth_db.get_auth_engine()
assert engine.url.password == _SPECIALS_PASSWORD
assert engine.url.host == "gendesign-postgres"
assert engine.url.database == "auth"
# repr URL маскирует пароль — на этом держится безопасность чужих логов.
assert _SPECIALS_PASSWORD not in repr(engine.url)
# ---------------------------------------------------------------------------
# Ошибки: явные, но без секрета внутри
# ---------------------------------------------------------------------------
def test_auth_mode_without_password_and_dsn_raises(monkeypatch: pytest.MonkeyPatch) -> None:
"""Режим `auth` без конфигурации — явная ошибка, как и до появления частей.
Тихий фолбэк был бы худшим исходом: вход «работал» бы по неактуальному
реестру либо молча отказывал бы всем под видом неверных паролей.
"""
use_identity_mode(monkeypatch, "auth")
with pytest.raises(auth_db.AuthDatabaseNotConfiguredError) as excinfo:
auth_db.get_auth_engine()
message = str(excinfo.value)
# Текст обязан называть ОБА пути конфигурации — иначе дежурный будет искать
# переменную, которую мы же и перестали требовать.
assert "AUTH_DB_PASSWORD" in message
assert "AUTH_DATABASE_URL" in message
assert "IDENTITY_STORE=tradein" in message
def test_malformed_explicit_dsn_never_leaks_password(monkeypatch: pytest.MonkeyPatch) -> None:
"""Нечитаемый DSN → своя ошибка; ни пароля, ни его обломков нигде.
Ловушка, ради которой существует `from None`: на «почти URL» разбор
SQLAlchemy доходит до `int(port)` и падает с `invalid literal for int() with
base 10: 'w'`, где `'w'` символ ПАРОЛЯ, съехавший на позицию порта. Без
обрыва цепочки исключений он всплыл бы в traceback («During handling of the
above exception...») то есть в логи и в GlitchTip.
"""
use_identity_mode(monkeypatch, "auth")
monkeypatch.setattr(
config.settings,
"auth_database_url",
f"garbage://auth_app:{_SPECIALS_PASSWORD}@gendesign-postgres/auth",
)
with pytest.raises(auth_db.AuthDatabaseNotConfiguredError) as excinfo:
auth_db.get_auth_engine()
exc = excinfo.value
rendered = "".join(traceback.format_exception(type(exc), exc, exc.__traceback__))
assert _SPECIALS_PASSWORD not in rendered
# Обломки пароля тоже не должны просочиться: пиним, что цепочка оборвана и
# рендерится ровно наше сообщение-константа.
assert "invalid literal for int" not in rendered
assert exc.__cause__ is None
assert exc.__suppress_context__ is True
assert str(exc) == auth_db._MALFORMED_DSN_MSG
def test_assembled_dsn_is_never_malformed(monkeypatch: pytest.MonkeyPatch) -> None:
"""Сборка из частей не может дать нечитаемый DSN даже на злом пароле.
Обратная сторона экранирования: путь «из частей» не должен уметь попадать в
ветку `_MALFORMED_DSN_MSG` вообще иначе ротация пароля с неудачным
символом положила бы вход.
"""
use_identity_mode(monkeypatch, "auth")
_set_password(monkeypatch, "://@:/?#%" + _SPECIALS_PASSWORD)
engine = auth_db.get_auth_engine()
assert engine.url.password == "://@:/?#%" + _SPECIALS_PASSWORD
assert engine.url.host == "gendesign-postgres"
def test_password_is_not_rendered_by_settings_repr_or_dump(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""Пароль не печатается ни в `repr(settings)`, ни в `model_dump()`.
Канал утечки, которого не видно глазами: обычное `str`-поле pydantic
выводит ДОСЛОВНО, поэтому любой будущий дамп настроек (диагностический
роут, `logger.debug("%s", settings)`, чужой обработчик ошибок) утащил бы
пароль роли `auth_app` в логи целиком. Поле объявлено `SecretStr` здесь
пинится именно это свойство, а не факт наличия обёртки, чтобы откат к
голому `str` красил тест.
"""
fresh = _fresh_settings(monkeypatch, AUTH_DB_PASSWORD=_SPECIALS_PASSWORD)
assert _SPECIALS_PASSWORD not in repr(fresh)
assert _SPECIALS_PASSWORD not in str(fresh.model_dump())
# …и при этом значение достаётся: маскировка не должна ломать работу.
assert fresh.auth_db_password.get_secret_value() == _SPECIALS_PASSWORD
assert make_url(fresh.resolved_auth_database_url).password == _SPECIALS_PASSWORD

View file

@ -33,6 +33,7 @@ os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:
import pytest import pytest
from fastapi import Depends, FastAPI from fastapi import Depends, FastAPI
from fastapi.testclient import TestClient from fastapi.testclient import TestClient
from pydantic import SecretStr
from sqlalchemy import Engine from sqlalchemy import Engine
from app.core import auth_db, config from app.core import auth_db, config
@ -63,6 +64,15 @@ def _clean_identity_state(monkeypatch: pytest.MonkeyPatch):
auth_db.reset_auth_db() auth_db.reset_auth_db()
monkeypatch.setattr(config.settings, "identity_store", "tradein") monkeypatch.setattr(config.settings, "identity_store", "tradein")
monkeypatch.setattr(config.settings, "auth_database_url", "") monkeypatch.setattr(config.settings, "auth_database_url", "")
# Второй источник DSN: при пустом AUTH_DATABASE_URL он собирается из
# AUTH_DB_PASSWORD + частей (см. Settings.resolved_auth_database_url). Не
# обнули его здесь — и заданная в окружении переменная сделала бы реестр
# «сконфигурированным»: тесты про «пустой DSN → явная ошибка» позеленели бы
# мимо проверяемого поведения.
# SecretStr, а не "": поле объявлено `SecretStr`, а `validate_assignment` у
# Settings выключен — monkeypatch кладёт значение КАК ЕСТЬ, без приведения
# типа, и голая строка уронила бы резолвер на `.get_secret_value()`.
monkeypatch.setattr(config.settings, "auth_db_password", SecretStr(""))
yield yield
auth_db.reset_auth_db() auth_db.reset_auth_db()