Дефолт 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).
Схема под решения владельца от 2026-07-31 по эпику «единый вход».
Python-кода нет, поведение прода не меняется — в БД auth пока никто не ходит.
Развилка А закрыта в пользу ПОЛНОГО переезда: tradein_users (БД tradein) в
итоге удаляется, auth.users становится единственным реестром людей. Значит
role и manager_id переезжают сюда — это отменяет решение 001:15-19 («ролей
здесь нет — сознательно»), что зафиксировано в шапке файла и переписанным
COMMENT ON TABLE, а не оставлено расходиться молча.
Развилка Б закрыта в пользу трёх состояний: is_active заменён на access_state
(active / trial_expired / disabled). Булев флаг схлопывал «пускаем, но
объясняем» и «не пускаем вовсе» в одно значение — trial-экран исчезал бы
без падения тестов. Семантика зафиксирована в COMMENT: trial_expired при
ВЕРНОМ пароле даёт 403 с отдельным кодом и НЕ выдаёт сессию, disabled —
generic 401; неверный пароль в любом состоянии остаётся generic 401, то есть
защита от перечисления логинов сохраняется. user2 («Брусника») → trial_expired.
Колонки role/manager_id зеркалят м.192 побуквенно (CHECK ролей, иерархический
CHECK, partial index, self-FK ON DELETE SET NULL), чтобы код «Меры» переехал
на auth.users без правок. Добавлен users_manager_not_self_ck — на уровне БД
самоназначение менеджером иначе проходит, а второй потребитель (Птица)
валидации «Меры» не имеет.
Гранты. INSERT выдан — без него переезд не состоится (создание сотрудника из
«Команды»). DELETE НЕ выдан: потребителя нет (в team.py только POST и PATCH),
а 002:22-33 отклоняла ровно такие гранты-на-будущее; появится хендлер —
появится строка GRANT в той же миграции. Табличный UPDATE из 002:80 сужен до
column-level: иначе auth_app молча получил бы право писать role и
access_state, и ошибка в PATCH-эндпоинте превращалась бы в тихое повышение до
админа или тихое снятие блокировки. role и manager_id в список не включены —
их сегодня не пишет никто.
Гранта на users_id_seq нет намеренно: для GENERATED ALWAYS AS IDENTITY
PostgreSQL использует NextValueExpr → nextval_internal(check_permissions
:= false), ACL последовательности не проверяется. Утверждение 002:26-27
(«идентичность требует nextval») фактически неверно; проверено обратным
экспериментом — REVOKE, затем INSERT.
Проверено исполнением на postgres:16, не по комментариям:
- чистая сборка 001→002→003→004 — 13 строк, роли admin/manager×2/employee×10,
user2 = trial_expired, is_active отсутствует, все 6 констрейнтов на месте;
- повторный прогон 004 ×2 идемпотентен;
- ручные прод-правки (user2 → active, user3 → manager) переживают повтор —
backfill не затирает решения владельца;
- периметр auth_app: INSERT users ✓, UPDATE access_state ✓, INSERT sessions ✓;
UPDATE role ✗, UPDATE manager_id ✗, DELETE ✗, CREATE TABLE ✗;
- CHECK'и ловят: admin с manager_id, self-manager, access_state вне списка,
role вне списка, INSERT без role.
Тест: 6 passed. Добавлена проверка запрета CREATE INDEX CONCURRENTLY —
в связке с обязательной обёрткой BEGIN/COMMIT это комбинация, невыполнимая
на проде (25001), а отдельной проверки на неё не было.
PR-1 эпика: вся авторизация переезжает на одну нейтральную форму входа, браузерный
popup (Caddy basic_auth) убирается. Этот PR — ТОЛЬКО фундамент, прод работает как
сейчас: в БД gendesign ничего не меняется, новая БД создаётся и наполняется
логинами без паролей, читать её пока некому.
Почему отдельная БД, а не таблица в существующей: хранилище доступов не должно
принадлежать продукту, из которого аккаунты выносятся. Сервер — существующий
gendesign-postgres (новый контейнер не заводим); проверено, что оба бэкенда
сидят в сети gendesign_shared и TCP-достают до него.
Состав:
- data/sql/auth/001-003 — схема (users, sessions), роль приложения, сид 13 логинов.
password_hash = NULL у ВСЕХ: plaintext и bcrypt-хеши в git запрещены, пароли
проставляются отдельно на проде (конвенция репы, прецедент tradein м.193).
- ops/db-bootstrap/create_auth_db.sql — CREATE DATABASE через \gexec. Не миграцией:
CREATE DATABASE запрещён в транзакции, а миграции обязаны быть транзакционными.
- ops/db-bootstrap/set_auth_app_password.sql — пароль роли из env, зеркало
set_tradein_fdw_password.sql (GUC + \o /dev/null + %L, строго через stdin —
:'pw' не интерполируется внутри $$...$$, на этом падал деплой 2026-05-24).
Схема лежит в ПОДКАТАЛОГЕ data/sql/auth/ намеренно: основной цикл деплоя использует
`ls -1 data/sql/*.sql`, который в подкаталоги не рекурсирует → эти файлы физически
не могут примениться в БД gendesign. Защита не на дисциплине, а на глобе. Триггер
`data/sql/**` подкаталог при этом покрывает.
Права: владелец БД — суперюзер, а не auth_app (иначе гранты были бы декорацией).
users — только SELECT+UPDATE (INSERT не выдан: создания аккаунтов в этом PR нет, а
снять грант, на который уже опирается прод-код, сложнее чем выдать). REVOKE ALL ON
DATABASE FROM PUBLIC продублирован в bootstrap и в миграции намеренно: bootstrap
гоняется каждый деплой (переприменяемость), миграция — однократно (самодостаточность).
Проверено ИСПОЛНЕНИЕМ на postgis/postgis:16-3.4 (тот же образ, что на проде):
двойной прогон всех файлов идемпотентен; COALESCE-защита сида не затирает вручную
проставленные пароль/имя (проверено живьём); ASCII-CHECK отклоняет кириллицу;
auth_app коннектится, посторонняя роль → permission denied; битая миграция даёт
exit 1 и НЕ пишется в _schema_migrations, т.е. деплой прервётся до подъёма кода;
пароль с кавычками и бэкслешем не ломает %L и не печатается в stdout.
Тест backend/tests/sql/test_auth_sql_migrations.py: имена, транзакционность, наличие
wiring в deploy.yml и детектор паролей/хешей, покрывающий И data/sql/auth, И
ops/db-bootstrap — единственное место в репе с ALTER ROLE ... PASSWORD.
Детектор проверен на живучесть: подложенный bcrypt-хеш роняет тест.
Открытые развилки зафиксированы комментариями в коде, решаются в PR-2/3:
судьба tradein_users/tradein_sessions (два одинаковых по схеме хранилища) и
expired != disabled (trial-экран не выражается булевым is_active).
NB: переменную AUTH_DB_PASSWORD нужно завести вручную в runtime-env бэкенда на VPS.
Пока пусто — шаг ALTER ROLE пропускается с warning'ом, деплой не падает.
Аккаунт praktika (DB-роль manager) видел оба пункта в топбаре на /trade-in/team.
Это внутренние инструменты — аналитика рынка и состояние кэшей/скраперов, —
клиентские аккаунты их видеть не должны (решение владельца продукта).
Гейт один — deny-список роли, потому что все три места сверяются с ним через
общий матчер: пункт меню (Topbar по scopePath из /me), страница (RouteGuard) и
серверные ручки (rbac_guard). Правка только фронта спрятала бы пункт, оставив
прямой URL и API открытыми.
Закрыто для employee/manager (DB_ROLE_PATHS) и для legacy pilot (roles.yaml):
/trade-in/sale-share/**
/trade-in/cache/**
/trade-in/api/v1/buildings/**
/trade-in/api/v1/trade-in/cache-stats/**
У cache-stats ГЛОБ, а не точный путь: точный паттерн — строгое равенство, его
обходит трейлинг-слэш ('…/cache-stats/' → allowed=True), и защита держалась бы
на Starlette redirect_slashes, а не на RBAC. Замерено после правки: все варианты
(слэш, %2f, ./, ../) дают 403, утечек нет.
Основной продукт не задет: buildings.py обслуживает ТОЛЬКО sale-share, секция
«Продажи в доме» на экране оценки питается estimate-хендлерами. admin и analyst
сознательно вне deny — запиннено тестом, иначе «синхронизация» списков закрыла
бы их молча.
Заодно починен КРАСНЫЙ pre-existing тест главного бэкенда:
backend/tests/test_rbac.py::test_get_role_known_users ждал pilot у всех
user1..user10, но user2 («Брусника») стал expired 2026-07-30. CI это пропустил —
auth/roles.yaml не входит в paths-filter backend/**, из-за чего сьют не бежал.
Тесты: 153 passed (tradein) + 24 passed (site-finder, было 23+1 failed).
Новые — e2e через реальный rbac_guard по session-ветке (именно ею ходит
praktika), пин deny_paths в выдаче /me, границы глоба и regression-guard'ы.
Проверены снятием deny: 7 тестов краснеют, т.е. не тавтологии.
Follow-up к смене user2 pilot→expired в roles.yaml. test_get_role_known_users
гонял user1..user10 == pilot циклом; user2 теперь expired. На PR #2472 этот
тест был skipped (paths-filter не матчил tradein-mvp/**, менялся только
auth/roles.yaml) → упал уже на полном деплое. Правка в обоих зеркалах
(tradein + main backend).