Files
ipam_control/docs/changes/033-review-fixes-032/SUMMARY.md
T

77 lines
11 KiB
Markdown
Raw Normal View 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 | Критическая | FK `audit_log.organization_id → organizations.id` получил `ondelete="SET NULL"` (модель + новая миграция) | `app/models.py`, `alembic/versions/0013_audit_log_org_fk_set_null.py` |
| 2 | Высокая | `UserIn`: кросс-полевая проверка «роль — организация» переведена с `@field_validator` на `@model_validator(mode="after")` (срабатывает и когда поле не передано); из `UserUpdate` валидатор убран — без текущего состояния записи инвариант не проверить | `app/schemas.py` |
| 3 | Высокая | `update_user`: реконсиляция «роль — организация» по итоговому состоянию до `apply_update` — повышение до `superadmin` само очищает `organization_id`, понижение без `organization_id` → 422; `userDialog`: отправка `organization_id` только при роли ≠ `superadmin` (одинаково для create/edit) | `app/api/v1/users.py`, `web/app.js` |
| 4 | Средняя | `refuse_delete` получил параметр `organization_id`, проброшен в `audit()`; проставлен в `delete_org`, `delete_vrf`, `delete_prefix` (там, где организация объекта известна) | `app/services.py`, `app/api/v1/refs.py`, `app/api/v1/prefixes.py` |
| 5 | Низкая | `screens.devices`: безусловный `btn("Добавить устройство", …)` вместо мёртвого ветвления с неподдерживаемым `disabled` | `web/app.js` |
| 6 | Низкая | Добавлен модификатор `.badge.purple` для бейджа роли `superadmin` | `web/styles.css` |
| 7 | Инфо | Локальные `from app.models import Role` в `require_org`/`scope_org` убраны — используется верхнеуровневый импорт | `app/services.py` |
| 8 | Низкая | Поле «Организация» в `userDialog` реагирует на живой выбор роли в открытом диалоге (`hidden` переключается в обработчике `dd-pick` для `name="role"`), а не на исходную роль записи | `web/app.js` |
| 9 | Высокая | `preview_subnet` получил `user: User = Depends(current_user)` и `require_org(user, p.organization_id, "Префикс")` до `_find_subnet` | `app/api/v1/prefixes.py` |
| 10 | Средняя | `create_user`: проверка `organization_id` через `get_or_404(Organization)` до `db.add`; текст «Пользователь с таким логином уже существует» на `flush`/`commit` убран (логин уже проверен явным запросом), гонку покрывает общий `CONFLICT_MSG`; `update_user` — проверка организации по № 3 | `app/api/v1/users.py` |
| 11 | Низкая | Устранены оракулы «422 для чужого объекта / 404 для несуществующего»: `create_prefix` — `require_org` по организации тела **до** `_lock_vrf`, затем по организации VRF **до** проверки 422, затем по организации родительского префикса; `_check_device` — `require_org` до 422; `update_isp` — `require_org` по телу **до** `get_or_404(Organization)`; `update_prefix`/`_move_to_vrf` — `require_org` по целевому VRF до 422. Для `superadmin` прежние 422 сохранены | `app/api/v1/prefixes.py`, `app/api/v1/refs.py` |
| 12 | Низкая | `_type_outs`: подсчёт устройств через `scope_org(..., Device.organization_id, user)`; `list_types` получил `user: User = Depends(current_user)` | `app/api/v1/refs.py` |
| 13 | Низкая | `CheckConstraint` в модели получил `name="ck_users_role_org_scope"` — совпадает с именем в миграции 0011; отдельная миграция не нужна | `app/models.py` |
| 14 | Инфо | Решено: вариант A (см. ниже) | `README.md` |
| 15 | Инфо | README актуализирован: «Пользователи» только `superadmin`, формулировка «последний активный superadmin защищён…», раздел о связке «роль — организация», строка изменения 033 в истории | `README.md` |
### Попутно (выявлено на проверке этапа 2)
Согласование рода в текстах 404: `get_or_404`/`require_org` формировали «{what} не найден» для любого `what`, из-за
чего получалось «Организация не найден» (женский род). Добавлена функция `not_found(what)` в `app/services.py` со
словарями исключений по первому слову (`_FEMININE_FIRST_WORDS = {"Организация", "Запись"}`, `_NEUTER_FIRST_WORDS = {"Устройство"}`),
используется в `get_or_404` и `require_org`.
### Дополнительно (выявлено на ревью этапа 3)
При проверке № 11 обнаружен ещё один не закрытый оракул: `create_prefix` не проверял организацию **родительского**
префикса (`parent_id`) — чужой `parent_id` давал 422 вместо 404. Закрыто той же схемой: `require_org(user, parent.organization_id, "Родительский префикс")`
сразу после `get_or_404(db, Prefix, parent_id, ...)`, до проверки принадлежности VRF/подсети. Файл: `app/api/v1/prefixes.py`.
## № 14. Метаданные суперадминистратора в журнале организации — решено: вариант A
Код не меняется. В README (раздел «Журнал аудита») явно описано: события организации показывают IP-адрес и клиент
(User-Agent) исполнителя, включая случаи, когда действие выполнил `superadmin`. Отклонённый вариант B (скрывать
`client_ip`/`meta` чужих исполнителей от админа/просмотрщика организации) потребовал бы также ограничивать фильтры
`client_ip`/`q` в `/audit` — иначе IP восстанавливается подбором через фильтр — и признан избыточным для этого цикла.
## Миграция
`alembic/versions/0013_audit_log_org_fk_set_null.py` — пересоздаёт `fk_audit_log_organization_id_organizations` с
`ondelete="SET NULL"` (0012 уже применена на стенде, её саму не правим). `downgrade` возвращает FK без `ondelete`.
## Тесты
- `tests/test_api.py::test_in_use_objects_cannot_be_deleted` — дополнен проверкой, что отказ в удалении VRF
(`vrf.delete_blocked`) виден в `/audit` от имени `admin` организации, у которой VRF отклонён (закрывает № 4).
- `tests/test_api.py::test_prefix_isolation_between_organizations` — новый тест: `admin` организации B получает 404
на `GET /prefixes/{id_A}`, `GET /prefixes/{id_A}/subnets/next` (№ 9), `POST /prefixes` с чужим VRF и с чужим `parent_id` (№ 11).
- `tests/test_users.py::test_users_management` — создание `viewer` теперь передаёт `organization_id` (фикстура `org`);
добавлены проверки: `role="admin"` без `organization_id` → 422 (№ 2), `PATCH {"role": "superadmin"}` → 200 и
`organization_id = null`, обратное понижение без `organization_id` → 422, с `organization_id` → 200 (№ 3).
- `tests/test_journal.py::test_clear_requires_password_and_locks_out` — прямая SQL-вставка пользователя теперь с ролью
`'superadmin'` (очистка журнала доступна только ему после 032/033).
## Проверки
- `pytest` — 15 passed.
- `alembic check` — чисто (расхождений нет), включая миграцию 0013.
- `downgrade` 0013 → 0012 и обратный `upgrade` — выполнены без ошибок.
- Сценарии на стенде:
- удаление организации — 204, записи журнала этой организации сохраняются с `organization_id = NULL` (№ 1);
- предпросмотр чужой подсети (`GET /prefixes/{id}/subnets/next`) от имени `admin`/`viewer` чужой организации — 404 (№ 9);
- повышение `admin` → `superadmin` и понижение обратно — 200/422 по сценарию № 3;
- `PATCH`/`POST` с несуществующей организацией — 404 (№ 10);
- чужие VRF, устройство, `parent_id`, организация оператора — 404 для `admin` своей организации, прежние 422 сохранены
для `superadmin` (№ 11);
- счётчики типов устройств (`devices_count`) — в пределах организации вызывающего (№ 12);
- `*.delete_blocked` виден `admin` организации в его собственном журнале (№ 4);
- тексты 404 согласованы по роду («Организация не найдена», «Устройство не найдено» и т. д.) — попутная правка;
- накопленные на стенде 18 организаций `test-*` удалены через API (стало возможно после № 1).
## Не проверено
- Живое переключение поля «Организация» в диалоге пользователя при смене роли в открытом select (№ 8) — проверено
чтением кода, не проверено в браузере.
- Цвет бейджа `superadmin` (класс `.badge.purple`, № 6) — проверено чтением CSS, не проверено визуально в браузере.