From 8db67fe4d0d7465b4fcd56cf8c11ebb2ce70206d Mon Sep 17 00:00:00 2001 From: bot-backend Date: Sun, 2 Aug 2026 12:05:41 +0300 Subject: [PATCH] =?UTF-8?q?feat(tradein):=20DSN=20=D0=BA=20=D0=91=D0=94=20?= =?UTF-8?q?auth=20=D1=81=D0=BE=D0=B1=D0=B8=D1=80=D0=B0=D0=B5=D1=82=D1=81?= =?UTF-8?q?=D1=8F=20=D0=B8=D0=B7=20AUTH=5FDB=5FPASSWORD,=20=D0=B0=20=D0=BD?= =?UTF-8?q?=D0=B5=20=D0=B4=D1=83=D0=B1=D0=BB=D0=B8=D1=80=D1=83=D0=B5=D1=82?= =?UTF-8?q?=20=D1=81=D0=B5=D0=BA=D1=80=D0=B5=D1=82?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Чтобы включить IDENTITY_STORE=auth, до этого требовалось положить в runtime-окружение полный AUTH_DATABASE_URL с паролем внутри — при том что пароль уже лежит там же отдельной переменной AUTH_DB_PASSWORD (её читает deploy-пайплайн для ALTER ROLE). Один секрет в двух местах разъезжается: сменили пароль роли, DSN остался старым — вход ложится молча и целиком. Теперь явный AUTH_DATABASE_URL по-прежнему выигрывает (обратная совместимость и аварийный обход, скажем sslmode); если он пуст, а AUTH_DB_PASSWORD задан, DSN собирается из частей. Части переопределяемы через AUTH_DB_HOST, _PORT, _NAME, _USER. Дефолт хоста — gendesign-postgres, не postgres. Внутри стека «Меры» имя postgres резолвится в ЕЁ СОБСТВЕННЫЙ контейнер (tradein-postgres), и такой дефолт не упал бы «неизвестным хостом», а молча увёл бы аутентификацию в живую БД tradein, где нет ни роли auth_app, ни таблиц реестра. Нужный сервер виден по алиасу gendesign-postgres в сети gendesign_shared, к которой tradein-backend подписан. Пароль и имя пользователя экранируются quote(safe=""). Имя БД и хост — намеренно нет: SQLAlchemy раскодирует обратно только userinfo, а path отдаёт как есть, поэтому quote("c/d") уехало бы в сервер литеральным c%2Fd. Найдено прогоном, закреплено тестом. Закрыта реальная утечка на пути ЯВНОГО AUTH_DATABASE_URL: на «почти URL» SQLAlchemy доходит до int(port) и падает ValueError с символом ПАРОЛЯ в тексте (он съезжает на позицию порта). Без обрыва цепочки обломок печатался бы в traceback, то есть в логи и GlitchTip. Теперь ValueError и ArgumentError перевыбрасываются своим сообщением from None; тест рендерит traceback целиком и проверяет, что пароля там нет. Пустое значение AUTH_DB_PORT больше не роняет импорт. Порт типизирован int и валидируется до всякой нашей логики, а settings создаётся на уровне модуля — пустая строка уводила контейнер в restart-loop В ЛЮБОМ режиме, включая дефолтный tradein, где к БД auth нет ни одного обращения. Прод не меняется: при IDENTITY_STORE=tradein (дефолт) ничего из этого не читается и соединение с auth не открывается. Тесты: +16 профильных, 118 passed на связке auth-сьютов. Проверено исполнением: пустой порт даёт 5432; пароль со спецсимволами экранируется и в открытом виде в DSN не встречается. --- tradein-mvp/backend/app/core/auth_db.py | 73 ++- tradein-mvp/backend/app/core/config.py | 147 +++++- .../backend/tests/test_auth_dsn_from_parts.py | 469 ++++++++++++++++++ .../backend/tests/test_identity_store.py | 10 + 4 files changed, 680 insertions(+), 19 deletions(-) create mode 100644 tradein-mvp/backend/tests/test_auth_dsn_from_parts.py diff --git a/tradein-mvp/backend/app/core/auth_db.py b/tradein-mvp/backend/app/core/auth_db.py index a7d1f5b4..0b87bc54 100644 --- a/tradein-mvp/backend/app/core/auth_db.py +++ b/tradein-mvp/backend/app/core/auth_db.py @@ -3,23 +3,33 @@ Отдельный модуль, а не ещё пара строк в `app.core.db`, ровно по одной причине: `app.core.db` создаёт engine НА ИМПОРТЕ (`create_engine(settings.database_url)` в теле модуля). Сделай мы так же для БД `auth` — приложение начало бы падать на -старте везде, где `AUTH_DATABASE_URL` не задан, а не задан он сейчас ВЕЗДЕ: на -проде роль `auth_app` ещё без пароля, в тестах этой БД нет вовсе. Здесь engine -создаётся ЛЕНИВО, при первом реальном обращении. +старте везде, где реестр не сконфигурирован, а не сконфигурирован он сейчас +ВЕЗДЕ: на проде роль `auth_app` ещё без пароля, в тестах этой БД нет вовсе. +Здесь engine создаётся ЛЕНИВО, при первом реальном обращении. Контракт (⚠️ после мержа прод обязан работать ТОЧНО как сейчас): * `settings.identity_store == "tradein"` (дефолт) — в этот модуль не заходит - никто: `app.services.identity_store` берёт сессию из `app.core.db`. Пустой - `AUTH_DATABASE_URL` при этом не ошибка ни на импорте, ни в рантайме; ни одно - соединение с БД `auth` не открывается. - * `settings.identity_store == "auth"` + пустой DSN — первое же обращение - поднимает `AuthDatabaseNotConfiguredError` с внятным текстом. Именно - исключение, а НЕ тихий откат на 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 ленивый), так что даже после первого обращения реальный коннект открывается только на первом запросе — но ошибку конфигурации мы обязаны отдать раньше, чем это станет @@ -33,19 +43,32 @@ 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`, но `AUTH_DATABASE_URL` пуст — идентичность негде читать.""" + """`IDENTITY_STORE=auth`, а DSN БД `auth` не задан/не разобрался.""" _NOT_CONFIGURED_MSG = ( - "IDENTITY_STORE=auth, но AUTH_DATABASE_URL пуст: подключаться к общему реестру " - "людей (БД `auth`) не к чему. Задай DSN роли auth_app в .env.runtime — либо " - "верни IDENTITY_STORE=tradein (старое поведение на tradein_users/tradein_sessions)." + "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 резолвит сессию на каждом @@ -58,11 +81,23 @@ _session_factory: sessionmaker[Session] | None = None def _build() -> tuple[Engine, sessionmaker[Session]]: - """Создаёт engine + session-factory по текущему DSN. Пустой DSN → явная ошибка.""" - dsn = settings.auth_database_url.strip() + """Создаёт 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) - 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) return engine, factory @@ -81,7 +116,8 @@ def get_auth_engine() -> Engine: """Engine БД `auth` (создаётся при первом вызове). Raises: - AuthDatabaseNotConfiguredError: `AUTH_DATABASE_URL` пуст. + AuthDatabaseNotConfiguredError: реестр не сконфигурирован (нет ни + AUTH_DATABASE_URL, ни AUTH_DB_PASSWORD) либо DSN не разобрался. """ engine, _ = _ensure_built() return engine @@ -91,7 +127,8 @@ def get_auth_session_factory() -> sessionmaker[Session]: """Session-factory БД `auth` (создаётся при первом вызове). Raises: - AuthDatabaseNotConfiguredError: `AUTH_DATABASE_URL` пуст. + AuthDatabaseNotConfiguredError: реестр не сконфигурирован (нет ни + AUTH_DATABASE_URL, ни AUTH_DB_PASSWORD) либо DSN не разобрался. """ _, factory = _ensure_built() return factory diff --git a/tradein-mvp/backend/app/core/config.py b/tradein-mvp/backend/app/core/config.py index 491c4600..1350b38d 100644 --- a/tradein-mvp/backend/app/core/config.py +++ b/tradein-mvp/backend/app/core/config.py @@ -1,10 +1,35 @@ """Минимальный settings для standalone trade-in MVP.""" 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 +# ── Дефолтные части 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): model_config = SettingsConfigDict(env_file=".env", env_file_encoding="utf-8", extra="ignore") @@ -81,7 +106,127 @@ class Settings(BaseSettings): # к этой БД не обращается ни одна строка кода: engine не создаётся, # соединение не открывается, пустой DSN на старте ничего не роняет — см. # 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") + + # ── Части 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 (люди + сессии): # "tradein" (ДЕФОЛТ) — БД tradein, таблицы tradein_users/tradein_sessions # (ровно сегодняшний прод, поведение не меняется); diff --git a/tradein-mvp/backend/tests/test_auth_dsn_from_parts.py b/tradein-mvp/backend/tests/test_auth_dsn_from_parts.py new file mode 100644 index 00000000..e6df03cc --- /dev/null +++ b/tradein-mvp/backend/tests/test_auth_dsn_from_parts.py @@ -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 diff --git a/tradein-mvp/backend/tests/test_identity_store.py b/tradein-mvp/backend/tests/test_identity_store.py index 59eba9f5..cf2c69d2 100644 --- a/tradein-mvp/backend/tests/test_identity_store.py +++ b/tradein-mvp/backend/tests/test_identity_store.py @@ -33,6 +33,7 @@ os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost: import pytest from fastapi import Depends, FastAPI from fastapi.testclient import TestClient +from pydantic import SecretStr from sqlalchemy import Engine from app.core import auth_db, config @@ -63,6 +64,15 @@ def _clean_identity_state(monkeypatch: pytest.MonkeyPatch): auth_db.reset_auth_db() monkeypatch.setattr(config.settings, "identity_store", "tradein") 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 auth_db.reset_auth_db() -- 2.45.3