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
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'ом, деплой не падает.
111 lines
10 KiB
PL/PgSQL
111 lines
10 KiB
PL/PgSQL
-- 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;
|