gendesign/data/sql/auth/004_users_roles_and_access_state.sql
bot-backend b5976c0cc9
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
feat(auth): роли и трёхзначное состояние доступа в БД auth [PR-2a/6]
Схема под решения владельца от 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), а отдельной проверки на неё не было.
2026-08-01 00:28:37 +03:00

408 lines
39 KiB
PL/PgSQL
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

-- auth/004: продуктовые роли + org-иерархия + трёхзначный access_state вместо булева is_active.
--
-- ⚠️ ЭТА МИГРАЦИЯ СОЗНАТЕЛЬНО ОТМЕНЯЕТ РЕШЕНИЯ, ЗАПИСАННЫЕ В 001 И 002.
-- Это не рассинхрон и не ошибка автора: решение владельца продукта от 2026-07-31 принято
-- ПОСЛЕ того, как 001-003 были написаны и применены на проде. Применённую миграцию править
-- нельзя (повторно она не выполнится — трекинг в _schema_migrations), поэтому актуальная
-- правда живёт здесь, а в 001/002 остаются исторические формулировки:
-- * 001:15-19 «Здесь НЕТ колонки role — сознательно» → ОТМЕНЕНО, см. WHY-1;
-- * 002:22-33 «users — INSERT/DELETE НЕ выдаются, сознательно» → ОТМЕНЕНО ЧАСТИЧНО: INSERT
-- выдаётся (без него переезд не состоится), DELETE — по-прежнему нет, см. Часть 4;
-- * 002:26-27 «идентичность требует nextval» (грант USAGE на sequence) → ФАКТИЧЕСКИ
-- НЕВЕРНО, гранта не требуется; проверено, разбор в Части 4;
-- * 003:78-90 «открытая развилка про trial-экран, решается в PR-2/3» → ЗАКРЫТА, см. WHY-2.
-- Ориентир для читателя: актуальное состояние колонок описано COMMENT'ами в БД, они
-- переписаны здесь. Заголовок 001 — археология, а не спецификация.
--
-- WHY-1 — продуктовые роли переезжают в `auth` (отмена решения 001):
-- 001 строилась на схеме «идентичность общая, полномочия у продукта»: auth.users знает, КТО
-- человек, tradein_users знает, ЧТО ему можно. Владелец выбрал другой сценарий — ПОЛНЫЙ
-- переезд: tradein_users (БД tradein) в итоге удаляется, auth.users остаётся единственным
-- реестром людей. Как только реестр один, роль перестаёт быть «знанием продукта»: без неё в
-- auth.users нельзя ни завести сотрудника, ни собрать раздел «Команда», ни ответить на вопрос
-- «чьи заявки видит этот менеджер» — а спросить больше не у кого, второй таблицы не будет.
-- Промежуточный вариант (человек в auth.users, его роль в tradein_users) — это два реестра,
-- которые кто-то обязан держать синхронными руками; их расхождение выглядит как «пользователь
-- есть, но он никто» и чинится только вручную по факту жалобы.
-- Цена решения ровно та, которую 001 и называла: новая роль в любом из продуктов = миграция
-- этой БД. Принято сознательно — это дешевле, чем двойной реестр людей.
--
-- WHY-2 — три состояния доступа вместо булева is_active (закрытие развилки из 003):
-- Булев флаг схлопывает два РАЗНЫХ события в одно значение: «пробный период закончился» и
-- «доступ закрыт владельцем». Для пользователя разница видимая и она уже реализована в
-- сегодняшнем стеке: expired-аккаунт доходит до фронта и видит осмысленный экран «пробный
-- доступ закончился» (auth/roles.yaml → expired: paths: [] + deny "/**"; frontend
-- NoAccessScreen variant="trial"), а закрытый — просто не входит. Переключившись на единую
-- форму входа с булевым is_active, мы бы потеряли trial-экран МОЛЧА: состояние перестало бы
-- существовать, и ни один тест бы не упал. Ровно это и было записано как открытая развилка в
-- 003:78-90. Решение: состояний три.
-- active — доступ есть, обычный вход.
-- trial_expired — пароль ВЕРНЫЙ, но пробный период истёк: логин отвечает 403 с отдельным
-- кодом и текстом «пробный доступ закончился», сессия НЕ выдаётся.
-- disabled — жёсткая блокировка: generic 401, для пользователя неотличимо от «неверный
-- пароль».
-- Неверный пароль в ЛЮБОМ состоянии → generic 401. Иначе отдельный 403 превращается в оракул
-- существования логина: перебором можно перечислить аккаунты, не зная ни одного пароля.
-- Осмысленный ответ полагается только тому, кто пароль уже доказал.
-- text + CHECK, а не enum-тип: добавить четвёртое состояние — это ALTER одного констрейнта в
-- обычной миграции, тогда как ALTER TYPE ... ADD VALUE нельзя использовать в той же
-- транзакции, где значение добавлено (PG16), и enum тянет за собой отдельный тип в дампах.
-- Enum-типов в репозитории нет вовсе — не заводим первый ради трёх значений.
--
-- WHAT:
-- 1. role — text NOT NULL + CHECK ('admin','manager','employee'). Тип, набор значений
-- и отсутствие DEFAULT — зеркало tradein_users.role (м.192:42).
-- 2. manager_id — self-FK ON DELETE SET NULL + иерархический CHECK + запрет self-manager +
-- partial index. Зеркало м.192:43/50-52/84-86, чтобы код «Меры» переехал на
-- auth.users без правок.
-- 3. access_state — text NOT NULL DEFAULT 'active' + CHECK на три значения; backfill из
-- is_active, точечный перевод user2 («Брусника») в trial_expired, затем
-- DROP COLUMN is_active.
-- 4. Гранты auth_app — INSERT на users (DELETE НЕ выдаётся) + сужение табличного UPDATE (002:80) до
-- column-level: новые колонки role/access_state не должны попасть под него
-- молча.
--
-- IDEMPOTENCY:
-- ADD COLUMN IF NOT EXISTS / DROP COLUMN IF EXISTS / CREATE INDEX IF NOT EXISTS; констрейнты —
-- через DO-блок с проверкой pg_constraint (в PostgreSQL нет ADD CONSTRAINT IF NOT EXISTS для
-- CHECK/FK, паттерн из м.193:80-90); GRANT идемпотентен по определению; UPDATE-backfill'ы
-- отфильтрованы так, что второй прогон не находит строк (детали у каждого блока).
-- Проверка pg_constraint здесь фильтрует ДОПОЛНИТЕЛЬНО по conrelid (в отличие от м.193, где
-- только conname): имена констрейнтов уникальны в пределах таблицы, а не БД — одноимённый
-- констрейнт на соседней таблице заставил бы миграцию молча пропустить создание своего.
--
-- ⚠️ ПОСЛЕ 004 ФАЙЛЫ 001 И 003 БОЛЬШЕ НЕ ПЕРЕИГРЫВАЮТСЯ ПООТДЕЛЬНОСТИ.
-- Обе ссылаются на колонку is_active, которой после этой миграции нет, и обе падают на уже
-- мигрированной БД с «column is_active does not exist»:
-- * 001 — на `COMMENT ON COLUMN users.is_active` (001:75). CREATE TABLE IF NOT EXISTS
-- пропускается, а COMMENT выполняется всегда — то есть ручной `psql -f 001` падает
-- РАНЬШЕ 003, вопреки интуиции «ломается только сид».
-- * 003 — на INSERT со списком колонок, включающим is_active (а если бы и не упал —
-- role NOT NULL без DEFAULT не даст вставить строку).
-- Это следствие требования «применённые миграции не правим», а не регресс. Поддерживаемый
-- сценарий восстановления — прогон каталога ЦЕЛИКОМ по возрастанию номеров (001→002→003→004)
-- на пустой БД; он рабочий, порядок гарантирован сортировкой имён в deploy.yml. Нужно добить
-- сид на живой БД — пиши новый файл 00N, не переигрывай 003.
--
-- Dependencies: 001_identity_schema.sql (users), 002_auth_app_role.sql (роль auth_app — гранты
-- Части 4 её предполагают), 003_users_seed.sql (13 строк, которым backfill проставляет role).
-- Deploy order: применяется на прод авто-циклом deploy.yml по data/sql/auth/*.sql. Python-кода в
-- этом PR нет и поведение прода не меняется — в БД `auth` пока никто не ходит; код логина,
-- чтение role/access_state и удаление tradein_users — отдельные PR'ы ПОСЛЕ (см.
-- .claude/rules/sql.md «Migration order»: схема первой).
BEGIN;
-- ---------------------------------------------------------------------------------------------
-- Часть 1: role
-- ---------------------------------------------------------------------------------------------
-- DEFAULT сознательно НЕТ (как в м.192): роль — осознанное решение того, кто заводит человека.
-- С дефолтом INSERT, забывший указать роль, тихо создал бы работающий аккаунт с полномочиями
-- «по умолчанию»; без дефолта он падает на NOT NULL — это и есть нужное поведение.
-- Колонка добавляется NULLable, заполняется backfill'ом ниже и только потом получает NOT NULL:
-- прямой ADD COLUMN ... NOT NULL без DEFAULT упал бы на 13 уже существующих строках сида.
ALTER TABLE users ADD COLUMN IF NOT EXISTS role text;
-- Backfill. Источник истины — м.193:101-113 (org-карта владельца продукта от 2026-07-30),
-- сверено построчно по файлу, не по памяти. Роли не являются секретом: они уже лежат в git
-- (м.193 и auth/roles.yaml) — запрет на git касается паролей и хешей, не полномочий.
-- `role IS NULL` в каждом WHERE даёт сразу две вещи: идемпотентность (второй прогон не находит
-- строк) и защиту от отката ручных решений — повышение сотрудника до manager, сделанное после
-- первого прогона, повторным применением файла не вернётся к seed-значению.
UPDATE users SET role = 'admin' WHERE role IS NULL AND username = 'admin';
UPDATE users SET role = 'manager' WHERE role IS NULL AND username IN ('kopylov', 'praktika');
-- Catch-all — ПОСЛЕДНИМ и именно employee: любая строка, попавшая в auth.users мимо сида
-- (ручная вставка, восстановление из дампа, будущий аккаунт), получает НАИМЕНЕЕ
-- привилегированную роль. Fail-safe: ошибка в этом месте не должна раздавать admin.
UPDATE users SET role = 'employee' WHERE role IS NULL;
DO $$
BEGIN
IF NOT EXISTS (
SELECT 1 FROM pg_constraint
WHERE conname = 'users_role_ck' AND conrelid = 'users'::regclass
) THEN
ALTER TABLE users
ADD CONSTRAINT users_role_ck CHECK (role IN ('admin', 'manager', 'employee'));
END IF;
END $$;
-- SET NOT NULL идемпотентен (на уже NOT NULL колонке — no-op) и стоит ПОСЛЕ backfill: на строке
-- с NULL он упал бы, а catch-all выше гарантирует, что таких строк не осталось.
ALTER TABLE users ALTER COLUMN role SET NOT NULL;
-- ---------------------------------------------------------------------------------------------
-- Часть 2: manager_id (org-иерархия)
-- ---------------------------------------------------------------------------------------------
-- FK и CHECK объявлены ОТДЕЛЬНЫМИ шагами, а не inline в ADD COLUMN (как в м.192, где это было
-- частью CREATE TABLE IF NOT EXISTS — «всё или ничего»). Причина: `ADD COLUMN IF NOT EXISTS ...
-- REFERENCES ...` пропускает ВЕСЬ оператор, если колонка уже есть, — на БД, где manager_id
-- когда-то завели руками без FK, миграция отчиталась бы об успехе и оставила связь без
-- ссылочной целостности. Раздельные идемпотентные шаги такого состояния не допускают.
-- Имя FK задано явно тем же, которое сгенерировал бы PostgreSQL для inline-формы, — чтобы схема
-- на проде и схема из чистой сборки не различались именами констрейнтов.
ALTER TABLE users ADD COLUMN IF NOT EXISTS manager_id bigint;
DO $$
BEGIN
IF NOT EXISTS (
SELECT 1 FROM pg_constraint
WHERE conname = 'users_manager_id_fkey' AND conrelid = 'users'::regclass
) THEN
-- ON DELETE SET NULL (зеркало м.192:43): удаление менеджера не должно каскадом сносить
-- его сотрудников — они остаются в реестре без привязки, и это чинится назначением
-- нового менеджера, а не восстановлением строк из бэкапа.
ALTER TABLE users
ADD CONSTRAINT users_manager_id_fkey
FOREIGN KEY (manager_id) REFERENCES users(id) ON DELETE SET NULL;
END IF;
END $$;
DO $$
BEGIN
IF NOT EXISTS (
SELECT 1 FROM pg_constraint
WHERE conname = 'users_role_manager_hierarchy_ck' AND conrelid = 'users'::regclass
) THEN
ALTER TABLE users
ADD CONSTRAINT users_role_manager_hierarchy_ck CHECK (
role NOT IN ('admin', 'manager') OR manager_id IS NULL
);
END IF;
END $$;
-- Запрет self-manager. users_role_manager_hierarchy_ck выше держит только admin/manager; для
-- employee self-FK допускает ссылку строки на саму себя, и `UPDATE users SET manager_id = id`
-- прошёл бы. Через сегодняшний API это недостижимо (team.py:398-406 требует role='manager' у
-- цели, PATCH manager_id вообще не меняет), но 004 делает auth.users ЕДИНСТВЕННЫМ реестром — в
-- него начнёт писать и «Птица», у которой этой валидации нет, а любой будущий WITH RECURSIVE по
-- manager_id на такой строке зациклится. Строчный CHECK ловит самый вероятный случай (опечатка
-- или копипаста собственного id) и стоит ноль.
-- Чего этот констрейнт НЕ ловит: взаимную пару employee↔employee (A.manager_id=B,
-- B.manager_id=A) и ссылку на строку с role<>'manager' — оба требуют чтения ДРУГОЙ строки,
-- строчным CHECK'ом это не выражается (нужен триггер или FK на несуществующий уникальный ключ
-- (id, role)). Инвариант зафиксирован COMMENT'ом к колонке — он живёт в приложении.
DO $$
BEGIN
IF NOT EXISTS (
SELECT 1 FROM pg_constraint
WHERE conname = 'users_manager_not_self_ck' AND conrelid = 'users'::regclass
) THEN
ALTER TABLE users
ADD CONSTRAINT users_manager_not_self_ck CHECK (
manager_id IS NULL OR manager_id <> id
);
END IF;
END $$;
-- Partial index (зеркало м.192:84-86): у admin/manager и у свободных слотов manager_id = NULL,
-- и эти строки никогда не участвуют в выборке «сотрудники этого менеджера». Индексировать NULL'ы
-- значит платить за большую часть таблицы, которая по этому пути не читается.
CREATE INDEX IF NOT EXISTS users_manager_id_idx
ON users (manager_id)
WHERE manager_id IS NOT NULL;
-- ---------------------------------------------------------------------------------------------
-- Часть 3: access_state вместо is_active
-- ---------------------------------------------------------------------------------------------
-- DEFAULT 'active' здесь, в отличие от role, уместен: «доступ есть» — это состояние, в котором
-- заводят любого нового сотрудника, и молчаливый дефолт не расширяет ничьих полномочий.
ALTER TABLE users ADD COLUMN IF NOT EXISTS access_state text NOT NULL DEFAULT 'active';
-- CHECK ставится СРАЗУ после колонки, до backfill'а: тогда он проверяет и сам backfill —
-- опечатка в значении ниже уронит миграцию, а не просочится в данные.
DO $$
BEGIN
IF NOT EXISTS (
SELECT 1 FROM pg_constraint
WHERE conname = 'users_access_state_ck' AND conrelid = 'users'::regclass
) THEN
ALTER TABLE users
ADD CONSTRAINT users_access_state_ck CHECK (
access_state IN ('active', 'trial_expired', 'disabled')
);
END IF;
END $$;
-- Backfill из is_active — под проверкой существования колонки, потому что в конце этого же
-- блока она удаляется: повторный прогон файла обязан пройти без ошибок, а прямое обращение к
-- несуществующей колонке — ошибка парсинга, не «0 строк».
-- EXECUTE (динамический SQL), а не обычные UPDATE внутри IF: обычные операторы уцелели бы лишь
-- благодаря ленивой подготовке операторов в PL/pgSQL (невыполненная ветка не разбирается). Это
-- рабочая, но недокументированная в самом файле деталь реализации; EXECUTE делает независимость
-- от отсутствующей колонки явной для читателя.
DO $$
BEGIN
IF EXISTS (
SELECT 1 FROM pg_attribute
WHERE attrelid = 'users'::regclass
AND attname = 'is_active'
AND NOT attisdropped
) THEN
-- Механическое отображение старой семантики: булев «доступ закрыт» = жёсткая блокировка.
-- `access_state = 'active'` в WHERE — не мёртвое условие: оно фиксирует, что переписывается
-- только значение, доставшееся из DEFAULT, и никогда — уже осмысленно проставленное.
EXECUTE $q$
UPDATE users
SET access_state = 'disabled'
WHERE is_active = false
AND access_state = 'active'
$q$;
-- Точечно: user2 («Брусника», доступ закрыт владельцем 2026-07-30) — не disabled, а
-- trial_expired. Основание: в auth/roles.yaml у него role=expired, то есть исторически он
-- видит trial-экран, а не отказ входа; решение владельца от 2026-07-31 эту семантику
-- сохраняет.
-- Условие `access_state = 'disabled'` — это защита от затирания ручного решения:
-- переводится РОВНО то значение, которое механическая ветка выше только что и вывела.
-- Если к моменту повторного прогона владелец уже открыл «Бруснике» доступ (active) или
-- перевёл её в другое состояние, WHERE не сматчится и решение человека переживёт миграцию.
-- Безусловный UPDATE по username возвращал бы аккаунт в trial_expired после каждого
-- прогона, и разбор «почему у клиента снова экран пробного периода» стоил бы часов при
-- нулевой пользе. Хардкод одного username оправдан: это разовая фиксация конкретного
-- исторического факта, а не правило — общего признака «пробный доступ» в схеме до сих пор
-- не было, выводить его задним числом не из чего.
EXECUTE $q$
UPDATE users
SET access_state = 'trial_expired'
WHERE username = 'user2'
AND access_state = 'disabled'
$q$;
END IF;
END $$;
-- Снятие is_active. Деструктивный шаг — но именно он и есть смысл решения: оставить обе колонки
-- значило бы два источника правды о доступе, расходящихся при первой же правке через UI.
-- Безопасно: на момент этого PR БД `auth` не читается ни одним работающим кодом (Caddy basic_auth
-- + tradein_users по-прежнему обслуживают прод), а данные колонки полностью перенесены выше.
-- DROP обязан жить именно здесь, а не в 003: 003 применён на проде и правке не подлежит.
ALTER TABLE users DROP COLUMN IF EXISTS is_active;
-- ---------------------------------------------------------------------------------------------
-- Часть 4: гранты auth_app под режим единственного реестра (отмена решения 002:22-33)
-- + сужение унаследованного табличного UPDATE до column-level
-- ---------------------------------------------------------------------------------------------
-- 002 намеренно не выдавала INSERT/DELETE на users, и её аргумент был верным для своего момента:
-- в PR-1 не существовало ни кода, ни UI создания аккаунтов, а грант «на будущее» — это открытая
-- операция, которой никто не пользуется и которую никто не тестирует. Аргумент перестаёт
-- применяться ровно сейчас: после полного переезда auth.users — единственный реестр людей, а
-- раздел «Команда» «Меры» (tradein-mvp/backend/app/api/v1/team.py: POST /employees заводит
-- сотрудника, PATCH правит) — единственный интерфейс, которым сотрудника заводят и убирают.
-- Без INSERT переезд физически не состоится: сегодняшний INSERT идёт в tradein_users, а её не
-- станет.
-- DELETE здесь НЕ выдаётся, хотя первая редакция этой миграции его содержала. Причина отказа:
-- DELETE-эндпоинта в team.py нет (только POST /employees и PATCH — проверено), то есть потребителя
-- у права нет ни одного, а 002:22-33 отклоняла ровно такие гранты-на-будущее. Симметричный
-- контраргумент («снять неиспользуемое право дешевле, чем добавлять его в момент релиза») здесь не
-- перевешивает: DELETE по users каскадит на sessions (001:94), то есть цена ошибки в коде выше
-- обычной, а добавить строку GRANT в миграцию того PR, где появится DELETE-хендлер, стоит ровно
-- столько же. Право выдаётся вместе с кодом, который им пользуется, — не раньше.
-- DELETE ≠ закрытие доступа. Закрытие — это access_state ('disabled' / 'trial_expired'):
-- обратимо, сохраняет строку и историю. Именно оно, а не удаление строки, закрывает сегодняшний
-- сценарий «Команды»; удаление понадобилось бы только чтобы убрать ошибочно заведённый слот.
GRANT INSERT ON users TO auth_app;
-- Гранта на последовательность users_id_seq здесь НЕТ — и это не забывчивость.
-- 002:26-27 записала как факт, что «идентичность требует nextval», то есть INSERT из auth_app
-- якобы упадёт с «permission denied for sequence» без USAGE на последовательности. Для
-- `GENERATED ALWAYS AS IDENTITY` (001:53) это неверно: PostgreSQL подставляет не вызов
-- nextval('...'), а узел NextValueExpr, который дёргает nextval_internal(seqid,
-- check_permissions := false) — ACL последовательности не проверяется вовсе. Это документированное
-- отличие identity от serial, и оно проверено живьём на postgres:16, а не выведено из
-- документации: после `REVOKE ALL ON SEQUENCE users_id_seq FROM app` INSERT в identity-таблицу
-- прошёл и вернул id, тогда как в контрольной таблице с bigserial тот же INSERT в тех же
-- условиях упал ровно с «permission denied for sequence».
-- Отсюда два следствия. Первое: грант не нужен — он выдал бы auth_app право звать
-- nextval('users_id_seq') напрямую (жечь идентификаторы) и читать last_value (число заведённых
-- аккаунтов), при том что ни один путь кода этого не делает; это прямо противоречило бы
-- REVOKE ALL ON ALL SEQUENCES из 002:73. Второе: «живая проверка» вида «auth_app сделал INSERT,
-- значит грант рабочий» ничего не доказывает — тот же INSERT проходит и после REVOKE, поэтому
-- проверять надо обратное (REVOKE, затем INSERT).
-- Если users.id когда-нибудь переведут на обычный DEFAULT nextval(...) — грант станет
-- обязательным, и его придётся добавить той же миграцией, что меняет колонку.
-- Сужение UPDATE до column-level. 002:80 выдала ТАБЛИЧНЫЙ `GRANT SELECT, UPDATE ON users`,
-- обосновав его узко («смена пароля самим пользователем и проставление хеша админом»), — но
-- табличный UPDATE автоматически распространяется на любые колонки, добавленные позже. Не сузь
-- мы его здесь, auth_app молча получил бы право писать role и access_state, и периметр 002
-- расширился бы ровно тем, что 004 добавила, без единой строки GRANT.
-- Почему это важно именно для этих двух колонок: любая SQL-инъекция или логическая ошибка в
-- UPDATE-эндпоинте (сегодня такой ровно один — team.py PATCH /employees, COALESCE-список полей
-- по WHERE id = :id) из «испортил профиль» превращалась бы в `SET role='admin' WHERE id=<свой>`
-- или `SET access_state='active' WHERE username='user2'` — тихое повышение до админа и тихое
-- снятие блокировки, без смены пароля, то есть без внешнего признака компрометации. Это ровно
-- тот класс, ради которого 002 и заводила отдельную роль (002:5-6).
-- role в список НЕ включена сознательно: сегодня её не пишет никто (team.py POST вставляет
-- литерал 'employee', PATCH в SET-списке role/manager_id не имеет вовсе). Появится админский
-- путь смены роли — добавится одной строкой новой миграции; это дешевле, чем держать открытым
-- право на эскалацию привилегий «на всякий случай».
-- manager_id по той же причине не включён: назначение сотрудника менеджеру сегодня делается
-- только при создании (INSERT), а не UPDATE'ом.
-- access_state включён — блокировка/разблокировка через «Команду» (сегодняшний
-- `is_active = COALESCE(...)` в PATCH) переезжает именно в эту колонку.
-- REVOKE перед GRANT обязателен и идемпотентен: REVOKE табличной привилегии снимает и
-- колоночные, поэтому повторный прогон файла даёт то же состояние (внутри одной транзакции,
-- то есть без окна «прав нет» для работающего приложения).
REVOKE UPDATE ON users FROM auth_app;
GRANT UPDATE (password_hash, display_name, org_name, email, access_state, updated_at)
ON users TO auth_app;
-- ---------------------------------------------------------------------------------------------
-- COMMENT'ы: переписываем то, что 004 сделала неверным в 001
-- ---------------------------------------------------------------------------------------------
COMMENT ON TABLE users IS
'Единый реестр людей для «Меры» (trade-in) и «Птицы» (Site Finder): идентичность И '
'полномочия. Решение владельца продукта 2026-07-31 — ПОЛНЫЙ переезд: tradein_users '
'удаляется, второго реестра не будет. Прежняя формулировка («роли остаются в продуктовых '
'БД», 001) отменена миграцией 004 — см. её заголовок.';
COMMENT ON COLUMN users.role IS
'Полномочия: admin | manager | employee. Зеркало tradein_users.role (tradein м.192) — код '
'«Меры» должен переехать на эту таблицу без правок в проверках роли. DEFAULT намеренно нет: '
'роль выбирает тот, кто заводит человека; INSERT без роли обязан падать, а не создавать '
'аккаунт с полномочиями «по умолчанию».';
COMMENT ON COLUMN users.manager_id IS
'Self-FK на users(id), ON DELETE SET NULL: удаление менеджера оставляет его сотрудников в '
'реестре без привязки, а не сносит их каскадом. NULL для admin/manager (top-level роли, '
'констрейнт users_role_manager_hierarchy_ck) и для employee без организации. '
'ИНВАРИАНТЫ, КОТОРЫЕ БД НЕ ПРОВЕРЯЕТ (обязан держать КАЖДЫЙ пишущий сюда код — реестр общий '
'для «Меры» и «Птицы»): цель ссылки обязана иметь role = ''manager''; циклы (A→B, B→A) '
'запрещены — рекурсивный обход иерархии на них зациклится. Схемой ловится только ссылка '
'строки на саму себя (users_manager_not_self_ck): остальное требует чтения другой строки и '
'строчным CHECK не выражается. Отсутствие проверки в БД — не разрешение.';
COMMENT ON COLUMN users.access_state IS
'Состояние доступа, три значения — заменило булев is_active (миграция 004). '
'active: вход разрешён. '
'trial_expired: пробный период истёк — при ВЕРНОМ пароле логин отвечает 403 с отдельным '
'кодом и текстом «пробный доступ закончился», сессия не выдаётся (аккаунт видит осмысленный '
'экран, а не «неверный пароль»). '
'disabled: доступ закрыт — generic 401, неотличимо от неверного пароля. '
'Неверный пароль в любом состоянии → generic 401: иначе отдельный ответ для trial_expired '
'стал бы оракулом существования логина. Булев флаг схлопывал бы trial_expired и disabled в '
'одно значение, и trial-экран исчез бы молча. '
'ИНВАРИАНТ ДЛЯ API (в БД не выразим): перевод ПОСЛЕДНЕГО active-админа в любое другое '
'состояние обязан отклоняться на уровне приложения. Констрейнт с role не связан, '
'UPDATE ... SET access_state = ''disabled'' WHERE username = ''admin'' в БД проходит, а после '
'перехода на единую форму входа это self-lockout: не остаётся аккаунта, способного открыть '
'доступ обратно через UI, восстановление — только psql на прод-БД. Сегодня путь закрыт тем, '
'что «Команда» не отдаёт строки с role = ''admin'' никому (team.py); любой новый админский '
'экран, пишущий access_state, обязан проверку восстановить.';
COMMENT ON CONSTRAINT users_role_manager_hierarchy_ck ON users IS
'admin/manager обязаны иметь manager_id IS NULL — это top-level роли, «начальника» у них в '
'этой модели нет (зеркало tradein м.192). Для employee manager_id любой, включая NULL '
'(свободный слот без организации допустим).';
COMMENT ON CONSTRAINT users_manager_not_self_ck ON users IS
'Строка не может быть собственным менеджером (manager_id <> id). Ловит опечатку/копипасту '
'id при ручной правке и у второго потребителя реестра («Птица»), где валидации «Команды» '
'нет. Взаимные пары и ссылку на не-менеджера строчный CHECK не ловит — см. COMMENT к '
'users.manager_id.';
COMMENT ON CONSTRAINT users_access_state_ck ON users IS
'Фиксирует ровно три состояния доступа. Расширение — новой миграцией с ALTER этого '
'констрейнта; тип text + CHECK выбран вместо enum именно ради дешёвого расширения.';
COMMIT;