# Исправление находок ревью изменения 032 (изменение 033) Источники: `docs/reviews/2026-09-27-changes-032-review.md` (находки № 1–8) и `docs/reviews/2026-09-27-codebase-review.md` (№ 9–15). Изменение 032 в репозиторий не закоммичено. 033 доводит его до готовности, и коммитятся они вместе. ## Порядок | Этап | Находки | Суть | |---|---|---| | 1 | 1, 9 | Удаление организаций, утечка данных через предпросмотр подсети | | 2 | 2, 3, 10, 8 | Проверка связки «роль — организация» по итоговому состоянию (API + диалог UI) | | 3 | 4, 11, 12, 13 | Изоляция журнала и ответов, счётчики типов, имя CHECK | | 4 | 5, 6, 7, 14, 15 | UI, стиль, документация | | 5 | — | Тесты, очистка стенда, SUMMARY | --- ## Этап 1 ### № 1. FK `audit_log.organization_id` → `ON DELETE SET NULL` (критическая) - `app/models.py`: `AuditLog.organization_id` → `ForeignKey("organizations.id", ondelete="SET NULL")`. - Новая миграция `alembic/versions/0013_audit_log_org_fk_set_null.py`. 0012 уже применена на стенде, поэтому её не правим. Миграция удаляет `fk_audit_log_organization_id_organizations` и создаёт её заново с `ondelete="SET NULL"`. `downgrade` — обратная операция. - `users.organization_id` не трогаем: FK с `RESTRICT` там уместен, удаление закрывает блокиратор `users` в `delete_org`. - Проверка: создать организацию → сразу `DELETE` → 204; записи журнала этой организации остаются с `organization_id = NULL`. ### № 9. `GET /prefixes/{id}/subnets/next` без проверки организации (высокая) - `app/api/v1/prefixes.py::preview_subnet`: добавить `user: User = Depends(current_user)`, прочитать префикс и вызвать `require_org(user, p.organization_id, "Префикс")` до `_find_subnet`. - Проверка: viewer организации B на префикс организации A → 404. ## Этап 2 — связка «роль — организация» пользователя Общий принцип: строгий инвариант API (`superadmin` ⇔ `organization_id IS NULL`) проверяется **по итоговому состоянию записи**, а не по наличию полей в теле запроса. Нарушение → 422 с понятным текстом, а не 409 от БД. ### № 2. Валидация `UserIn` - `app/schemas.py`: `@field_validator` в `UserIn` заменить на `@model_validator(mode="after")` с той же логикой и теми же текстами. - Из `UserUpdate` валидатор убрать: без текущего состояния записи он не может проверить инвариант (см. № 3). ### № 3. Смена роли в `update_user` Файл `app/api/v1/users.py`. Итоговое состояние вычисляется до `apply_update`: - `role_f = data.get("role", u.role)`, `active_f = data.get("is_active", u.is_active)`; - если `role_f == superadmin`: - `organization_id` явно передан → 422 «Суперадминистратор не привязан к организации»; - иначе принудительно `data["organization_id"] = None` (повышение без ручной очистки организации); - иначе `org_f = data.get("organization_id", u.organization_id)`. Если `org_f is None` и (`active_f` или в запросе есть `role`/`organization_id`) → 422 «Администратор и просмотрщик должны быть привязаны к организации». Это касается и понижения суперадминистратора без `organization_id`. Не затронутые запросом записи в унаследованном состоянии (например, неактивный viewer после миграции 0011) при смене пароля не ломаются. - Если `organization_id` передан — `get_or_404(db, Organization, ...)` (см. № 10). - Изменение `organization_id` попадает в `changed` и в журнал через существующий `apply_update`. ### № 10. Ошибки целостности подписаны как «логин уже существует» - `create_user`: до `db.add` — `get_or_404(db, Organization, body.organization_id, "Организация")`, если `organization_id` задан. В `flush`/`commit` убрать текст «Пользователь с таким логином уже существует» и оставить `CONFLICT_MSG`: занятость логина уже проверяется явным запросом выше, а редкую гонку двух одинаковых логинов покрывает общий текст. - `update_user`: проверка организации — в № 3. - Групповое «Разрешить доступ» для неактивного viewer без организации получит 422 с текстом из № 3. Отдельной правки не требует: `bulk()` уже показывает ошибки по строкам. ### № 8 (+ UI-часть № 3). Диалог пользователя (`web/app.js::userDialog`) - Поле «Организация» выводится всегда (кроме своей записи) и скрывается, если в селекторе роли **текущее** значение — `superadmin`. Переключение роли в открытом диалоге сразу показывает или прячет поле: в обработчике `dd-pick` для `name="role"` переключать `hidden` у обёртки поля, по образцу существующих зависимых полей, если они есть. Условие `S.dialog && u?.role === "superadmin"` убрать. - Отправка, одинаковая для создания и правки: `organization_id` добавляется в тело, только если `v.role !== "superadmin"` и значение выбрано. При смене роли на `superadmin` в PATCH поле не передаётся — сервер очистит его сам (№ 3). ## Этап 3 ### № 4. `*.delete_blocked` в журнале организации - `app/services.py::refuse_delete`: параметр `organization_id: int | None = None`, пробросить в `audit(...)`. - Вызовы: `delete_org` → `id`; `delete_vrf` → `v.organization_id`; `delete_prefix` → `p.organization_id`; `delete_type`, `delete_user` → без организации (системные события). ### № 11. Различимые ответы для объектов чужой организации Для не-`superadmin` чужой объект должен давать тот же 404, что и несуществующий. `superadmin` сохраняет прежние 422. - `create_prefix`: `require_org(user, body.organization_id, ...)` → **затем** `_lock_vrf`, чтобы не блокировать чужой VRF. После чтения VRF — `require_org(user, vrf.organization_id, "VRF")`, и только потом проверка 422 «VRF принадлежит другой организации». - `_check_device(db, user, prefix, device_id)`: после `get_or_404` — `require_org(user, d.organization_id, "Устройство")`, затем прежняя проверка 422. Обновить три вызова. - `update_isp`: `require_org(user, body.organization_id, "Организация")` **до** `get_or_404(Organization)`, с тем же текстом. - `update_prefix` с переносом VRF (`_move_to_vrf`): для не-`superadmin` чужой целевой VRF → 404 тем же приёмом. Сейчас это 422. ### № 12. Счётчики типов устройств - `app/api/v1/refs.py::_type_outs(db, rows, user)`: подсчёт устройств через `scope_org(..., Device.organization_id, user)`. `list_types` получает `user: User = Depends(current_user)`. Для `create/update_type` (только `superadmin`) поведение не меняется. ### № 13. Имя CHECK-ограничения - `app/models.py`: `CheckConstraint(..., name="ck_users_role_org_scope")`, как в миграции 0011. Миграция не нужна. ## Этап 4 ### № 5. Кнопка «Добавить устройство» - `screens.devices`: вернуть безусловный `btn("Добавить устройство", ...)` — устройства создаёт любой `admin` своей организации. ### № 6. Бейдж `superadmin` - `web/styles.css`: `.badge.purple{background:#efe8fb;color:#5b2a9e}` рядом с остальными модификаторами. ### № 7. Локальный импорт `Role` - `app/services.py`: `Role` — в верхнеуровневый `from app.models import ...`, локальные импорты в `require_org`/`scope_org` убрать. ### № 14. IP и User-Agent суперадминистратора в журнале организации — решено: вариант A - Код не меняется. В README («Журнал») описать, что события организации показывают IP и клиент исполнителя, включая суперадминистратора. - Отклонённый вариант B — скрывать `client_ip`/`meta` чужих исполнителей. Потребовал бы ограничить и фильтры `client_ip`/`q`, иначе IP восстанавливается подбором через фильтр. ### Попутно (выявлено на проверке этапа 2) - `get_or_404`/`require_org` формируют «{what} не найден» — для женского рода получается «Организация не найден». Нужно согласовать род, например через `_NOUNS` из `app/services.py` или явный текст в вызове. ### № 15. README - Раздел «UI»: «Пользователи» (только `superadmin`). - Роли: «последний активный `superadmin` защищён от понижения, отключения и удаления» — вместо текущей обратной формулировки. - Раздел про связку «роль — организация»: смена роли на `superadmin` сама снимает организацию, понижение требует `organization_id`. - Строка об изменении 033 в истории изменений, в формате README. ## Этап 5 — тесты и стенд Исполнитель правит код этапов 1–4 и не запускает тесты. Тесты обновляются после повторного ревью, минимально: - `tests/test_users.py`: при создании `viewer` передать `organization_id` (через фикстуру `org`); добавить проверки в тот же сквозной сценарий: `role="admin"` без организации → 422; `PATCH {"role": "superadmin"}` → 200 и `organization_id = null`; обратно без организации → 422. - `tests/test_journal.py:43`: прямая SQL-вставка — роль `'superadmin'` (очистка журнала доступна только ему). - Один новый тест изоляции (`tests/test_api.py`): viewer организации B → 404 на `GET /prefixes/{id_A}` и `GET /prefixes/{id_A}/subnets/next`. - `test_delete_organization` проходит без изменений после № 1. - Стенд: `alembic upgrade head`, затем удалить накопленные организации `test-*` (18 шт.) через API — после № 1 удаление работает. ## Файлы `app/models.py`, `app/schemas.py`, `app/services.py`, `app/api/v1/users.py`, `app/api/v1/prefixes.py`, `app/api/v1/refs.py`, `alembic/versions/0013_audit_log_org_fk_set_null.py` (новый), `web/app.js`, `web/styles.css`, `README.md`, `tests/*` (этап 5), `docs/changes/033-review-fixes-032/SUMMARY.md` (по завершении). ## Проверка готовности - `alembic upgrade head` и `alembic check` — чисто; `import app.main`, `node --check web/app.js` — без ошибок. - `pytest -q` — все тесты зелёные. - Сценарии на стенде: удаление новой организации; предпросмотр чужой подсети → 404; повышение `admin` → `superadmin` и обратно через UI; `vrf.delete_blocked` виден админу организации в журнале.