feat(sitefinder): «Птица» принимает сессию общего реестра [PR-2c/6] #2635

Merged
lekss361 merged 1 commit from feat/auth-ptica-session-guard into main 2026-08-02 14:18:24 +00:00
Owner

Четвёртый PR эпика «единый вход» (#2597#2602#2608#2619). Самая рискованная часть: единственная, где ошибка открывает доступ, а не закрывает.

Прод после мержа работает ровно как сейчас. AUTH_MODE=legacy — дефолт: соединение с БД auth не открывается, кука не читается, отсутствие настроек не роняет старт. Popup Caddy стоит и снимается последним PR — инвариант «гейт уходит последним» не нарушен.

Что делает

У Site Finder не было авторизации вообще: rbac_guard доверял заголовку X-Authenticated-User, который проставляет Caddy из basic_auth. Снять popup сегодня означало бы не оставить у продукта ничего.

Теперь guard умеет резолвить сессионную куку общего реестра. Выдавать сессии «Птица» не будет — логин один, у «Меры», а кука host-only на gendsgn.ru с path=/ и так долетает до обоих продуктов. Меньше кода и меньше мест, где можно ошибиться.

Режим трёхзначный, а не булев — прямое следствие ревью

AUTH_MODE: legacy | dual | db_only.

При булевом флаге фолбэк «сессия не нашлась → верим заголовку» после снятия popup превращался бы в полный обход аутентификации, и ничто в коде не заставило бы про него вспомнить. В db_only легаси-ветка недостижима: 401. Забыть переключиться теперь нельзя молча — это отдельное состояние, а не отсутствие флага.

Три ревьюера независимо нашли один и тот же high

Sync-запрос к БД в async def rbac_guard блокирует event loop на каждом non-public запросе — ровно инцидент #1202, который в этом же файле уже лечили. Исправлено: кука разбирается на loop'е, резолв уехал в run_in_threadpool, в поток передаётся только строка токена. Запрос без куки не платит ни за поток, ни за коннект — то есть весь сегодняшний трафик не платит ничего.

Что ещё вскрыло ревью

Срок годности решали часы приложения, а строку переписывали часы БД. Проверка expires_at шла в Python, а sliding-refresh обновлял строку через now() базы. При расхождении часов истёкшая сессия не просто проходила — она продлевалась, то есть воскресала навсегда. Теперь expires_at > now() в самом SELECT, один источник времени; питоновская проверка оставлена вторым поясом.

Fail-fast проверял синтаксис DSN, а не работоспособность. Неверный пароль или хост выглядели бы как «ни у кого нет сессии» — сутками, потому что ошибку ловил бы except внутри guard'а. Теперь на старте открывается соединение и делается SELECT 1; в legacy пробы нет вовсе.

Тела SQL не были покрыты ничем — доказано мутацией: правка запроса не красила ни одного теста. Добавлены ассерты формы (FROM/JOIN/WHERE/expires_at > now(), обе колонки UPDATE, CAST(:ttl_hours AS integer)).

Плюс connect_timeout и statement_timeout по 3с (недоступный хост вешал коннект на минуты) и throttling логов сбоя реестра (иначе шторм событий в GlitchTip на каждый запрос).

Ловушка хоста, зеркальная к «Мере»

Дефолт — postgres. У «Меры» он gendesign-postgres, потому что внутри её стека имя postgres — чужой контейнер; здесь стек главный, и postgres из корневого compose и есть нужный сервер. Алиас gendesign-postgres дефолтом был бы багом: контейнер beat состоит только в сети default, и это имя из него не разрезолвится.

Сверка с ролевой картой — сделана на живом проде

Агент оставил это долгом (не мог сделать кросс-БД запрос); проверил сам:

  • все 13 юзеров auth.users присутствуют в auth/roles.yaml → ни один не получит 403 на всё после включения;
  • четыре QA-фикстуры (admintest, pilottest, analysttest, expiredtest) есть в yaml, но нет в реестре — после снятия popup войти ими через браузер будет нельзя, только внутрисетевым заголовком. Если они нужны для смоука через UI — их надо завести в реестре отдельно;
  • user2 = expired в yaml и trial_expired в реестре — согласовано, сессию он получить не может в принципе.

Осознанный долг, вписан ⚠️-блоком перед guard'ом

paths/deny из roles.yaml бэкендом не применяются — их энфорсит фронтовый RouteGuard; guard проверяет только известность username и admin-пути. Это предсуществующее поведение: менять его здесь значило бы изменить и легаси-ветку, то есть нарушить «дефолт = сегодня». Записано как долг с двумя рубильниками отзыва, а не замолчано.

Test plan

  • uv run pytest -q4594 passed, 0 failed, 52 skipped
  • ruff check по изменённым файлам — чисто
  • Главный тест — подделка: валидная кука + присланный клиентом X-Authenticated-User другого пользователя → выигрывает кука, и роут, читающий заголовок напрямую, видит владельца куки. У «Птицы» таких прямых читателей одиннадцать, поэтому перезапись ASGI-scope обязана быть полной, а не skip-if-present (CRITICAL #2552)
  • Сверка реестра с roles.yaml на живом проде
  • Включение режима на проде — не в этом PR

Дальше

PR-3 — нейтральная форма входа на обоих фронтах. PR-4 — canary. PR-5 — cutover: снятие popup у Site Finder (import caddy/users.caddy.snippet на строках 176 и 272, четыре header_up Set-формы на 180/186/276/282 → -X-Authenticated-User, блок :80 → redir на https). PR-6 — уборка.

Отдельно к PR-5: /api/v1/trade-in/* в бэкенде Site Finder — легаси-поверхность, которую matcher @tradein не ловит; сегодня она закрыта именно popup'ом и после cutover останется без гейта. Закрыть до снятия.

Четвёртый PR эпика «единый вход» (#2597 → #2602 → #2608 → #2619). Самая рискованная часть: единственная, где ошибка **открывает** доступ, а не закрывает. **Прод после мержа работает ровно как сейчас.** `AUTH_MODE=legacy` — дефолт: соединение с БД `auth` не открывается, кука не читается, отсутствие настроек не роняет старт. Popup Caddy стоит и снимается последним PR — инвариант «гейт уходит последним» не нарушен. ## Что делает У Site Finder не было авторизации **вообще**: `rbac_guard` доверял заголовку `X-Authenticated-User`, который проставляет Caddy из basic_auth. Снять popup сегодня означало бы не оставить у продукта ничего. Теперь guard умеет резолвить сессионную куку общего реестра. **Выдавать** сессии «Птица» не будет — логин один, у «Меры», а кука host-only на `gendsgn.ru` с `path=/` и так долетает до обоих продуктов. Меньше кода и меньше мест, где можно ошибиться. ## Режим трёхзначный, а не булев — прямое следствие ревью `AUTH_MODE: legacy | dual | db_only`. При булевом флаге фолбэк «сессия не нашлась → верим заголовку» после снятия popup превращался бы в **полный обход аутентификации**, и ничто в коде не заставило бы про него вспомнить. В `db_only` легаси-ветка недостижима: 401. Забыть переключиться теперь нельзя молча — это отдельное состояние, а не отсутствие флага. ## Три ревьюера независимо нашли один и тот же high Sync-запрос к БД в `async def rbac_guard` блокирует event loop на **каждом** non-public запросе — ровно инцидент **#1202**, который в этом же файле уже лечили. Исправлено: кука разбирается на loop'е, резолв уехал в `run_in_threadpool`, в поток передаётся только строка токена. Запрос без куки не платит ни за поток, ни за коннект — то есть весь сегодняшний трафик не платит ничего. ## Что ещё вскрыло ревью **Срок годности решали часы приложения, а строку переписывали часы БД.** Проверка `expires_at` шла в Python, а sliding-refresh обновлял строку через `now()` базы. При расхождении часов истёкшая сессия не просто проходила — она продлевалась, то есть **воскресала навсегда**. Теперь `expires_at > now()` в самом SELECT, один источник времени; питоновская проверка оставлена вторым поясом. **Fail-fast проверял синтаксис DSN, а не работоспособность.** Неверный пароль или хост выглядели бы как «ни у кого нет сессии» — сутками, потому что ошибку ловил бы `except` внутри guard'а. Теперь на старте открывается соединение и делается `SELECT 1`; в `legacy` пробы нет вовсе. **Тела SQL не были покрыты ничем** — доказано мутацией: правка запроса не красила ни одного теста. Добавлены ассерты формы (`FROM`/`JOIN`/`WHERE`/`expires_at > now()`, обе колонки UPDATE, `CAST(:ttl_hours AS integer)`). Плюс `connect_timeout` и `statement_timeout` по 3с (недоступный хост вешал коннект на минуты) и throttling логов сбоя реестра (иначе шторм событий в GlitchTip на каждый запрос). ## Ловушка хоста, зеркальная к «Мере» Дефолт — `postgres`. У «Меры» он `gendesign-postgres`, потому что внутри её стека имя `postgres` — чужой контейнер; здесь стек главный, и `postgres` из корневого compose и есть нужный сервер. Алиас `gendesign-postgres` дефолтом был бы багом: контейнер `beat` состоит только в сети `default`, и это имя из него не разрезолвится. ## Сверка с ролевой картой — сделана на живом проде Агент оставил это долгом (не мог сделать кросс-БД запрос); проверил сам: - все **13** юзеров `auth.users` присутствуют в `auth/roles.yaml` → ни один не получит 403 на всё после включения; - четыре QA-фикстуры (`admintest`, `pilottest`, `analysttest`, `expiredtest`) есть в yaml, но **нет в реестре** — после снятия popup войти ими через браузер будет нельзя, только внутрисетевым заголовком. Если они нужны для смоука через UI — их надо завести в реестре отдельно; - `user2` = `expired` в yaml и `trial_expired` в реестре — согласовано, сессию он получить не может в принципе. ## Осознанный долг, вписан ⚠️-блоком перед guard'ом `paths`/`deny` из `roles.yaml` бэкендом **не применяются** — их энфорсит фронтовый `RouteGuard`; guard проверяет только известность username и admin-пути. Это **предсуществующее** поведение: менять его здесь значило бы изменить и легаси-ветку, то есть нарушить «дефолт = сегодня». Записано как долг с двумя рубильниками отзыва, а не замолчано. ## Test plan - [x] `uv run pytest -q` — **4594 passed, 0 failed**, 52 skipped - [x] `ruff check` по изменённым файлам — чисто - [x] Главный тест — **подделка**: валидная кука + присланный клиентом `X-Authenticated-User` другого пользователя → выигрывает кука, и роут, читающий заголовок напрямую, видит владельца куки. У «Птицы» таких прямых читателей **одиннадцать**, поэтому перезапись ASGI-scope обязана быть полной, а не skip-if-present (CRITICAL #2552) - [x] Сверка реестра с `roles.yaml` на живом проде - [ ] Включение режима на проде — **не в этом PR** ## Дальше PR-3 — нейтральная форма входа на обоих фронтах. PR-4 — canary. PR-5 — cutover: снятие popup у Site Finder (`import caddy/users.caddy.snippet` на строках 176 и 272, четыре `header_up` Set-формы на 180/186/276/282 → `-X-Authenticated-User`, блок `:80` → redir на https). PR-6 — уборка. Отдельно к PR-5: `/api/v1/trade-in/*` **в бэкенде Site Finder** — легаси-поверхность, которую matcher `@tradein` не ловит; сегодня она закрыта именно popup'ом и после cutover останется без гейта. Закрыть до снятия.
lekss361 added 1 commit 2026-08-02 14:01:48 +00:00
feat(sitefinder): «Птица» принимает сессию общего реестра [PR-2c/6]
All checks were successful
CI Trade-In / changes (pull_request) Successful in 8s
CI / changes (pull_request) Successful in 8s
CI Trade-In / backend-tests (pull_request) Has been skipped
CI Trade-In / frontend-checks (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Successful in 1m59s
CI / backend-tests (pull_request) Successful in 15m13s
7be07efe70
Дефолт AUTH_MODE=legacy — сегодняшнее поведение байт-в-байт: соединение с БД
auth не открывается, кука не читается, отсутствие настроек не роняет старт.
Popup Caddy стоит и снимается последним PR эпика — инвариант «гейт уходит
последним» не нарушен.

У Site Finder не было авторизации вообще: rbac_guard доверял заголовку
X-Authenticated-User от Caddy. Теперь он умеет резолвить сессионную куку
общего реестра. ВЫДАВАТЬ сессии «Птица» не будет — логин один, у «Меры», а
кука host-only на gendsgn.ru с path=/ и так долетает до обоих продуктов.
Меньше кода и меньше мест, где можно ошибиться.

Режим трёхзначный, а не булев: legacy | dual | db_only. Это прямое следствие
ревью. При булевом флаге фолбэк «сессия не нашлась → верим заголовку» после
снятия popup превращался бы в полный обход аутентификации, и ничто в коде не
заставило бы про него вспомнить. В db_only легаси-ветка недостижима: 401.

Резолв уехал в threadpool. Три независимых ревьюера нашли одно и то же:
sync-запрос к БД в async-guard блокирует event loop на каждом non-public
запросе — ровно инцидент #1202, который в этом же файле уже лечили. Кука
разбирается на loop'е, в поток уезжает только строка токена; запрос без куки
не платит ни за поток, ни за коннект.

Срок годности сессии считают часы БД, а не приложения. Раньше проверка шла в
Python, а sliding-refresh переписывал строку через now() базы — при
расхождении часов истёкшая сессия не просто проходила, а продлевалась заново,
то есть воскресала навсегда. Теперь `expires_at > now()` в самом SELECT;
питоновская проверка оставлена вторым поясом.

Fail-fast на старте проверяет не синтаксис DSN, а живое соединение: SELECT 1.
Иначе неверный пароль или хост выглядели бы как «ни у кого нет сессии» —
сутками, потому что ошибку ловил бы except в guard'е.

Ещё из ревью: connect_timeout и statement_timeout по 3с (недоступный хост
вешал коннект на минуты); throttling логов сбоя реестра (иначе шторм в
GlitchTip на каждый запрос); тела SQL закреплены ассертами формы — мутация
любого фрагмента теперь красит тесты, до этого не красила ничего.

Дефолт хоста БД — postgres, и это зеркально «Мере». У неё gendesign-postgres,
потому что внутри её стека `postgres` — чужой контейнер; здесь стек главный, и
`postgres` из корневого compose и есть нужный сервер. Алиас gendesign-postgres
дефолтом был бы багом: контейнер beat состоит только в сети default и это имя
из него не разрезолвится.

Сверка реестра с ролевой картой сделана на живом проде: все 13 юзеров
auth.users присутствуют в auth/roles.yaml, ни один не получит 403 на всё.
Четыре QA-фикстуры (admintest, pilottest, analysttest, expiredtest) есть в
yaml, но не в реестре — после снятия popup войти ими через браузер будет
нельзя, только внутрисетевым заголовком.

Осознанный долг, вписан ⚠️-блоком перед guard'ом: paths/deny из roles.yaml
бэкендом не применяются (их энфорсит фронтовый RouteGuard), guard проверяет
только известность username и admin-пути. Это предсуществующее поведение;
менять его здесь значило бы изменить и легаси-ветку, то есть нарушить
«дефолт = сегодня».

Тесты: 4594 passed, 0 failed. Главный — подделка: валидная кука плюс
присланный клиентом X-Authenticated-User другого пользователя, выигрывает
кука, и роут, читающий заголовок напрямую, видит владельца куки. У «Птицы»
таких прямых читателей одиннадцать, поэтому перезапись ASGI-scope обязана быть
полной, а не skip-if-present (CRITICAL #2552).
lekss361 merged commit 864c551c0d into main 2026-08-02 14:18:24 +00:00
lekss361 deleted branch feat/auth-ptica-session-guard 2026-08-02 14:18:24 +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#2635
No description provided.