feat(tradein): переключаемый реестр людей — подготовка переезда «Меры» в БД auth [PR-2b/6] #2608

Merged
lekss361 merged 1 commit from feat/auth-mera-identity-store into main 2026-07-31 23:54:46 +00:00
Owner

Третий PR эпика «единый вход». Продолжение #2597 (БД auth) и #2602 (роли + access_state).

Прод после мержа работает ровно как сейчас

IDENTITY_STORE="tradein" — дефолт и сегодняшнее поведение: tradein_users / tradein_sessions, соединение с БД auth не открывается вообще. Переключение — одна переменная окружения, уже после того как на проде появится пароль auth_app и будут скопированы данные.

Флаг сделан намеренно, а не «на всякий случай»: на проде роль auth_app до сих пор без пароля (rolpassword IS NULL — проверено), а хеши, роли и 8 живых сессий в auth ещё не перенесены. Мерж, который зависит от невыполненного ручного шага, — это мерж, который ломает прод в момент невнимательности. Здесь порядок обратный: код едет первым и спит, данные и пароль догоняют, переключение — отдельное осознанное действие с мгновенным откатом той же переменной.

Ядро

app/services/identity_store.py — единственное место, знающее, в какой БД и в каких таблицах живёт реестр людей. Имена таблиц берутся из фиксированного словаря по значению флага; конкатенации с внешним вводом нет (имя таблицы — не тот параметр, который можно биндить).

app/core/auth_db.pyленивый engine БД auth под блокировкой. Отдельный модуль именно потому, что core/db.py создаёт engine на импорте: то же самое для auth роняло бы старт всякий раз, когда DSN не задан — то есть прямо сейчас.

Одно понятие состояния доступа вместо двух

В tradein_users состояние — булев is_active; в auth.usersaccess_state из трёх значений. Чтобы вызывающий код не разбирался, в каком он режиме, конверсия живёт в одной функции to_access_state():

  • True → active, False → disabled;
  • неизвестная строка, NULL, чужой тип → disabled + WARNING.

Fail-closed выбран сознательно: когда следующая миграция добавит четвёртое состояние, оно по умолчанию не будет пускать — вместо того чтобы пускать молча. По той же причине проверка называется can_sign_in (свойство), а не state == "active" (сравнение, которое забудут обновить).

Логин в режиме auth

ситуация ответ
верный пароль, active 200, сессия
верный пароль, trial_expired 403, detail.code="access_expired", сессия не создаётся
верный пароль, disabled generic 401
неверный пароль, любое состояние generic 401

Пароль проверяется всегда и до ветвления по состоянию — иначе появляется timing-oracle, и по времени ответа можно отличить существующий логин от несуществующего. Честный текст показывается только после верного пароля, поэтому перечисление логинов остаётся невозможным: заблокированный аккаунт с неверным паролем неотличим от несуществующего (запинено тестом).

Резолв уже выданной сессии пропускает только active — блокировка обрывает сессию немедленно, а не по истечении sliding-refresh.

Старт падает явно при недонастроенном режиме

Если IDENTITY_STORE=auth, а DSN пуст — приложение не поднимается.

Причина не в аккуратности, а в том, как выглядела бы ошибка иначе: продуктовая БД жива, приложение полностью работоспособно, а rbac_guard ловит AuthDatabaseNotConfiguredError вместе с любым другим сбоем резолва и падает в legacy trusted-header ветку (auth_mode='dual'). То есть недонастроенный деплой сутками раздавал бы права из roles.yaml мимо реестра — включая аккаунты с disabled. Это нашло ревью, и это исправлено здесь же.

На дефолтный режим не влияет: ветка не выполняется, пустой AUTH_DATABASE_URL по-прежнему не ошибка. Проверяется конфигурация, а не доступность БД — create_engine к серверу не ходит, недоступный сервер старту не мешает.

Форма входа понимает новый код

Ветвление по detail.code, а не по тексту сообщения: текст бэкенд вправе менять, код — нет. Без этого 403 показывался бы как «Проверьте подключение» — контракт без потребителя бесполезен (второе замечание ревью).

Гранты соблюдены, а не обойдены

auth_app не имеет UPDATE на role/manager_id и не имеет DELETE на users (миграция 004, column-level). Код этих путей не пишет — блокировка сотрудника идёт через access_state, а не через удаление строки.

Тесты

2996 passed (+59 к базе), 9 skipped.

