feat(auth): роли и трёхзначное состояние доступа в БД auth [PR-2a/6] #2602

Merged
lekss361 merged 1 commit from feat/auth-schema-roles-access-state into main 2026-07-31 21:46:26 +00:00
Owner

Второй PR эпика «единый вход». Только схема — Python-кода нет, прод работает ровно как сейчас: в БД auth пока никто не ходит, а auth_app на проде до сих пор без пароля.

Закрывает обе развилки, оставленные открытыми в #2597 (решения владельца от 31.07).

Развилка А → полный переезд

tradein_users (БД tradein) в итоге удаляется, auth.users становится единственным реестром людей. Значит role и manager_id переезжают сюда.

Это отменяет решение 001:15-19 («здесь НЕТ колонки role — сознательно»). Применённую миграцию править нельзя, поэтому актуальная правда живёт в 004: шапка адресно перечисляет, какие формулировки 001/002/003 больше не действуют, и COMMENT ON TABLE users переписан — старый утверждал, что роли остаются в продуктовых БД, и это стало ложью.

Колонки зеркалят м.192 побуквенно (CHECK ролей, иерархический CHECK, partial index, self-FK ON DELETE SET NULL) — чтобы код «Меры» переехал на auth.users без правок. Сверено с живой схемой прода, не с файлом.

Добавлен users_manager_not_self_ck: на уровне БД самоназначение менеджером сейчас проходит, а валидация есть только в «Мере» (team.py) — у второго потребителя, «Птицы», её нет, и любой будущий WITH RECURSIVE по manager_id на такой строке зациклится.

Развилка Б → три состояния вместо булева флага

is_active заменён на access_state ∈ (active, trial_expired, disabled).

состояние верный пароль неверный пароль
active 200, сессия выдана generic 401
trial_expired 403 с отдельным кодом, сессия НЕ выдаётся generic 401
disabled generic 401 generic 401

Смысл разделения: булев флаг схлопывал «пускаем внутрь, но объясняем» и «не пускаем вовсе» в одно значение — trial-экран исчез бы молча, без падения тестов (ровно это предсказывал комментарий 003:78-90). Честный текст показывается только после верного пароля, поэтому защита от перечисления логинов сохраняется. user2 («Брусника») → trial_expired.

Семантика зафиксирована в COMMENT ON COLUMN, чтобы автор PR-2b читал её из БД, а не угадывал.

Гранты

INSERT выдан — без него переезд не состоится: создание сотрудника из «Команды» сегодня пишет в tradein_users, а её не станет.

DELETE НЕ выдан. Первая редакция его содержала; убрано после ревью. Потребителя нет (в team.py только POST и PATCH), а 002:22-33 отклоняла ровно такие гранты-на-будущее. DELETE по users каскадит на sessions, то есть цена ошибки выше обычной, а строка GRANT в миграции того PR, где появится хендлер, стоит столько же. Закрытие доступа — это access_state, а не удаление строки.

Табличный UPDATE из 002:80 сужен до column-level. Он автоматически распространялся бы на всё, что добавила эта миграция: auth_app молча получил бы право писать role и access_state. Тогда ошибка в единственном UPDATE-эндпоинте (team.py PATCH) превращалась бы из «испортил профиль» в SET role='admin' WHERE id=<свой> или тихое снятие блокировки — без смены пароля, то есть без внешнего признака компрометации. Выданы ровно поля сегодняшнего PATCH; role и manager_id не включены — их не пишет никто.

Гранта на users_id_seq нет намеренно. 002:26-27 записала как факт, что «идентичность требует nextval». Для GENERATED ALWAYS AS IDENTITY это неверно: PostgreSQL подставляет не nextval('...'), а NextValueExprnextval_internal(seqid, check_permissions := false), ACL последовательности не проверяется вовсе. Проверено обратным экспериментом (REVOKE, затем INSERT — прошёл; контрольная таблица с bigserial в тех же условиях упала). Разбор оставлен в файле: «auth_app сделал 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.

Ревью — три независимых скептика в отдельных контекстах, 8 находок, все не блокирующие; две (ложное обоснование гранта на sequence, молчаливое расширение UPDATE) нашлись только потому, что ревьюеры ставили эксперименты, а не читали комментарии.

