-- 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;