From bea295d222c81919d652a3944ab153e7302531f0 Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Mon, 5 Oct 2026 02:23:31 +0400 Subject: [PATCH] =?UTF-8?q?chore(openspec):=20=D1=80=D1=83=D0=BB=D0=B8?= =?UTF-8?q?=D0=BD=D0=B3=D0=B8=20R-23,=20R-24=20(design=20#34=E2=80=9335),?= =?UTF-8?q?=20=D1=81=D0=BF=D0=B5=D0=BA=D0=B0=20users-admin=20=D1=83=D1=82?= =?UTF-8?q?=D0=BE=D1=87=D0=BD=D0=B5=D0=BD=D0=B0=20(I-1),=20=D0=BF=D0=BE?= =?UTF-8?q?=D0=BF=D1=80=D0=B0=D0=B2=D0=BA=D0=B8=20design=20#22/#28?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_012vJkmBGKKqMoEMzvsXG3y2 --- openspec/changes/slice-1-directories/design.md | 8 +++++--- openspec/changes/slice-1-directories/proposal.md | 2 +- .../changes/slice-1-directories/specs/users-admin/spec.md | 6 ++++-- 3 files changed, 10 insertions(+), 6 deletions(-) diff --git a/openspec/changes/slice-1-directories/design.md b/openspec/changes/slice-1-directories/design.md index 8009814..0594dfe 100644 --- a/openspec/changes/slice-1-directories/design.md +++ b/openspec/changes/slice-1-directories/design.md @@ -83,7 +83,7 @@ WHERE is_current`; `ALTER TYPE audit_action ADD VALUE` ×2 (`LEGAL_ENTITY_CHANGE (pre-check `lower(email)`); `external_id` не задаётся (появится при первом входе через РАИСА — Срез JWT). Матрица: `PUT /admin/roles/{code}/permissions` заменяет набор целиком (diff → `ROLE_PERMISSION_CHANGE` с before/after); системные роли (`is_system`) переименовывать/деактивировать нельзя; нельзя снять `USERS_ADMIN` у роли, если это единственная роль с этим правом -у активных пользователей, и нельзя снять её у себя. Новые коды прав не создаются (комментарий seed). Повторный seed +у активных пользователей, (защита системная: «у себя» запрещено лишь пока ты — последний активный обладатель права, решение №34). Новые коды прав не создаются (комментарий seed). Повторный seed **возвращает** права, снятые администратором (вставка недостающих пар `ON CONFLICT DO NOTHING`; лишние права не отзывает) — известное ограничение (OQ из Среза 0 M-8), задокументировано в README администратора. @@ -153,15 +153,17 @@ WHERE is_current`; `ALTER TYPE audit_action ADD VALUE` ×2 (`LEGAL_ENTITY_CHANGE | 19 | 2026-10-04 | R-8 (SDD фаза 1, Task 5, приняты решения исполнителя): `crm_id` и `name` нормализуются (strip; пустой `crm_id` → NULL; пустое/`null` имя → 422); `office_for_create` применяется и к PATCH (филиал не может перепривязать ГК на чужой филиал, `null` у филиала → свой филиал; у ГО `null` очищает); новый 422 `OFFICE_NOT_FOUND` на несуществующий филиал (без проверки `is_active`); pre-check `crm_id` глобальный (409 раскрывает лишь факт существования строки `crm_id`, не данные ГК); повторный archive/unarchive — no-op без аудита; список ГК — одна Query-модель `CompanyGroupListQuery` (FastAPI не принимает две); сортировка `name, id` | единая семантика для остальных справочников; спека не уточняла | пересмотр нормализации/слабого oracle `crm_id` — правка сервиса; снятие `office_for_create` с PATCH — одна строка | | 20 | 2026-10-04 | R-9 (SDD фаза 1, Task 6, приняты решения исполнителя): филиал ЮЛ идёт через `office_for_create` только при фактической смене значения (совпадающий с текущим пропускается — no-op PATCH без последствий); новые 409 `ALREADY_CLOSED` (повторный CLOSE), `NOT_IN_COMPANY_GROUP` (REMOVE_FROM_GROUP у ЮЛ без ГК), `LEGAL_ENTITY_CLOSED` (перевод закрытого ЮЛ); «сегодня» для `effective_date` — дата UTC; `move` не меняет `regional_office_id`; `move`/`withdraw` применяются сразу, `effective_date` пишется только в историю; PATCH не принимает `inn`/`company_group_id`; ИНН — только ASCII-цифры, внутренние пробелы убираются; `crm_id` ЮЛ нормализуется и проверяется глобально (как у ГК) | спека не называла; защита инвариантов (закрытый ЮЛ без ГК) и единообразие с ГК | пересмотр кодов/семантики даты — правка сервиса; UTC vs MSK — сдвиг границы на 3 часа | | 21 | 2026-10-04 | R-10 (SDD фаза 1, Task 7, приняты решения исполнителя): `ALIAS_TAKEN` раскрывает только `legal_entity_id` владельца (даже вне видимости пользователя; имя и ИНН не отдаются); `alias` хранится дословно, строка из пробелов → 422 `EMPTY_ALIAS`; чужой/несуществующий `alias_id` → одинаковый 404 «Алиас не найден» после проверки видимости ЮЛ; `DELETE` алиаса требует `DIRECTORY_DELETE` (по seed — только ADMIN) | спека требует `legal_entity_id` владельца; единообразие 404 | при желании скрыть владельца — убрать поле из `extra`; расширить `DIRECTORY_DELETE` — отдельное решение по матрице прав | -| 22 | 2026-10-04 | R-11 (SDD фаза 1, Task 8, приняты решения исполнителя): `add_member` возвращает пару (членство, ЮЛ) и проверяет сначала видимость ГСК/ГСЗ через ГК (явный join `CompanyGroup`, T4.1), затем ЮЛ через scope; список ГСК/ГСЗ ГК — простой массив (не `Page`; групп у ГК единицы); `DELETE …/members/{id}` требует `DIRECTORY_EDIT` (спека: `DIRECTORY_DELETE` — только удаление ГСК/ГСЗ и алиасов); при удалении группы весь её состав сохраняется в `before_value` аудита; общий `src/common/clock.py:utc_now` вместо локальных `_now` | спека и бриф; единообразие аудита («для списков — весь набор») | смена формы списка до фронта бесплатна; удаление группы, на которую ссылаются consolidation/ex_snapshot, даст 500 вместо 409 (T8.1, deferred к OQ-34) | +| 22 | 2026-10-04 | R-11 (SDD фаза 1, Task 8, приняты решения исполнителя): `add_member` возвращает пару (членство, ЮЛ) и проверяет сначала видимость ГСК/ГСЗ через ГК (явный join `CompanyGroup`, T4.1), затем ЮЛ через scope; список ГСК/ГСЗ ГК — простой массив (не `Page`; групп у ГК единицы); `DELETE …/members/{id}` требует `DIRECTORY_EDIT` (спека: `DIRECTORY_DELETE` — только удаление ГСК/ГСЗ и алиасов); при удалении группы весь её состав сохраняется в `before_value` аудита; общий `src/common/clock.py:utc_now` вместо локальных `_now` | спека и бриф; единообразие аудита («для списков — весь набор») | смена формы списка до фронта бесплатна; удаление группы, на которую ссылаются consolidation/ex_snapshot, даст нарушение FK (23503) — общий 409 со строкой «нарушение целостности данных в БД», без кода `GROUP_IN_USE` (T8.1, deferred к OQ-34; в первоначальной формулировке ошибочно значилось «500») | | 23 | 2026-10-04 | R-12 (SDD фаза 1→2, контроллер, T8.3): список ГСК/ГСЗ одной ГК остаётся простым массивом; требование «списки `{items,total,limit,offset}`» относится к спискам верхнего уровня (ГК, ЮЛ, пользователи) | размер ограничен составом ГК (единицы групп); спека `directories-api` пагинацию для него не требует; фронт Среза 1 его не использует | обернуть в `Page` до появления UI справочника; смена формата до фронта бесплатна | | 24 | 2026-10-04 | R-13 (SDD фаза 1→2, контроллер, O-5): дубль `RegionalOfficeOut` в `schemas/me.py` и `schemas/directories.py` устраняется — одна модель в `schemas/directories.py`, `schemas/me.py` импортирует её (Task 12); ответ `/me` не меняется | две одноимённые Pydantic-модели порождают OpenAPI-компоненты с длинными именами и расхождение схем | вернуть локальную модель в `me.py` | | 25 | 2026-10-04 | R-14 (SDD фаза 2, pre-flight): Task 10 выносит логику update ГК и ЮЛ в модульные функции `apply_group_update` / `apply_entity_update` (`services/company_groups.py`, `services/legal_entities.py`), `*Service.update` вызывает их; план называл их существующими, но в коде фазы 1 были только методы | импорт обязан писать историю/аудит так же, как ручная правка; дублирование — расхождение поведения | вернуть дублирование; регрессию фазы 1 ловят её тесты | | 26 | 2026-10-04 | R-15 (SDD фаза 2, Task 9, находка исполнителя): на фикстуре «Русский Бисквит» ожидается 20 ЮЛ/ИП (18 ЮЛ + 2 ИП), `DUPLICATE_IN_FILE` в этом файле не возникает (ФЛ с тем же ИНН идёт после ИП и отсекается типом); на полной выгрузке `groups_created = 344` (ГК `1-5AL0CDM` целиком из дублей), `LE_IN_OTHER_GROUP` на пустой БД недостижим и в ожиданиях не проверяется; текст спеки `directory-import`, tasks.md, design.md и план приведены к факту | данные важнее ориентировочных цифр спеки; ожидания фиксируются тестами по реальным файлам | поправить числа в тестах и спеке | | 27 | 2026-10-04 | R-16 (SDD фаза 2, Task 9, приняты решения исполнителя): `errors` считает итоговые коды-пропуски; `GK_EXISTS` и для неизменной ГК (идемпотентность); РФ разрешается один раз на ГК после проверок пустого iD ГК и конфликта имени (нет «осиротевших» филиалов, `RF_CREATED = 53`); ИНН считается «встречавшимся» после проверок типа и формата; офис нового ЮЛ = действующий РФ ГК; ГСК/ГСЗ создаётся по флагу даже если у ЮЛ уже есть членство того же вида в другой группе (буквально §13.2, возможна пустая группа — T9.5); флаги обрабатываются и при `skip` (добавляется отсутствующее членство); значение колонки ГСК «входит в ГСЗ» → `UNKNOWN_FLAG`; xlsx читается с `reset_dimensions()`; пины `openpyxl==3.1.5`, `et_xmlfile==2.0.0`, `types-openpyxl` (dev) | единообразие со спекой и идемпотентность повторного импорта | каждое — локальная правка `plan_import` и теста | -| 28 | 2026-10-04 | R-17 (SDD фаза 2, Task 10, приняты решения исполнителя): общие `apply_group_update`/`apply_entity_update(session, audit, obj, changes: dict, *, now, description=None)` принимают словарь изменений (PATCH и импорт делят одну реализацию), `snapshot_entity`/`touch_entity` — функции модуля; обновления ГК/ЮЛ импортом пишут собственные события `GROUP_CHANGE`/`ASSIGNMENT_CHANGE`/`LEGAL_ENTITY_CHANGE` с `description="импорт ЕЦП"` помимо одного `DIRECTORY_IMPORT` (создания — только сводным событием); тест `dry_run` — на отдельной БД с зафиксированным seed, т.к. `session.rollback()` под SAVEPOINT-фикстурой `app` сносит seed теста; `IMPORT_MAX_BYTES` также в `.env.test`; имя файла сводится к basename (путь отбрасывается, пустое/`.`/`..` → `import`) для формата, `description` и ключа S3; `s3_object.sha256` заполняется, `content_type` — по расширению; сначала строка `s3_object` + flush, затем `put_object` (ошибка S3 откатывает импорт); разбор файла в `asyncio.to_thread`; `created_date` ГСК/ГСЗ не заполняется; 413 проверяется в эндпоинте (до чтения) и в сервисе (`HTTPException` из сервиса, как в брифе); архивные ГК входят в снимок и при `update` не разархивируются; сообщение `LE_IN_OTHER_GROUP` называет ЮЛ из другой ГК независимо от филиала пользователя (импорт — только `DIRECTORY_ADMIN`, права — данные) | единообразие истории/аудита ручной правки и импорта; честная проверка rollback; безопасность ключа S3 | пересмотр — правка сервиса/теста; ограничить раскрытие имён при выдаче `DIRECTORY_ADMIN` филиальному пользователю — убрать имя из сообщения | +| 28 | 2026-10-04 | R-17 (SDD фаза 2, Task 10, приняты решения исполнителя): общие `apply_group_update`/`apply_entity_update(session, audit, obj, changes: dict, *, now, description=None)` принимают словарь изменений (PATCH и импорт делят одну реализацию), `snapshot_entity`/`touch_entity` — функции модуля; обновления ГК/ЮЛ импортом пишут собственные события `GROUP_CHANGE`/`ASSIGNMENT_CHANGE`/`LEGAL_ENTITY_CHANGE` с `description="импорт ЕЦП"` помимо одного `DIRECTORY_IMPORT` (создания — только сводным событием); тест `dry_run` — на отдельной БД с зафиксированным seed, т.к. `session.rollback()` под SAVEPOINT-фикстурой `app` сносит seed теста; `IMPORT_MAX_BYTES` также в `.env.test`; имя файла сводится к basename (путь отбрасывается, пустое/`.`/`..` → `import`) для формата, `description` и ключа S3; `s3_object.sha256` заполняется, `content_type` — по расширению; сначала строка `s3_object` + flush, затем `put_object` (ошибка S3 откатывает импорт); разбор файла в `asyncio.to_thread`; `created_date` ГСК/ГСЗ не заполняется; 413 проверяется в эндпоинте (до чтения) и в сервисе (`HTTPException` из сервиса, как в брифе); архивные ГК входят в снимок и при `update` не разархивируются; `put_object` выполняется до commit — при сбое commit в бакете остаётся объект без строки `s3_object` (зачистка — follow-up, M-7); сообщение `LE_IN_OTHER_GROUP` называет ЮЛ из другой ГК независимо от филиала пользователя (импорт — только `DIRECTORY_ADMIN`, права — данные) | единообразие истории/аудита ручной правки и импорта; честная проверка rollback; безопасность ключа S3 | пересмотр — правка сервиса/теста; ограничить раскрытие имён при выдаче `DIRECTORY_ADMIN` филиальному пользователю — убрать имя из сообщения | | 29 | 2026-10-04 | R-18 (SDD фаза 2, Task 11, приняты решения исполнителя): сборка приложения в `tests/conftest.py` вынесена в приватные `_build_application`/`_client_factory`, добавлены фикстуры `committed_sessionmaker`/`committed_client`/`committed_app`/`committed_storage` (отдельная БД с зафиксированным seed — для тестов с явным `rollback`); `safe_filename`: убираются управляющие символы (Cc), имя ≤ 200 символов, суффикс ≤ 16 (иначе отбрасывается); замер ≤ 60 с — только HTTP-запрос полной выгрузки (факт 1,14 с) | не дублировать фабрику приложения; ключ S3 ≤ 1024 байт | косметика тестов; смена лимита имени — константа | | 30 | 2026-10-04 | R-19 (SDD фаза 2, Task 12, приняты решения исполнителя): R-13 реализован иерархией — `RegionalOfficeBrief` (`/me`, поля `id,name,is_head_office`) и `RegionalOfficeOut(RegionalOfficeBrief)` (+`is_active`; справочник и `UserOut`), одно определение в `schemas/directories.py` (буквальный импорт добавил бы `is_active` в `/me`); несуществующий филиал в теле — 422 `OFFICE_NOT_FOUND` (как R-8), не 404; email хранится в форме `EmailStr` (домен в нижнем регистре), сравнение `strip().lower()`; `UserCreate` тоже `extra=forbid` (явный 422 на `external_id`); аудит создания содержит и `employee_no`; `USER_ROLE_CHANGE`: before — коды по алфавиту, after — в порядке запроса без дублей, повтор набора — без аудита; `UNKNOWN_ROLE`/`UNKNOWN_PERMISSION` несут список кодов; LAST_ADMIN сериализуется блокировкой `FOR NO KEY UPDATE` строки права `USERS_ADMIN` (+ строка пользователя/роли), порядок «объект → право» без дедлоков (`FOR UPDATE` дедлочит с FK-проверкой вставки аудита); LAST_ADMIN считает активных пользователей с активными ролями, несущими право (код роли не читается) | безопасность администрирования; `/me` не меняется | вернуть локальную модель в `me.py`; сменить код 422↔404 — одна проверка; снять блокировку — риск остаться без администратора при гонке | | 31 | 2026-10-04 | R-20 (SDD фаза 2, Task 13, приняты решения исполнителя): условие dev-входа — литерал `process.env.NODE_ENV !== 'production'` прямо в `DevLogin` (rsbuild подменяет `process.env` объектом `REACT_APP_*`, через `Config` `NODE_ENV` недоступен; в prod-бандле формы и `dev/token` нет — проверено grep); `AuthGate` оборачивает весь `Layout` (а не только `Routes`), чтобы форма входа не рисовалась рядом с сайдбаром; профиль детям — `MeContext`/`useCurrentUser`; Redux Provider каркаса не удалён; токен: `sessionStorage` с запасным токеном в памяти при недоступном хранилище; клиент: сетевые ошибки → `ApiError(0,'NETWORK')`, не-JSON 2xx → `BAD_RESPONSE`; в prod ошибка `/me` показывается в `Alert` с кнопкой «Повторить»; типы `Permission.object_type/action` — union по enum бэкенда | D-9, безопасность prod-сборки, устойчивость клиента | вернуть `AuthGate` вокруг `Routes`; при смене сборщика пересмотреть условие `NODE_ENV` | | 32 | 2026-10-04 | R-21 (SDD фаза 2, Task 14, приняты решения исполнителя): `/admin` берёт профиль из `useCurrentUser()` (контекст `AuthGate`), а не `useMe()` (второго `/me` нет); пункт меню «Пользователи и роли» скрыт без `USERS_ADMIN` (страница по URL всё равно закрыта 403 без запросов к `/admin/*`); корень приложения обёрнут в antd `` (`index.tsx`, вне списка файлов брифа) — уведомления через `App.useApp().message`; общие хуки (`useNotifyError`/`useMountedRef`/`useAsyncData`) — `widgets/Admin/hooks.ts`, константа `USERS_ADMIN` — `auth/permissions.ts`; строки таблицы показывают серверное состояние (без оптимистичных обновлений), при ошибке список перечитывается, на время сохранения блокируются контролы всех строк; матрица: PUT по ролям строго последовательно (защита от гонки снятий `USERS_ADMIN`), после сохранения — слияние ответов, сброс черновика, перечитывание; неактивную роль нельзя назначить, но можно снять; черновик матрицы — `Map` (lib ES2020 без `Object.hasOwn`); `EMAIL_EXISTS` показывается под полем email; страница показывает первые 200 пользователей с пометкой (поиска/пагинации нет) | D-9 минимализм; безопасность и устойчивость экрана | добавить поиск/пагинацию при росте числа пользователей; расширить lib и заменить `Map` | | 33 | 2026-10-04 | R-22 (SDD фаза 2, пост-Task 15): общий маппинг нарушений уникальности PostgreSQL (SQLSTATE 23505) в коды 409 — реестр `src/common/constraint_conflicts.py` (7 ограничений: crm_id ГК и ЮЛ, inn, алиас, email (точный дубликат), два индекса членства); имя ограничения берётся из `orig.__cause__.constraint_name` (asyncpg) / `.diag` (psycopg), текст ошибки не разбирается; ответ — тот же `{"detail": {"code","message"}}`, что у pre-check, без значений ключей; прочие ошибки целостности — прежняя строка; в лог добавлен `resolved_code`; гонка повторного добавления ЮЛ в ту же группу даёт `MEMBER_OF_OTHER_GROUP` (срабатывает индекс «ЮЛ + вид»), текст нейтральный; без кода остаются `ALIAS_TAKEN`, email в другом регистре, `external_id` | закрывает T6.5, T7.1, T8.2, T12.7: гонка не отличается от последовательного дубля | расширить реестр; убрать — вернуть общий 409 | +| 34 | 2026-10-04 | R-23 (SDD, финальное ревью I-1): правило «администратор не снимает с себя USERS_ADMIN» трактуется как системное — запрещено (409 `LAST_ADMIN`), пока он единственный активный обладатель права; при втором активном администраторе снятие роли у себя, самодеактивация и снятие права с роли разрешены; текст требования спеки users-admin, D-8 и proposal приведены к этому; код и тест `test_second_admin_allows_dropping_own_admin_role` не менялись | сценарии спеки и Review Focus 5 плана описывают именно системную защиту; план сузил буквальный текст требования, расхождение не было записано | реализовать строгий вариант: при `user_id == actor_id` и потере всех источников `USERS_ADMIN` у себя — 409 `LAST_ADMIN` в `set_roles`/`set_role_permissions`/`update_user(is_active=false)` и поправить тест | +| 35 | 2026-10-04 | R-24 (SDD, финальное ревью M-3): смена имени и филиала ГК одним PATCH (и импортом `update`) пишет одно событие `ASSIGNMENT_CHANGE` с обоими полями в before/after — оставлено как есть; выборка журнала по `GROUP_CHANGE` пропустит такие переименования | спека не требует разнесения; полный состав изменения сохранён в одном событии | разнести на два события (филиал → `ASSIGNMENT_CHANGE`, прочее → `GROUP_CHANGE`) — правка `apply_group_update` и тестов | diff --git a/openspec/changes/slice-1-directories/proposal.md b/openspec/changes/slice-1-directories/proposal.md index cc82b7c..907a78b 100644 --- a/openspec/changes/slice-1-directories/proposal.md +++ b/openspec/changes/slice-1-directories/proposal.md @@ -41,7 +41,7 @@ (маркер `fixtures`, `ARM_KA_DOCS`). - **Администрирование** (`/api/v1/admin/…`, право `USERS_ADMIN`): пользователи (список, создание с обязательным email, изменение, деактивация; `USER_CHANGE`), роли (список), назначение ролей пользователю (`USER_ROLE_CHANGE`), матрица - `role_permission` (чтение, замена набора прав роли; `ROLE_PERMISSION_CHANGE`); нельзя снять `USERS_ADMIN` с себя. + `role_permission` (чтение, замена набора прав роли; `ROLE_PERMISSION_CHANGE`); нельзя оставить систему без активного обладателя `USERS_ADMIN` (в т.ч. снять его с себя, будучи последним). - **Минимальный экран администратора** (`web/`): API-клиент с `Authorization: Bearer` (dev-токен по email в режиме разработки), страница `/admin`: таблица пользователей с назначением ролей и форма создания, матрица «роль × право» с чекбоксами. Без справочника ГК/ЮЛ (решение владельца). diff --git a/openspec/changes/slice-1-directories/specs/users-admin/spec.md b/openspec/changes/slice-1-directories/specs/users-admin/spec.md index 5e0212f..42cc394 100644 --- a/openspec/changes/slice-1-directories/specs/users-admin/spec.md +++ b/openspec/changes/slice-1-directories/specs/users-admin/spec.md @@ -25,8 +25,10 @@ ### Requirement: Назначение ролей `PUT /api/v1/admin/users/{id}/roles` (тело: список кодов) SHALL заменять набор ролей пользователя: отсутствующие в наборе назначения удаляются, новые добавляются; неизвестный или неактивный код роли → 422; пишется `audit_event(USER_ROLE_CHANGE, -before_value = прежний набор, after_value = новый)`. Администратор MUST NOT снять с себя последнюю роль, дающую `USERS_ADMIN` -(409 `LAST_ADMIN`). +before_value = прежний набор, after_value = новый)`. Замена набора ролей MUST NOT оставить систему без активного пользователя с +`USERS_ADMIN` (409 `LAST_ADMIN`): администратор не может снять с себя последнюю роль, дающую `USERS_ADMIN`, если он — +единственный активный обладатель этого права; при наличии другого активного администратора снятие у себя разрешено +(решение №34). #### Scenario: Замена набора ролей - **WHEN** у пользователя роли `[KA_RF]`, администратор ставит `[KA_GO, EXPERT]`