gendesign/data/sql/auth/003_users_seed.sql
bot-backend bea61f6cd9
All checks were successful
CI Trade-In / changes (pull_request) Successful in 14s
CI / changes (pull_request) Successful in 14s
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 3m0s
CI / backend-tests (pull_request) Successful in 15m35s
feat(auth): отдельная БД auth — фундамент единого входа «Меры» и «Птицы»
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'ом, деплой не падает.
2026-07-31 21:47:33 +03:00

111 lines
10 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/003: сид 13 существующих аккаунтов (org-карта владельца продукта, 2026-07-30/31).
--
-- WHY:
-- 001 создала схему, но без данных единая форма входа не заработает: реальные аккаунты
-- сейчас живут только в Caddy basic_auth (caddy/users.caddy.snippet + tradein auth/roles.yaml)
-- и в tradein_users. Эта миграция переносит список людей — БЕЗ ЕДИНОГО ПАРОЛЯ.
--
-- password_hash = NULL у ВСЕХ строк. Это конвенция репо, а не недоделка: ни plaintext, ни
-- bcrypt-хеш не должны попадать в git (прецедент — tradein-mvp/backend/data/sql/
-- 193_tradein_users_seed.sql, там сид тоже вставляет NULL, хеши проставляются отдельно на
-- проде). Хеш в git — это офлайн-brute-force для любого, кто получил доступ к репозиторию,
-- и он переживает любую ротацию пароля в истории коммитов.
-- Пока hash = NULL, вход по паролю через новую форму для строки невозможен, но доступ НЕ
-- теряется: PR-1 ничего не переключает, прод продолжает пускать через существующий
-- Caddy basic_auth ровно как сейчас. Переключение — отдельные PR'ы.
--
-- Состав (утверждён владельцем продукта):
-- admin — владелец
-- kopylov — отдельный клиент, display_name «Копылов»
-- praktika — ГК «Практика»
-- user1, user3..user10 — свободные слоты, is_active = true
-- user2 — «Брусника», is_active = FALSE (доступ закрыт 2026-07-30);
-- в roles.yaml он role=expired — расхождение семантики,
-- см. ⚠️ у строки user2 в VALUES ниже
-- display_name заполнен только у kopylov (единственная фамилия, подтверждённая в коде:
-- tradein auth.py::_USERNAME_PROFILE). Остальным NULL — реальных данных нет, выдумывать
-- нельзя: выдуманное ФИО в UI неотличимо от настоящего.
-- QA-фикстуры НЕ мигрируются — им нечего делать в общем хранилище доступов двух продуктов.
-- Состав фикстур неоднороден, и это важно при сверке списков (проверено по обоим файлам):
-- admintest, pilottest — действующие логины: есть И в caddy/users.caddy.snippet
-- (basic_auth-запись с хешем), И в auth/roles.yaml (role-mapping). Реально входят.
-- analysttest, expiredtest — существуют ТОЛЬКО в auth/roles.yaml как role-mapping,
-- basic_auth-записи в caddy/users.caddy.snippet у них нет, то есть войти под ними
-- снаружи сегодня нельзя вообще. Это тестовые фикстуры, а не аккаунты: analysttest
-- гоняется в backend/tests (test_rbac.py, test_insights.py, test_audit_middleware.py),
-- expiredtest — в tradein-mvp/backend/tests/test_rbac.py как покрытие role=expired.
--
-- IDEMPOTENCY (логика и обоснование перенесены из tradein м.193, deep-review #2564):
-- INSERT ... ON CONFLICT (username) DO UPDATE, но НЕ безусловно: password_hash, display_name,
-- org_name, email защищены COALESCE(текущее, EXCLUDED). Если админ уже проставил пароль или
-- поправил профиль между двумя прогонами файла (обычный auto-apply трекает filename в
-- _schema_migrations и не запускает файл дважды на одном окружении — но ручной re-apply при
-- recovery и scratch/staging БД такого трекинга не имеют), повторный прогон НЕ должен
-- затереть это состояние NULL-ом. В м.193 это был живой баг: назначенный через API manager_id
-- тихо обнулялся повторным прогоном сида.
-- Направление COALESCE односторонее: NULL в БД можно дозаполнить значением из сида, но
-- значение из БД никогда не перетирается сидом.
--
-- is_active НАМЕРЕННО отсутствует в SET — и не как COALESCE тоже: колонка NOT NULL, значит
-- COALESCE(NOT NULL-значение, x) никогда не возьмёт x, это был бы мёртвый код с видимостью
-- защиты. Открытие/закрытие доступа — решение владельца продукта, оно принимается в
-- интерфейсе, а не повторным прогоном seed-файла: после первой вставки колонка сознательно
-- «замораживается» на текущем значении в БД.
-- (В м.193 в SET присутствовал ещё role — как источник истины org-карты. Здесь колонки role
-- нет вовсе: полномочия остаются в продуктовых БД, см. заголовок 001.)
--
-- updated_at = now() выставляется на любом конфликте, даже когда ни одна колонка фактически
-- не изменилась — паритет с м.193; «строка была затронута прогоном сида» это честно отражает.
--
-- Разрывы в users.id после повторного прогона — норма, НЕ следы удалённых строк. Дефолт
-- GENERATED ALWAYS AS IDENTITY вычисляется ДО обнаружения конфликта, поэтому каждый
-- повторный прогон сжигает 13 значений последовательности впустую. Функционально безвредно;
-- упомянуто, чтобы дыры в id не увели разбор инцидента в сторону «кого-то удалили».
--
-- Dependencies: 001_identity_schema.sql (users + ASCII-CHECK на username; все логины ниже
-- ASCII, констрейнту не противоречат).
BEGIN;
INSERT INTO users (username, password_hash, display_name, org_name, email, is_active)
VALUES
('admin', NULL, NULL, NULL, NULL, true),
('kopylov', NULL, 'Копылов', NULL, NULL, true),
('praktika', NULL, NULL, NULL, NULL, true),
('user1', NULL, NULL, NULL, NULL, true),
-- user2 — «Брусника», доступ закрыт владельцем продукта 2026-07-30.
--
-- ⚠️ ОТКРЫТАЯ РАЗВИЛКА, решается в PR-2/3 (переключение на единую форму входа), НЕ здесь:
-- сегодня в auth/roles.yaml у user2 role=expired, и семантика ДРУГАЯ, чем is_active=false.
-- expired != disabled: expired-юзер проходит гейт (basic_auth-запись в
-- caddy/users.caddy.snippet у него есть), доходит до фронта и видит осмысленный экран
-- «пробный доступ закончился» (roles.yaml → блок expired: paths: [] + deny "/**";
-- frontend NoAccessScreen variant="trial"). is_active=false — это отказ на этапе входа,
-- неотличимый для пользователя от «неверный пароль».
-- Сейчас расхождение безобидно: PR-1 ничего не переключает, прод по-прежнему ходит через
-- Caddy basic_auth + roles.yaml, и никакой код эту колонку не читает. Но в момент
-- переключения trial-экран пропадёт МОЛЧА — тесты не упадут, роль просто перестанет
-- существовать как состояние. Решать тогда: если trial-UX сохраняем, нужно отдельное
-- состояние (колонка status / отдельная роль), а не булев флаг — is_active схлопывает
-- «доступ закрыт» и «пробный период истёк» в одно значение. Схему в этом PR НЕ трогаем.
('user2', NULL, NULL, NULL, NULL, false),
('user3', NULL, NULL, NULL, NULL, true),
('user4', NULL, NULL, NULL, NULL, true),
('user5', NULL, NULL, NULL, NULL, true),
('user6', NULL, NULL, NULL, NULL, true),
('user7', NULL, NULL, NULL, NULL, true),
('user8', NULL, NULL, NULL, NULL, true),
('user9', NULL, NULL, NULL, NULL, true),
('user10', NULL, NULL, NULL, NULL, true)
ON CONFLICT (username) DO UPDATE SET
-- COALESCE(текущее, EXCLUDED): сид дозаполняет пустые поля, но никогда не затирает
-- уже проставленные вручную (в первую очередь password_hash — иначе повторный прогон
-- отключал бы вход всем, кому пароль уже выдали).
password_hash = COALESCE(users.password_hash, EXCLUDED.password_hash),
display_name = COALESCE(users.display_name, EXCLUDED.display_name),
org_name = COALESCE(users.org_name, EXCLUDED.org_name),
email = COALESCE(users.email, EXCLUDED.email),
-- is_active НЕ в SET: NOT NULL-колонка, COALESCE был бы мёртвым кодом (см. IDEMPOTENCY).
updated_at = now();
COMMIT;