Известные ограничения, записанные в файл

  • После 004 файлы 001 и 003 не переигрываются поодиночке (падают на is_active). Прогон каталога целиком на пустой БД чист — это и есть штатный recovery-путь; проверено.
  • Блокировка последнего active-админа в БД достижима — схемой не выражается. Инвариант записан в COMMENT ON COLUMN access_state как требование к API: PR-2b обязан отклонять такой переход. Сегодня путь закрыт тем, что «Команда» не отдаёт строки с role='admin'.
  • Требование «manager_id указывает на строку с role='manager'» строчным CHECK не выражается — зафиксировано COMMENT'ом как инвариант приложения, адресно для второго потребителя.

Блокер деплоя (не этого PR, но следующего)

На проде auth_app без пароля (rolpassword IS NULL — проверено), потому что AUTH_DB_PASSWORD не заведён в .env.runtime. Эту миграцию отсутствие пароля не ломает (она идёт под суперюзером), но PR-2b без него не подключится. Переменную нужно завести в двух файлах: /opt/gendesign/backend/.env.runtime и /opt/gendesign/tradein-mvp/backend/.env.runtime — у стеков разные env_file.

Test plan

  • uv run pytest tests/sql/test_auth_sql_migrations.py -q — 6 passed (добавлена проверка запрета CREATE INDEX CONCURRENTLY: в связке с обязательной обёрткой BEGIN/COMMIT это невыполнимая на проде комбинация, а отдельной проверки не было)
  • pre-commit чист (детектор приватных ключей + ruff)
  • Прогон против живого postgres:16 — см. выше
  • Применение на прод — автоматически при мерже, цикл data/sql/auth/*.sql в deploy.yml

Дальше

PR-2b — «Мера» переезжает на БД auth (второй engine, ~14 SQL-запросов, team.py, ~2100 строк тестов с fake-DB по тексту SQL). PR-2c — guard «Птицы». Перед деплоем 2b на прод нужно скопировать в auth.users bcrypt-хеши и роли из tradein_users, а в auth.sessions — 8 живых сессий, иначе пилоты разлогинятся.

Второй PR эпика «единый вход». **Только схема — Python-кода нет, прод работает ровно как сейчас**: в БД `auth` пока никто не ходит, а `auth_app` на проде до сих пор без пароля. Закрывает обе развилки, оставленные открытыми в #2597 (решения владельца от 31.07). ## Развилка А → полный переезд `tradein_users` (БД `tradein`) в итоге удаляется, `auth.users` становится единственным реестром людей. Значит `role` и `manager_id` переезжают сюда. Это **отменяет решение 001:15-19** («здесь НЕТ колонки role — сознательно»). Применённую миграцию править нельзя, поэтому актуальная правда живёт в 004: шапка адресно перечисляет, какие формулировки 001/002/003 больше не действуют, и `COMMENT ON TABLE users` переписан — старый утверждал, что роли остаются в продуктовых БД, и это стало ложью. Колонки зеркалят м.192 побуквенно (CHECK ролей, иерархический CHECK, partial index, self-FK `ON DELETE SET NULL`) — чтобы код «Меры» переехал на `auth.users` без правок. Сверено с живой схемой прода, не с файлом. Добавлен `users_manager_not_self_ck`: на уровне БД самоназначение менеджером сейчас проходит, а валидация есть только в «Мере» (`team.py`) — у второго потребителя, «Птицы», её нет, и любой будущий `WITH RECURSIVE` по `manager_id` на такой строке зациклится. ## Развилка Б → три состояния вместо булева флага `is_active` заменён на `access_state ∈ (active, trial_expired, disabled)`. | состояние | верный пароль | неверный пароль | |---|---|---| | `active` | 200, сессия выдана | generic 401 | | `trial_expired` | **403 с отдельным кодом**, сессия НЕ выдаётся | generic 401 | | `disabled` | generic 401 | generic 401 | Смысл разделения: булев флаг схлопывал «пускаем внутрь, но объясняем» и «не пускаем вовсе» в одно значение — trial-экран исчез бы молча, без падения тестов (ровно это предсказывал комментарий 003:78-90). Честный текст показывается только после верного пароля, поэтому защита от перечисления логинов сохраняется. `user2` («Брусника») → `trial_expired`. Семантика зафиксирована в `COMMENT ON COLUMN`, чтобы автор PR-2b читал её из БД, а не угадывал. ## Гранты **INSERT выдан** — без него переезд не состоится: создание сотрудника из «Команды» сегодня пишет в `tradein_users`, а её не станет. **DELETE НЕ выдан.** Первая редакция его содержала; убрано после ревью. Потребителя нет (в `team.py` только POST и PATCH), а 002:22-33 отклоняла ровно такие гранты-на-будущее. `DELETE` по `users` каскадит на `sessions`, то есть цена ошибки выше обычной, а строка `GRANT` в миграции того PR, где появится хендлер, стоит столько же. Закрытие доступа — это `access_state`, а не удаление строки. **Табличный `UPDATE` из 002:80 сужен до column-level.** Он автоматически распространялся бы на всё, что добавила эта миграция: `auth_app` молча получил бы право писать `role` и `access_state`. Тогда ошибка в единственном UPDATE-эндпоинте (`team.py` PATCH) превращалась бы из «испортил профиль» в `SET role='admin' WHERE id=<свой>` или тихое снятие блокировки — без смены пароля, то есть без внешнего признака компрометации. Выданы ровно поля сегодняшнего PATCH; `role` и `manager_id` не включены — их не пишет никто. **Гранта на `users_id_seq` нет намеренно.** 002:26-27 записала как факт, что «идентичность требует nextval». Для `GENERATED ALWAYS AS IDENTITY` это неверно: PostgreSQL подставляет не `nextval('...')`, а `NextValueExpr` → `nextval_internal(seqid, check_permissions := false)`, ACL последовательности не проверяется вовсе. Проверено обратным экспериментом (REVOKE, затем INSERT — прошёл; контрольная таблица с `bigserial` в тех же условиях упала). Разбор оставлен в файле: «auth_app сделал 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`. Ревью — три независимых скептика в отдельных контекстах, 8 находок, все не блокирующие; две (ложное обоснование гранта на sequence, молчаливое расширение UPDATE) нашлись только потому, что ревьюеры ставили эксперименты, а не читали комментарии. ## Известные ограничения, записанные в файл - После 004 файлы **001 и 003 не переигрываются поодиночке** (падают на `is_active`). Прогон каталога целиком на пустой БД чист — это и есть штатный recovery-путь; проверено. - Блокировка **последнего** active-админа в БД достижима — схемой не выражается. Инвариант записан в `COMMENT ON COLUMN access_state` как требование к API: PR-2b обязан отклонять такой переход. Сегодня путь закрыт тем, что «Команда» не отдаёт строки с `role='admin'`. - Требование «`manager_id` указывает на строку с `role='manager'`» строчным CHECK не выражается — зафиксировано COMMENT'ом как инвариант приложения, адресно для второго потребителя. ## Блокер деплоя (не этого PR, но следующего) На проде `auth_app` **без пароля** (`rolpassword IS NULL` — проверено), потому что `AUTH_DB_PASSWORD` не заведён в `.env.runtime`. Эту миграцию отсутствие пароля не ломает (она идёт под суперюзером), но PR-2b без него не подключится. Переменную нужно завести в **двух** файлах: `/opt/gendesign/backend/.env.runtime` и `/opt/gendesign/tradein-mvp/backend/.env.runtime` — у стеков разные `env_file`. ## Test plan - [x] `uv run pytest tests/sql/test_auth_sql_migrations.py -q` — 6 passed (добавлена проверка запрета `CREATE INDEX CONCURRENTLY`: в связке с обязательной обёрткой BEGIN/COMMIT это невыполнимая на проде комбинация, а отдельной проверки не было) - [x] pre-commit чист (детектор приватных ключей + ruff) - [x] Прогон против живого `postgres:16` — см. выше - [ ] Применение на прод — автоматически при мерже, цикл `data/sql/auth/*.sql` в `deploy.yml` ## Дальше PR-2b — «Мера» переезжает на БД `auth` (второй engine, ~14 SQL-запросов, `team.py`, ~2100 строк тестов с fake-DB по тексту SQL). PR-2c — guard «Птицы». Перед деплоем 2b на прод нужно скопировать в `auth.users` bcrypt-хеши и роли из `tradein_users`, а в `auth.sessions` — 8 живых сессий, иначе пилоты разлогинятся.
lekss361 added 1 commit 2026-07-31 21:29:41 +00:00
feat(auth): роли и трёхзначное состояние доступа в БД auth [PR-2a/6]
All checks were successful
CI / changes (pull_request) Successful in 10s
CI Trade-In / changes (pull_request) Successful in 11s
CI / frontend-tests (pull_request) Has been skipped
CI Trade-In / backend-tests (pull_request) Has been skipped
CI Trade-In / frontend-checks (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Successful in 2m47s
CI / backend-tests (pull_request) Successful in 15m48s
b5976c0cc9
Схема под решения владельца от 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), а отдельной проверки на неё не было.
lekss361 merged commit 72d0ebf53f into main 2026-07-31 21:46:26 +00:00
lekss361 deleted branch feat/auth-schema-roles-access-state 2026-07-31 21:46:26 +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#2602
No description provided.