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
Owner

Продолжение эпика «единый вход» (#2597#2602#2608). Прод не меняется: при IDENTITY_STORE=tradein (дефолт) ничего из этого не читается и соединение с auth не открывается.

Зачем

Чтобы включить IDENTITY_STORE=auth, до этого требовалось положить в runtime-окружение полный AUTH_DATABASE_URL с паролем внутри — при том что пароль уже лежит там же отдельной переменной AUTH_DB_PASSWORD (её читает deploy-пайплайн для ALTER ROLE).

Один секрет в двух местах разъезжается. Сценарий отказа: сменили пароль роли — DSN остался старым — вход ложится молча и целиком, потому что реестр людей один на оба продукта.

Всплыло не в теории: ровно на этом я упёрся, доводя эпик на проде. Пароль был сгенерирован и записан так, чтобы не проходить через сессию, — а значит собрать из него DSN было нечем, и единственным выходом оставалось перегенерировать и получить вторую строку AUTH_DB_PASSWORD в том же файле, из которых deploy-пайплайн взял бы первую. Это и есть та самая тихая рассинхронизация, только сразу.

Что теперь

  1. Явный AUTH_DATABASE_URL выигрывает — обратная совместимость и аварийный обход (например sslmode).
  2. Если он пуст, а AUTH_DB_PASSWORD задан — DSN собирается из частей.
  3. Ни того, ни другого — «не сконфигурировано», как и раньше: дефолтный режим сюда не заходит, а при IDENTITY_STORE=auth первое обращение поднимает AuthDatabaseNotConfiguredError.

Части переопределяемы через AUTH_DB_HOST / _PORT / _NAME / _USER.

Дефолт хоста — gendesign-postgres, и это не косметика

Внутри стека «Меры» имя postgres резолвится в её собственный контейнер (tradein-postgres) — им же собран продуктовый DATABASE_URL. Дефолт 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: invalid literal for int() ... 'w', где 'w'символ пароля, съехавший на позицию порта. Без обрыва цепочки обломок печатался бы в traceback — то есть в логи и GlitchTip. Теперь ValueError/ArgumentError перевыбрасываются своим сообщением from None; тест рендерит traceback целиком и проверяет отсутствие пароля.

Починена ловушка, ломавшая дефолтный режим

Пустое значение AUTH_DB_PORT роняло импорт конфига. Порт типизирован int и валидируется pydantic'ом до всякой нашей логики, а settings создаётся на уровне модуля — то есть пустая строка уводила контейнер в restart-loop в любом режиме, включая дефолтный tradein, где к БД auth нет ни одного обращения. Ровно тот инвариант «новые переменные не трогают дефолт», который держит остальной код.

Сценарий не гипотетический: ops раскладывает блок AUTH_DB_* и заполняет только пароль. Непустой мусор (AUTH_DB_PORT=abc) по-прежнему валится — это опечатка со смыслом, а не «оставил пустым».

Test plan

  • +16 тестов (tests/test_auth_dsn_from_parts.py): сборка из частей, приоритет явного DSN, экранирование спецсимволов, отсутствие пароля в traceback, поведение дефолтного режима без переменных
  • 118 passed на связке auth-сьютов (test_auth_dsn_from_parts, test_identity_store, test_auth_api, test_auth_session)
  • Полный сьют: 3065 passed. Единственный красный — tests/test_search_api.py::test_search_cache_hit, предсуществующий: контрольный прогон на чистом main даёт тот же
  • Проверено исполнением: AUTH_DB_PORT='' → порт 5432; пароль p@ss:w/o?rd#1% экранируется, в открытом виде в DSN не встречается, хост gendesign-postgres
  • ruff check по изменённым файлам — чисто (3 ошибки в test_estimator_pure_units.py предсуществующие, файл не тронут: локальный ruff 0.15 против пиннутого 0.7.4)

Известное ограничение

auth_db_password — обычное str, поэтому repr(settings) содержит его открытым текстом. Сегодня это недостижимо (никто не рендерит settings, include_local_variables=False в Sentry), и класс проблемы предсуществующий — тот же repr выдаёт database_url со всеми остальными секретами. Переводить на SecretStr одно поле из десятка — непоследовательно; если делать, то разом по всему конфигу, отдельным PR.

Состояние прода на момент этого PR

Подготовка к переключению уже выполнена и проверена: роль auth_app получила пароль, 13 аккаунтов перенесены с bcrypt-хешами и ролями, 8 живых сессий перенесены с ремаппингом user_id по username. access_state намеренно не копировался — в tradein_users состояние Брусники выражено как is_active=false, что дало бы disabled вместо верного trial_expired.

После мержа для переключения останется только IDENTITY_STORE=auth + пересоздание контейнера (deploy-tradein.yml не делает --force-recreate при IMAGE_TAG=latest). Откат — вернуть переменную и перезапустить.

Продолжение эпика «единый вход» (#2597 → #2602 → #2608). **Прод не меняется**: при `IDENTITY_STORE=tradein` (дефолт) ничего из этого не читается и соединение с `auth` не открывается. ## Зачем Чтобы включить `IDENTITY_STORE=auth`, до этого требовалось положить в runtime-окружение полный `AUTH_DATABASE_URL` **с паролем внутри** — при том что пароль уже лежит там же отдельной переменной `AUTH_DB_PASSWORD` (её читает deploy-пайплайн для `ALTER ROLE`). Один секрет в двух местах разъезжается. Сценарий отказа: сменили пароль роли — DSN остался старым — вход ложится молча и **целиком**, потому что реестр людей один на оба продукта. Всплыло не в теории: ровно на этом я упёрся, доводя эпик на проде. Пароль был сгенерирован и записан так, чтобы не проходить через сессию, — а значит собрать из него DSN было нечем, и единственным выходом оставалось перегенерировать и получить вторую строку `AUTH_DB_PASSWORD` в том же файле, из которых deploy-пайплайн взял бы первую. Это и есть та самая тихая рассинхронизация, только сразу. ## Что теперь 1. Явный `AUTH_DATABASE_URL` **выигрывает** — обратная совместимость и аварийный обход (например `sslmode`). 2. Если он пуст, а `AUTH_DB_PASSWORD` задан — DSN собирается из частей. 3. Ни того, ни другого — «не сконфигурировано», как и раньше: дефолтный режим сюда не заходит, а при `IDENTITY_STORE=auth` первое обращение поднимает `AuthDatabaseNotConfiguredError`. Части переопределяемы через `AUTH_DB_HOST` / `_PORT` / `_NAME` / `_USER`. ## Дефолт хоста — `gendesign-postgres`, и это не косметика Внутри стека «Меры» имя `postgres` резолвится в **её собственный** контейнер (`tradein-postgres`) — им же собран продуктовый `DATABASE_URL`. Дефолт `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: invalid literal for int() ... 'w'`, где `'w'` — **символ пароля**, съехавший на позицию порта. Без обрыва цепочки обломок печатался бы в traceback — то есть в логи и GlitchTip. Теперь `ValueError`/`ArgumentError` перевыбрасываются своим сообщением `from None`; тест рендерит traceback целиком и проверяет отсутствие пароля. ## Починена ловушка, ломавшая дефолтный режим Пустое значение `AUTH_DB_PORT` роняло импорт конфига. Порт типизирован `int` и валидируется pydantic'ом до всякой нашей логики, а `settings` создаётся на уровне модуля — то есть пустая строка уводила контейнер в restart-loop **в любом режиме**, включая дефолтный `tradein`, где к БД `auth` нет ни одного обращения. Ровно тот инвариант «новые переменные не трогают дефолт», который держит остальной код. Сценарий не гипотетический: ops раскладывает блок `AUTH_DB_*` и заполняет только пароль. Непустой мусор (`AUTH_DB_PORT=abc`) по-прежнему валится — это опечатка со смыслом, а не «оставил пустым». ## Test plan - [x] +16 тестов (`tests/test_auth_dsn_from_parts.py`): сборка из частей, приоритет явного DSN, экранирование спецсимволов, отсутствие пароля в traceback, поведение дефолтного режима без переменных - [x] 118 passed на связке auth-сьютов (`test_auth_dsn_from_parts`, `test_identity_store`, `test_auth_api`, `test_auth_session`) - [x] Полный сьют: 3065 passed. Единственный красный — `tests/test_search_api.py::test_search_cache_hit`, **предсуществующий**: контрольный прогон на чистом `main` даёт тот же - [x] Проверено исполнением: `AUTH_DB_PORT=''` → порт 5432; пароль `p@ss:w/o?rd#1%` экранируется, в открытом виде в DSN не встречается, хост `gendesign-postgres` - [x] `ruff check` по изменённым файлам — чисто (3 ошибки в `test_estimator_pure_units.py` предсуществующие, файл не тронут: локальный ruff 0.15 против пиннутого 0.7.4) ## Известное ограничение `auth_db_password` — обычное `str`, поэтому `repr(settings)` содержит его открытым текстом. Сегодня это недостижимо (никто не рендерит settings, `include_local_variables=False` в Sentry), и класс проблемы предсуществующий — тот же `repr` выдаёт `database_url` со всеми остальными секретами. Переводить на `SecretStr` одно поле из десятка — непоследовательно; если делать, то разом по всему конфигу, отдельным PR. ## Состояние прода на момент этого PR Подготовка к переключению уже выполнена и проверена: роль `auth_app` получила пароль, 13 аккаунтов перенесены с bcrypt-хешами и ролями, 8 живых сессий перенесены с ремаппингом `user_id` по `username`. `access_state` намеренно не копировался — в `tradein_users` состояние Брусники выражено как `is_active=false`, что дало бы `disabled` вместо верного `trial_expired`. После мержа для переключения останется только `IDENTITY_STORE=auth` + пересоздание контейнера (`deploy-tradein.yml` не делает `--force-recreate` при `IMAGE_TAG=latest`). Откат — вернуть переменную и перезапустить.
lekss361 added 1 commit 2026-08-02 09:06:28 +00:00
feat(tradein): DSN к БД auth собирается из AUTH_DB_PASSWORD, а не дублирует секрет
All checks were successful
CI Trade-In / changes (pull_request) Successful in 9s
CI / changes (pull_request) Successful in 10s
CI Trade-In / frontend-checks (pull_request) Has been skipped
CI / backend-tests (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Has been skipped
CI Trade-In / backend-tests (pull_request) Successful in 2m43s
8db67fe4d0
Чтобы включить 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 не встречается.
lekss361 merged commit 42adfe0900 into main 2026-08-02 09:09:55 +00:00
lekss361 deleted branch feat/auth-dsn-from-password 2026-08-02 09:09:55 +00:00
Sign in to join this conversation.
No reviewers
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference: lekss361/gendesign#2619
No description provided.