Единственный красный — tests/test_search_api.py::test_search_cache_hit (assert 401 == 200) — предсуществующий. Проверено не рассуждением, а контрольным полным прогоном на чистом main в отдельном worktree: 2937 passed, тот же самый красный. Модуль не собирается в изоляции, поэтому одиночный прогон как доказательство не годится — нужен был именно полный.

Новые тесты покрывают: оба режима, 403 при trial_expired, неотличимость неверного пароля от заблокированного аккаунта, отказ сессии для не-active, перезапись X-Authenticated-User в ASGI-scope при валидной куке (CRITICAL #2552 — проверено, что подделанный заголовок не выигрывает у куки), и дефолт флага прямым ассертом.

Ревью

Три скептика в свежих контекстах, с мутационными проверками (ломали код и смотрели, краснеет ли сьют). Два замечания уровня medium — тихая деградация в legacy-ветку при неверном DSN и контракт 403 без потребителя — закрыты в этом же коммите. Заодно ревью поймало изъян самого процесса: mutation-харнесс правил файлы в общем worktree параллельно с чтением; финальное состояние дерева проверено отдельно, следов мутаций нет.

Test plan

  • uv run pytest -q — 2996 passed, 1 предсуществующий красный (контроль на main: 2937 passed, тот же)
  • ruff check по изменённым файлам — чисто (3 ошибки в test_estimator_pure_units.py предсуществующие, файл не тронут; локальный ruff новее пиннутого)
  • pre-commit чист
  • tsc фронта — локально нет node_modules, проверит CI Trade-In / frontend-checks
  • Переключение флага на проде — не в этом PR

Что должно случиться на проде ПЕРЕД переключением флага

  1. AUTH_DB_PASSWORD в двух файлах (/opt/gendesign/backend/.env.runtime и /opt/gendesign/tradein-mvp/backend/.env.runtime) + ALTER ROLE.
  2. Копирование tradein_users → auth.users: password_hash, role, manager_id, display_name, org_name, email. access_state не копируется — в auth он уже выставлен верно (user2 = trial_expired, а в tradein_users то же состояние выражено как is_active=false, что дало бы disabled).
  3. Копирование 8 живых сессий tradein_sessions → auth.sessions с ремаппингом user_id по username — id между базами не гарантированно совпадают. Без этого восемь человек разлогинятся.
  4. AUTH_DATABASE_URL (хост gendesign-postgres, не postgres — внутри стека «Меры» это имя резолвится в её собственный контейнер) + IDENTITY_STORE=auth + пересоздание контейнера (deploy-tradein.yml не делает --force-recreate при IMAGE_TAG=latest).

Откат на любом шаге — вернуть IDENTITY_STORE=tradein и перезапустить.

Третий PR эпика «единый вход». Продолжение #2597 (БД `auth`) и #2602 (роли + `access_state`). ## Прод после мержа работает ровно как сейчас `IDENTITY_STORE="tradein"` — дефолт и сегодняшнее поведение: `tradein_users` / `tradein_sessions`, соединение с БД `auth` **не открывается вообще**. Переключение — одна переменная окружения, уже после того как на проде появится пароль `auth_app` и будут скопированы данные. Флаг сделан намеренно, а не «на всякий случай»: на проде роль `auth_app` до сих пор без пароля (`rolpassword IS NULL` — проверено), а хеши, роли и **8 живых сессий** в `auth` ещё не перенесены. Мерж, который зависит от невыполненного ручного шага, — это мерж, который ломает прод в момент невнимательности. Здесь порядок обратный: код едет первым и спит, данные и пароль догоняют, переключение — отдельное осознанное действие с мгновенным откатом той же переменной. ## Ядро `app/services/identity_store.py` — единственное место, знающее, **в какой БД** и **в каких таблицах** живёт реестр людей. Имена таблиц берутся из фиксированного словаря по значению флага; конкатенации с внешним вводом нет (имя таблицы — не тот параметр, который можно биндить). `app/core/auth_db.py` — **ленивый** engine БД `auth` под блокировкой. Отдельный модуль именно потому, что `core/db.py` создаёт engine на импорте: то же самое для `auth` роняло бы старт всякий раз, когда DSN не задан — то есть прямо сейчас. ## Одно понятие состояния доступа вместо двух В `tradein_users` состояние — булев `is_active`; в `auth.users` — `access_state` из трёх значений. Чтобы вызывающий код не разбирался, в каком он режиме, конверсия живёт в одной функции `to_access_state()`: - `True → active`, `False → disabled`; - неизвестная строка, `NULL`, чужой тип → **`disabled` + WARNING**. Fail-closed выбран сознательно: когда следующая миграция добавит четвёртое состояние, оно по умолчанию не будет пускать — вместо того чтобы пускать молча. По той же причине проверка называется `can_sign_in` (свойство), а не `state == "active"` (сравнение, которое забудут обновить). ## Логин в режиме `auth` | ситуация | ответ | |---|---| | верный пароль, `active` | 200, сессия | | верный пароль, `trial_expired` | **403**, `detail.code="access_expired"`, сессия **не** создаётся | | верный пароль, `disabled` | generic 401 | | неверный пароль, любое состояние | generic 401 | Пароль проверяется **всегда и до** ветвления по состоянию — иначе появляется timing-oracle, и по времени ответа можно отличить существующий логин от несуществующего. Честный текст показывается только после верного пароля, поэтому перечисление логинов остаётся невозможным: заблокированный аккаунт с неверным паролем неотличим от несуществующего (запинено тестом). Резолв уже выданной сессии пропускает только `active` — блокировка обрывает сессию немедленно, а не по истечении sliding-refresh. ## Старт падает явно при недонастроенном режиме Если `IDENTITY_STORE=auth`, а DSN пуст — приложение не поднимается. Причина не в аккуратности, а в том, как выглядела бы ошибка иначе: продуктовая БД жива, приложение полностью работоспособно, а `rbac_guard` ловит `AuthDatabaseNotConfiguredError` вместе с любым другим сбоем резолва и **падает в legacy trusted-header ветку** (`auth_mode='dual'`). То есть недонастроенный деплой сутками раздавал бы права из `roles.yaml` мимо реестра — включая аккаунты с `disabled`. Это нашло ревью, и это исправлено здесь же. На дефолтный режим не влияет: ветка не выполняется, пустой `AUTH_DATABASE_URL` по-прежнему не ошибка. Проверяется **конфигурация**, а не доступность БД — `create_engine` к серверу не ходит, недоступный сервер старту не мешает. ## Форма входа понимает новый код Ветвление по `detail.code`, а не по тексту сообщения: текст бэкенд вправе менять, код — нет. Без этого 403 показывался бы как «Проверьте подключение» — контракт без потребителя бесполезен (второе замечание ревью). ## Гранты соблюдены, а не обойдены `auth_app` не имеет `UPDATE` на `role`/`manager_id` и не имеет `DELETE` на `users` (миграция 004, column-level). Код этих путей не пишет — блокировка сотрудника идёт через `access_state`, а не через удаление строки. ## Тесты **2996 passed** (+59 к базе), 9 skipped. Единственный красный — `tests/test_search_api.py::test_search_cache_hit` (`assert 401 == 200`) — **предсуществующий**. Проверено не рассуждением, а контрольным полным прогоном на чистом `main` в отдельном worktree: **2937 passed, тот же самый красный**. Модуль не собирается в изоляции, поэтому одиночный прогон как доказательство не годится — нужен был именно полный. Новые тесты покрывают: оба режима, 403 при `trial_expired`, неотличимость неверного пароля от заблокированного аккаунта, отказ сессии для не-`active`, перезапись `X-Authenticated-User` в ASGI-scope при валидной куке (CRITICAL #2552 — проверено, что подделанный заголовок не выигрывает у куки), и дефолт флага прямым ассертом. ## Ревью Три скептика в свежих контекстах, с мутационными проверками (ломали код и смотрели, краснеет ли сьют). Два замечания уровня medium — тихая деградация в legacy-ветку при неверном DSN и контракт 403 без потребителя — закрыты в этом же коммите. Заодно ревью поймало изъян самого процесса: mutation-харнесс правил файлы в общем worktree параллельно с чтением; финальное состояние дерева проверено отдельно, следов мутаций нет. ## Test plan - [x] `uv run pytest -q` — 2996 passed, 1 предсуществующий красный (контроль на main: 2937 passed, тот же) - [x] `ruff check` по изменённым файлам — чисто (3 ошибки в `test_estimator_pure_units.py` предсуществующие, файл не тронут; локальный ruff новее пиннутого) - [x] pre-commit чист - [ ] `tsc` фронта — локально нет `node_modules`, проверит `CI Trade-In / frontend-checks` - [ ] Переключение флага на проде — **не в этом PR** ## Что должно случиться на проде ПЕРЕД переключением флага 1. `AUTH_DB_PASSWORD` в **двух** файлах (`/opt/gendesign/backend/.env.runtime` и `/opt/gendesign/tradein-mvp/backend/.env.runtime`) + `ALTER ROLE`. 2. Копирование `tradein_users → auth.users`: `password_hash`, `role`, `manager_id`, `display_name`, `org_name`, `email`. `access_state` **не** копируется — в `auth` он уже выставлен верно (`user2 = trial_expired`, а в `tradein_users` то же состояние выражено как `is_active=false`, что дало бы `disabled`). 3. Копирование 8 живых сессий `tradein_sessions → auth.sessions` с ремаппингом `user_id` **по `username`** — id между базами не гарантированно совпадают. Без этого восемь человек разлогинятся. 4. `AUTH_DATABASE_URL` (хост `gendesign-postgres`, не `postgres` — внутри стека «Меры» это имя резолвится в её собственный контейнер) + `IDENTITY_STORE=auth` + пересоздание контейнера (`deploy-tradein.yml` не делает `--force-recreate` при `IMAGE_TAG=latest`). Откат на любом шаге — вернуть `IDENTITY_STORE=tradein` и перезапустить.
lekss361 added 1 commit 2026-07-31 23:51:15 +00:00
feat(tradein): переключаемый реестр людей — подготовка переезда «Меры» в БД auth [PR-2b/6]
All checks were successful
CI Trade-In / changes (pull_request) Successful in 7s
CI / changes (pull_request) Successful in 7s
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 / frontend-checks (pull_request) Successful in 1m3s
CI Trade-In / backend-tests (pull_request) Successful in 2m40s
eccb895db1
Дефолт не меняет ничего: IDENTITY_STORE="tradein" — это сегодняшний прод,
tradein_users/tradein_sessions, соединение с БД auth не открывается вообще.
Переключение делается одной переменной окружения ПОСЛЕ того, как на проде
появится пароль auth_app и будут скопированы данные. Так сделано намеренно:
мерж, который зависит от невыполненного ручного шага, — это мерж, который
ломает прод в момент невнимательности.

Ядро. app/services/identity_store.py — единственное место, знающее, в какой БД
и в каких таблицах живёт реестр. Имена таблиц берутся из фиксированного словаря
по значению флага, не конкатенацией с вводом. app/core/auth_db.py — ЛЕНИВЫЙ
engine БД auth (core/db.py создаёт свой на импорте; такое же для auth роняло бы
старт без DSN).

Одно понятие состояния доступа вместо двух. В tradein_users состояние — булев
is_active, в auth.users — access_state из трёх значений. Конверсия живёт в одной
функции to_access_state(): True→active, False→disabled, а неизвестная строка,
NULL или чужой тип → disabled с WARNING. Fail-closed выбран сознательно: если
следующая миграция добавит четвёртое состояние, оно по умолчанию НЕ будет
пускать. Проверка доступа — свойство can_sign_in, а не сравнение со строкой.

Логин в режиме auth. Пароль проверяется ВСЕГДА и ДО ветвления по состоянию —
иначе появляется timing-oracle и перечисление логинов. Верный пароль +
trial_expired → 403 с машиночитаемым code="access_expired", сессия НЕ создаётся.
Верный пароль + disabled → тот же generic 401, что и при неверном пароле.
Резолв уже выданной сессии пропускает только active — блокировка обрывает
сессию немедленно, а не по истечении sliding-refresh.

Старт падает явно, если IDENTITY_STORE=auth, а DSN не задан. Без этого ошибка
конфигурации не похожа на аварию: продуктовая БД жива, приложение работает, а
rbac_guard ловит исключение резолва вместе с любым другим сбоем и падает в
legacy trusted-header ветку — то есть сутками раздаёт права из roles.yaml мимо
реестра, включая аккаунты с disabled.

Форма входа понимает новый код ответа. Ветвление по detail.code, а не по тексту:
текст бэк вправе менять, код — нет.

Гранты соблюдены, а не обойдены: auth_app не имеет UPDATE на role/manager_id и
не имеет DELETE на users (миграция 004, column-level).

Тесты: 2996 passed (+59). Единственный красный — test_search_cache_hit —
предсуществующий: проверен контрольным полным прогоном на чистом main
(2937 passed, тот же красный).
lekss361 merged commit 34b346b097 into main 2026-07-31 23:54:46 +00:00
lekss361 deleted branch feat/auth-mera-identity-store 2026-07-31 23:54:46 +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#2608
No description provided.