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