Files
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

135 lines
13 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Исправление находок ревью изменения 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` виден админу организации в журнале.