feat(tradein): DSN к БД auth собирается из AUTH_DB_PASSWORD, а не дублирует секрет #2619
No reviewers
Labels
No labels
Fable 5 ревью
GG-форсайт
admin
analytics
auth
automation
bug
business
chore
ci
compliance
data
data-moat
docs
duplicate
dx
enhancement
feedback/max
generative
needs-discussion
needs-human
observability
pause-bots
performance
priority/p0
priority/p1
priority/p2
priority/p3
scope/backend
scope/db
scope/devops
scope/frontend
scope/qa
scrapers
security
site-finder
stage/1
stage/2
status/blocked
status/done
status/needs-analysis
status/needs-fix
status/qa
status/ready
status/review
status/wip
tech-debt
tradein
ux
week ревью 1
wontfix
ИРД
вторичка
No milestone
No project
No assignees
1 participant
Notifications
Due date
No due date set.
Dependencies
No dependencies set.
Reference: lekss361/gendesign#2619
Loading…
Add table
Reference in a new issue
No description provided.
Delete branch "feat/auth-dsn-from-password"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Продолжение эпика «единый вход» (#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-пайплайн взял бы первую. Это и есть та самая тихая рассинхронизация, только сразу.Что теперь
AUTH_DATABASE_URLвыигрывает — обратная совместимость и аварийный обход (напримерsslmode).AUTH_DB_PASSWORDзадан — DSN собирается из частей.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
tests/test_auth_dsn_from_parts.py): сборка из частей, приоритет явного DSN, экранирование спецсимволов, отсутствие пароля в traceback, поведение дефолтного режима без переменныхtest_auth_dsn_from_parts,test_identity_store,test_auth_api,test_auth_session)tests/test_search_api.py::test_search_cache_hit, предсуществующий: контрольный прогон на чистомmainдаёт тот жеAUTH_DB_PORT=''→ порт 5432; парольp@ss:w/o?rd#1%экранируется, в открытом виде в DSN не встречается, хостgendesign-postgresruff 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). Откат — вернуть переменную и перезапустить.Чтобы включить 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 не встречается.