From e7a658c7facdb74c576750227314bfe84c46f185 Mon Sep 17 00:00:00 2001 From: bot-backend Date: Thu, 30 Jul 2026 21:56:16 +0300 Subject: [PATCH 1/2] =?UTF-8?q?feat(tradein/db):=20seed=20=D1=81=D1=83?= =?UTF-8?q?=D1=89=D0=B5=D1=81=D1=82=D0=B2=D1=83=D1=8E=D1=89=D0=B8=D1=85=20?= =?UTF-8?q?=D1=8E=D0=B7=D0=B5=D1=80=D0=BE=D0=B2=20+=20ASCII-CHECK=20=D0=BD?= =?UTF-8?q?=D0=B0=20username=20(#2557)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Migration 193: переносит org-карту (admin/kopylov/praktika/user1-10), утверждённую владельцем продукта, из auth/roles.yaml в tradein_users (Foundation — миграция 192). password_hash=NULL для всех — пароли админ проставит вручную через team-UI (#2556). ASCII-CHECK на username (deep-review #2561): rbac кодирует session-username через encode("latin-1","replace"), кириллические логины одинаковой длины схлопываются в общий downstream-identity (IDOR) — constraint запрещает это fail-closed. ON CONFLICT DO UPDATE защищает password_hash/is_active/display_name/org_name/email через COALESCE — повторный прогон (recovery / staging без _schema_migrations tracking) не затирает то, что менеджер поменял через UI. --- .../data/sql/193_tradein_users_seed.sql | 112 ++++++++++++++++++ 1 file changed, 112 insertions(+) create mode 100644 tradein-mvp/backend/data/sql/193_tradein_users_seed.sql diff --git a/tradein-mvp/backend/data/sql/193_tradein_users_seed.sql b/tradein-mvp/backend/data/sql/193_tradein_users_seed.sql new file mode 100644 index 00000000..6181b0bb --- /dev/null +++ b/tradein-mvp/backend/data/sql/193_tradein_users_seed.sql @@ -0,0 +1,112 @@ +-- Migration 193: seed существующих юзеров в tradein_users + ASCII-CHECK на username +-- (issue #2557, эпик #2549) +-- +-- WHY: +-- Migration 192 создала schema (tradein_users/tradein_sessions), но без данных — +-- DB-backed auth не может заработать, пока реальные аккаунты (сейчас живущие только +-- в auth/roles.yaml + caddy/users.caddy.snippet, legacy Caddy basic-auth) не отражены +-- в таблице. Эта миграция переносит org-карту, утверждённую владельцем продукта, +-- в данные — без единого пароля (см. WHAT.2) и без Python-кода (backend wiring — #2556, +-- team-UI для проставления паролей — отдельная задача, тоже #2556). +-- +-- ASCII-CHECK (deep-review #2561, обязательное требование ДО прод-данных): +-- rbac кодирует session-username через `encode("latin-1", "replace")`. Кириллические +-- логины ОДИНАКОВОЙ длины схлопываются в одну и ту же byte-строку под этой кодировкой +-- ("иванов" и "петров" оба 6 кириллических символов -> оба превращаются в одинаковую +-- строку из '?' одной длины) -> общий downstream-identity между разными людьми, общая +-- квота, взаимный IDOR (один видит сессии/данные другого). Все текущие org-логины уже +-- ASCII (admin/kopylov/praktika/userN), поэтому constraint не конфликтует с seed'ом +-- ниже; он существует, чтобы navsegda запретить будущим кириллическим логинам попасть +-- в таблицу — fail-closed на уровне схемы, а не на уровне доверия к тому, что кто-то +-- не забудет проверить в UI/API layer. +-- +-- WHAT: +-- 1. ASCII-CHECK: tradein_users_username_ascii_ck CHECK (username ~ '^[A-Za-z0-9._-]{3,64}$'). +-- Добавлен ДО seed-инсертов (порядок важен: constraint должен успеть провалидировать +-- вставляемые строки, а не просто существовать после них). +-- 2. Seed — org-карта, утверждённая владельцем продукта (2026-07-30): +-- admin role=admin, manager_id=NULL, is_active=true (владелец) +-- kopylov role=manager, manager_id=NULL, is_active=true (отдельный клиент) +-- praktika role=manager, manager_id=NULL, is_active=true (ГК «Практика») +-- user1, user3-10 role=employee, manager_id=NULL, is_active=true (свободные слоты, без org) +-- user2 role=employee, manager_id=NULL, is_active=false («Брусника», доступ +-- закрыт 2026-07-30) +-- password_hash = NULL для ВСЕХ — пароли админ проставит вручную через team-UI (#2556). +-- NULL-hash делает password-логин невозможным для этой строки, но НЕ снимает доступ: +-- в переходный период работает только legacy Caddy basic-auth fallback (dual-mode, +-- см. комментарий password_hash в 192_tradein_users_auth.sql) — никто не теряет доступ +-- из-за этой миграции. +-- display_name = 'Копылов' для kopylov (источник — auth.py::_USERNAME_PROFILE, уже +-- задокументированная фамилия). Для остальных — NULL, реальных данных нет, не выдумываем. +-- НЕ мигрируем admintest/pilottest/analysttest/expiredtest — temp QA-фикстуры +-- (auth/roles.yaml), остаются только там, в DB-backed auth не нужны. +-- +-- IDEMPOTENCY: +-- - ADD CONSTRAINT через DO-блок с проверкой pg_constraint (Postgres не поддерживает +-- `ADD CONSTRAINT IF NOT EXISTS` для CHECK) — паттерн из +-- 189_account_estimate_usage_nonnegative.sql. +-- - INSERT ... ON CONFLICT (username) DO UPDATE, но НЕ безусловно: password_hash, +-- is_active, display_name, org_name, email защищены COALESCE(текущее, EXCLUDED) — +-- если админ уже проставил пароль / поменял is_active / display_name вручную через +-- team-UI (#2556) между двумя прогонами этого файла (например ручной re-apply при +-- recovery — обычный auto-apply тречит filename в _schema_migrations и не запускает +-- файл дважды на одном окружении, но scratch/staging БД такого трекинга не имеют), +-- повторный прогон НЕ должен затереть это состояние NULL-ом / seed-дефолтом. +-- role/manager_id намеренно синкаются с EXCLUDED (не защищены) — это и есть источник +-- истины org-карты из этой миграции; если владелец продукта поправит эту таблицу +-- новой миграцией поверх, DO UPDATE-ветка должна донести исправление, а не заморозить +-- первый прогон навсегда. +-- +-- Dependencies: 192_tradein_users_auth.sql (создаёт tradein_users, tradein_sessions). +-- Deploy order: после 192 (Foundation). Backend auth-код (login/password-verify) и +-- team-UI (#2556) — отдельные PR'ы ПОСЛЕ этой миграции (SQL-схема+данные первыми, см. +-- .claude/rules/sql.md "Migration order"). + +BEGIN; + +-- Часть 1: ASCII-CHECK — должен применяться ДО вставки seed-строк ниже. +DO $$ +BEGIN + IF NOT EXISTS ( + SELECT 1 FROM pg_constraint + WHERE conname = 'tradein_users_username_ascii_ck' + ) THEN + ALTER TABLE tradein_users + ADD CONSTRAINT tradein_users_username_ascii_ck + CHECK (username ~ '^[A-Za-z0-9._-]{3,64}$'); + END IF; +END $$; + +COMMENT ON CONSTRAINT tradein_users_username_ascii_ck ON tradein_users IS + 'Fail-closed защита от кириллических/не-ASCII логинов (deep-review #2561): ' + 'rbac кодирует session-username через encode("latin-1","replace"), не-ASCII ' + 'логины одинаковой длины схлопываются в общий downstream-identity (IDOR).'; + +-- Часть 2: seed org-карты (владелец продукта, 2026-07-30). +INSERT INTO tradein_users + (username, password_hash, role, manager_id, display_name, org_name, email, is_active) +VALUES + ('admin', NULL, 'admin', NULL, NULL, NULL, NULL, true), + ('kopylov', NULL, 'manager', NULL, 'Копылов', NULL, NULL, true), + ('praktika', NULL, 'manager', NULL, NULL, NULL, NULL, true), + ('user1', NULL, 'employee', NULL, NULL, NULL, NULL, true), + ('user2', NULL, 'employee', NULL, NULL, NULL, NULL, false), + ('user3', NULL, 'employee', NULL, NULL, NULL, NULL, true), + ('user4', NULL, 'employee', NULL, NULL, NULL, NULL, true), + ('user5', NULL, 'employee', NULL, NULL, NULL, NULL, true), + ('user6', NULL, 'employee', NULL, NULL, NULL, NULL, true), + ('user7', NULL, 'employee', NULL, NULL, NULL, NULL, true), + ('user8', NULL, 'employee', NULL, NULL, NULL, NULL, true), + ('user9', NULL, 'employee', NULL, NULL, NULL, NULL, true), + ('user10', NULL, 'employee', NULL, NULL, NULL, NULL, true) +ON CONFLICT (username) DO UPDATE SET + role = EXCLUDED.role, + manager_id = EXCLUDED.manager_id, + password_hash = COALESCE(tradein_users.password_hash, EXCLUDED.password_hash), + is_active = COALESCE(tradein_users.is_active, EXCLUDED.is_active), + display_name = COALESCE(tradein_users.display_name, EXCLUDED.display_name), + org_name = COALESCE(tradein_users.org_name, EXCLUDED.org_name), + email = COALESCE(tradein_users.email, EXCLUDED.email), + updated_at = now(); + +COMMIT; -- 2.45.3 From f915e1ee4d28d4032a3e1b08eb06a67db898786e Mon Sep 17 00:00:00 2001 From: bot-backend Date: Thu, 30 Jul 2026 22:08:14 +0300 Subject: [PATCH 2/2] =?UTF-8?q?fix(tradein/db):=20=D0=BD=D0=B5=20=D0=B7?= =?UTF-8?q?=D0=B0=D1=82=D0=B8=D1=80=D0=B0=D1=82=D1=8C=20manager=5Fid=20?= =?UTF-8?q?=D0=BF=D1=80=D0=B8=20=D0=BF=D0=BE=D0=B2=D1=82=D0=BE=D1=80=D0=BD?= =?UTF-8?q?=D0=BE=D0=BC=20=D0=BF=D1=80=D0=BE=D0=B3=D0=BE=D0=BD=D0=B5=20?= =?UTF-8?q?=D1=81=D0=B8=D0=B4=D0=B0=20(#2557)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Deep-review #2564: manager_id синкался из EXCLUDED безусловно — повторный прогон сида тихо обнулял связь сотрудник->менеджер, назначенную через team-API (#2563), сотрудник выпадал из _LIST_EMPLOYEES_BY_MANAGER_SQL. Защищён COALESCE, как остальные UI-managed поля. is_active убран из ON CONFLICT DO UPDATE SET вовсе (не COALESCE — колонка NOT NULL DEFAULT true делала бы COALESCE-ветку недостижимой, мёртвый код вводил в заблуждение симметрией с реально работающими COALESCE-полями). Open/close доступа — решение владельца продукта через UI (#2556), не повторный прогон seed-файла. --- .../data/sql/193_tradein_users_seed.sql | 55 ++++++++++++------- 1 file changed, 35 insertions(+), 20 deletions(-) diff --git a/tradein-mvp/backend/data/sql/193_tradein_users_seed.sql b/tradein-mvp/backend/data/sql/193_tradein_users_seed.sql index 6181b0bb..7eea9d95 100644 --- a/tradein-mvp/backend/data/sql/193_tradein_users_seed.sql +++ b/tradein-mvp/backend/data/sql/193_tradein_users_seed.sql @@ -22,8 +22,9 @@ -- -- WHAT: -- 1. ASCII-CHECK: tradein_users_username_ascii_ck CHECK (username ~ '^[A-Za-z0-9._-]{3,64}$'). --- Добавлен ДО seed-инсертов (порядок важен: constraint должен успеть провалидировать --- вставляемые строки, а не просто существовать после них). +-- Добавлен ДО seed-инсертов ниже для читаемости файла (CHECK — immediate constraint, +-- Postgres валидирует им и ROW-строки транзакции независимо от того, в каком месте +-- файла он объявлен относительно INSERT, так что порядок сам по себе не критичен). -- 2. Seed — org-карта, утверждённая владельцем продукта (2026-07-30): -- admin role=admin, manager_id=NULL, is_active=true (владелец) -- kopylov role=manager, manager_id=NULL, is_active=true (отдельный клиент) @@ -46,16 +47,27 @@ -- `ADD CONSTRAINT IF NOT EXISTS` для CHECK) — паттерн из -- 189_account_estimate_usage_nonnegative.sql. -- - INSERT ... ON CONFLICT (username) DO UPDATE, но НЕ безусловно: password_hash, --- is_active, display_name, org_name, email защищены COALESCE(текущее, EXCLUDED) — --- если админ уже проставил пароль / поменял is_active / display_name вручную через --- team-UI (#2556) между двумя прогонами этого файла (например ручной re-apply при --- recovery — обычный auto-apply тречит filename в _schema_migrations и не запускает --- файл дважды на одном окружении, но scratch/staging БД такого трекинга не имеют), --- повторный прогон НЕ должен затереть это состояние NULL-ом / seed-дефолтом. --- role/manager_id намеренно синкаются с EXCLUDED (не защищены) — это и есть источник --- истины org-карты из этой миграции; если владелец продукта поправит эту таблицу --- новой миграцией поверх, DO UPDATE-ветка должна донести исправление, а не заморозить --- первый прогон навсегда. +-- manager_id, display_name, org_name, email защищены COALESCE(текущее, EXCLUDED) — +-- если админ уже проставил пароль / назначил manager_id (team-API #2563 пишет +-- manager_id при создании сотрудника менеджером) / поменял display_name вручную +-- через team-UI (#2556) между двумя прогонами этого файла (например ручной re-apply +-- при recovery — обычный auto-apply тречит filename в _schema_migrations и не +-- запускает файл дважды на одном окружении, но scratch/staging БД такого +-- трекинга не имеют), повторный прогон НЕ должен затереть это состояние NULL-ом / +-- seed-дефолтом. Deep-review #2564 нашёл это живым багом: manager_id, назначенный +-- через #2563, тихо обнулялся повторным прогоном сида — employee выпадал из +-- `_LIST_EMPLOYEES_BY_MANAGER_SQL`, менеджер переставал видеть его в дашборде. +-- role намеренно синкается с EXCLUDED (не защищён) — это и есть источник истины +-- org-карты из этой миграции; если владелец продукта поправит эту таблицу новой +-- миграцией поверх, DO UPDATE-ветка должна донести исправление роли, а не +-- заморозить первый прогон навсегда. +-- - is_active НАМЕРЕННО отсутствует в SET (не COALESCE — колонка NOT NULL DEFAULT +-- true, COALESCE(NOT NULL, x) никогда не берёт x, это была бы мёртвая, вводящая в +-- заблуждение симметрия с password_hash/manager_id, deep-review #2564 medium). +-- Открытие/закрытие доступа (is_active) — решение владельца продукта, принимается +-- через UI (#2556), НЕ повторным прогоном этого seed-файла: после первой вставки +-- колонка сознательно «замораживается» на текущем значении в БД, seed её больше +-- не трогает. -- -- Dependencies: 192_tradein_users_auth.sql (создаёт tradein_users, tradein_sessions). -- Deploy order: после 192 (Foundation). Backend auth-код (login/password-verify) и @@ -64,7 +76,7 @@ BEGIN; --- Часть 1: ASCII-CHECK — должен применяться ДО вставки seed-строк ниже. +-- Часть 1: ASCII-CHECK (immediate constraint — валидирует и вставляемые ниже строки). DO $$ BEGIN IF NOT EXISTS ( @@ -100,13 +112,16 @@ VALUES ('user9', NULL, 'employee', NULL, NULL, NULL, NULL, true), ('user10', NULL, 'employee', NULL, NULL, NULL, NULL, true) ON CONFLICT (username) DO UPDATE SET - role = EXCLUDED.role, - manager_id = EXCLUDED.manager_id, + role = EXCLUDED.role, + -- manager_id защищён COALESCE: team-API (#2563) пишет manager_id при назначении + -- сотрудника менеджером, повторный прогон seed'а не должен тихо обнулять эту связь. + manager_id = COALESCE(tradein_users.manager_id, EXCLUDED.manager_id), password_hash = COALESCE(tradein_users.password_hash, EXCLUDED.password_hash), - is_active = COALESCE(tradein_users.is_active, EXCLUDED.is_active), - display_name = COALESCE(tradein_users.display_name, EXCLUDED.display_name), - org_name = COALESCE(tradein_users.org_name, EXCLUDED.org_name), - email = COALESCE(tradein_users.email, EXCLUDED.email), - updated_at = now(); + display_name = COALESCE(tradein_users.display_name, EXCLUDED.display_name), + org_name = COALESCE(tradein_users.org_name, EXCLUDED.org_name), + email = COALESCE(tradein_users.email, EXCLUDED.email), + -- is_active НЕ в SET: NOT NULL DEFAULT true колонка, COALESCE был бы мёртвым кодом + -- (см. IDEMPOTENCY выше) — open/close доступа решается через UI (#2556), не seed'ом. + updated_at = now(); COMMIT; -- 2.45.3