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