Files
ipam_control/docs/changes/033-review-fixes-032/PLAN.md
T
ayurishchevandClaude Opus 5.5 744a025960 Задачи 032-033: ролевая модель с привязкой к организации, исправления по ревью
Пентест (docs/reviews/2026-09-27-pentest.md) и план 031 (Swagger, TLS) — план, не реализован.
032 Роль superadmin (без организации) и привязка admin/viewer к одной организации:
    users.organization_id + CHECK, audit_log.organization_id (миграции 0010-0012);
    require_org/scope_org во всех чтениях и записях, журнал и «Обзор» в границах
    организации; пользователи, организации, типы устройств, настройки журнала — только superadmin.
033 Исправление находок ревью 032 (docs/reviews/2026-09-27-changes-032-review.md,
    docs/reviews/2026-09-27-codebase-review.md):
    - FK audit_log.organization_id ON DELETE SET NULL (миграция 0013) — удаление организаций;
    - проверка организации в предпросмотре подсети;
    - инвариант «роль — организация» по итоговому состоянию (повышение снимает организацию,
      понижение требует её), 422/404 вместо обезличенных 409;
    - одинаковый 404 для чужих и несуществующих объектов (VRF, устройство, parent_id, оператор);
    - отказы удаления в журнале организации, счётчики типов в пределах организации;
    - UI: живое поле «Организация» в диалоге пользователя, бейдж superadmin; род в текстах 404.
README актуализирован под ролевую модель.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-27 11:26:57 +03:00

13 KiB
Raw Blame History

Исправление находок ревью изменения 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 виден админу организации в журнале.