Пентест (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>
105 lines
15 KiB
Markdown
105 lines
15 KiB
Markdown
# Ролевая модель с привязкой к организации и суперадминистратором (изменение 032)
|
||
|
||
## Context
|
||
Сейчас роль пользователя (`admin`/`viewer`) действует на все организации сразу — любой администратор видит и меняет данные любой организации,
|
||
включая журнал аудита. Нужна привязка учётной записи к одной организации: `admin`/`viewer` работают только в её пределах, включая чтение
|
||
(реестры, «Обзор», журнал). Добавляется роль `superadmin` — без привязки к организации, видит и меняет всё, и единственный, кто создаёт/меняет
|
||
пользователей (включая назначение админов организациям). Уровней прав внутри организации по-прежнему два: чтение или запись.
|
||
|
||
## Решения (согласованы с пользователем)
|
||
1. **Управление пользователями — только `superadmin`.** Администратор организации не создаёт и не редактирует других пользователей (даже `viewer` своей организации).
|
||
2. **Типы устройств (`device_types`) остаются общим справочником.** Читают все роли из любой организации; создание/переименование/удаление — только `superadmin`.
|
||
3. **Журнал аудита изолирован по организации.** Админ/viewer организации видит в журнале только события своей организации. Системные события без
|
||
привязки к организации (вход/выход, настройки/очистка журнала, ротация, управление пользователями) видит только `superadmin`.
|
||
4. **Миграция существующих пользователей:** все нынешние пользователи с ролью `admin` становятся `superadmin` (`organization_id = NULL`).
|
||
Пользователи с ролью `viewer` остаются `viewer`, `organization_id = NULL`, `is_active = false` (учётная запись заблокирована), пока
|
||
`superadmin` не назначит им организацию явным `PATCH`.
|
||
|
||
## Модель данных
|
||
- `Role`: добавить `superadmin`. Enum в PostgreSQL расширяется отдельной миграцией (`ALTER TYPE ... ADD VALUE` вне транзакции — `autocommit_block()`),
|
||
использовать новое значение можно только в следующей миграции.
|
||
- `users.organization_id` (nullable, `FK organizations.id`, индекс). Инвариант: `superadmin` ⇒ `organization_id IS NULL`; `admin`/`viewer` ⇒ `organization_id` задан.
|
||
На уровне БД — `CheckConstraint`, ослабленный для неактивных записей (`is_active = false OR (role = 'superadmin') = (organization_id IS NULL)`),
|
||
чтобы допустить временное состояние «viewer без организации, вход заблокирован» после миграции. На уровне API (`UserIn`/`UserUpdate`) инвариант строгий
|
||
и без исключений — заблокированное состояние возникает только из миграции, не создаётся через API.
|
||
- `audit_log.organization_id` (nullable, индекс). Заполняется у новых записей, если у сущности события есть организация (`organization`, `vrf`, `prefix`,
|
||
`address`, `device`, `isp`); у `user`/`journal`/`session`/`device_type` — `NULL` (видно только `superadmin`). Для существующих записей — точечный backfill
|
||
join'ом по `entity_id` там, где сущность ещё существует (`organization`, `vrf`, `device`, `isp`, `prefix`); что не сматчилось (сущность уже удалена) — остаётся
|
||
`NULL`, то есть уходит в «видно только superadmin». Это осознанный компромисс, не полная реконструкция истории.
|
||
- Удаление организации (`delete_org`) получает новую группу блокираторов — `users`: пока у организации есть привязанные пользователи (активные или нет),
|
||
удалить её нельзя (иначе `organization_id` осиротеет или нарушится FK).
|
||
|
||
Миграции: `0010_user_role_superadmin.py` (значение enum), `0011_users_organization_scope.py` (колонка, ограничение, backfill ролей по решению 4),
|
||
`0012_audit_log_organization.py` (колонка, индекс, backfill).
|
||
|
||
## Права доступа (сводка)
|
||
| Действие | Кто |
|
||
|---|---|
|
||
| Пользователи: список/создание/правка/удаление | только `superadmin` |
|
||
| Организации: создание/удаление | только `superadmin` |
|
||
| Организации: правка карточки | `superadmin` или `admin` **своей** организации |
|
||
| VRF, префиксы, адреса, устройства, операторы: чтение/запись | `admin`/`viewer` — только своя организация; `superadmin` — любая |
|
||
| Типы устройств: чтение | любая роль, любая организация |
|
||
| Типы устройств: создание/правка/удаление | только `superadmin` |
|
||
| Настройки/очистка журнала (`/journal/settings`, `/journal/clear`) | только `superadmin` (затрагивает весь журнал) |
|
||
| Журнал: чтение (`/audit*`) | `admin`/`viewer` — только события своей организации; `superadmin` — все, включая системные |
|
||
| «Обзор» | `admin`/`viewer` — сводка по своей организации; `superadmin` — по всем |
|
||
|
||
## Реализация
|
||
**`app/models.py`** — `Role.superadmin`; `User.organization_id` + `CheckConstraint`; `AuditLog.organization_id`.
|
||
|
||
**`app/security.py`** — `admin_user` меняет смысл: пропускает `admin` и `superadmin` (роль ≠ `viewer`), название и место в коде не меняются.
|
||
Новая зависимость `superadmin_user` (строго `Role.superadmin`) — по образцу `admin_user`.
|
||
|
||
**`app/services.py`** — переиспользуемые хелперы рядом с `get_or_404`/`refuse_delete`:
|
||
- `require_org(user, organization_id, what)` — для операций с известным целевым id: `superadmin` пропускает, иначе при несовпадении `HTTPException(404, f"{what} не найден")`
|
||
(404, а не 403 — не подтверждать существование чужих данных, тот же стиль сообщений, что у `get_or_404`).
|
||
- `scope_org(stmt, column, user)` — для списков: у `superadmin` не трогает `stmt`, иначе `stmt.where(column == user.organization_id)` (безусловно, любой переданный
|
||
клиентом `organization_id` в query-параметрах просто пересекается с этим условием — отдельной ошибки не нужно).
|
||
- `audit()` получает необязательный `organization_id` (по умолчанию `None`) — простановка на стороне вызывающего кода, не выводится автоматически.
|
||
- `_GROUPS`/`blockers` — добавить `"users": "пользователи"` для группы блокираторов удаления организации.
|
||
|
||
**`app/api/v1/users.py`** — все маршруты на `superadmin_user`. `UserIn`/`UserUpdate` (`app/schemas.py`) получают `organization_id: int | None`
|
||
с валидацией «обязателен для admin/viewer, запрещён для superadmin» (`model_validator`), в `UserOut` — тоже поле. Инвариант «нельзя обезвредить последнего
|
||
активного администратора» переводится на `superadmin`: защищается последний активный `superadmin`, не «последний admin организации» (в организации
|
||
администраторов назначает и переназначает `superadmin` по своему усмотрению — не защищается отдельно, это осознанно, см. «Вне объёма»).
|
||
|
||
**`app/api/v1/refs.py`** — организации: `create_org`/`delete_org` → `superadmin_user`; `update_org` → `admin_user` + `require_org`; `list_orgs`/`get_org` →
|
||
`scope_org`/`require_org`. VRF, устройства, операторы: та же пара `admin_user` + `require_org`/`scope_org` по образцу друг друга (один раз описать паттерн,
|
||
применить в `create_vrf`/`update_vrf`/`delete_vrf`/`list_vrfs`, `create_device`/`update_device`/`list_devices`/`get_device`, аналогично `isps`).
|
||
Типы устройств: чтение без изменений (уже открыто всем), запись → `superadmin_user`.
|
||
|
||
**`app/api/v1/prefixes.py`** — префиксы и адреса: `require_org`/`scope_org` там, где сейчас читается/проверяется `organization_id` (создание — из `body`,
|
||
остальные операции — через уже загруженный префикс/родителя). `list_prefixes`/`list_addresses`(через принадлежащий префикс) — `scope_org`.
|
||
|
||
**`app/api/v1/journal.py`** — `list_audit`, `/audit/summary`, `/audit/facets`, `GET /audit/{uid}` — `scope_org` (для `{uid}` — `require_org` с 404 при чужой
|
||
или системной записи). `/journal/settings`, `/journal/clear` → `superadmin_user`.
|
||
|
||
**`app/api/v1/overview.py`** — `_ipv4_roots` и подсчёт назначенных/зарезервированных адресов, `recent_changes` — фильтр по `organization_id` для не-`superadmin`
|
||
(системные события без организации в `recent_changes` не подмешиваются).
|
||
|
||
**`app/main.py`** — `seed()`: пользователь-бутстрап из `.env` создаётся с `role=Role.superadmin`, `organization_id=None`.
|
||
|
||
**`web/app.js`** — `ROLE_RU` + `"Суперадминистратор"`; `isAdmin()` = роль ≠ `viewer` (как и на сервере), новая `isSuperadmin()`. Пункт навигации
|
||
«Пользователи» — виден только `isSuperadmin()`. `orgSwitcher()` — интерактивный (с выпадающим списком) только для `isSuperadmin()`; для `admin`/`viewer`
|
||
показывает название их единственной организации без переключателя (`GET /organizations` для них и так вернёт один элемент). Экран «Организации»:
|
||
кнопка «Добавить организацию» и пункт «Удалить» в меню строки — только `isSuperadmin()`; редактирование доступно как обычная запись. Экран «Пользователи»:
|
||
диалог создания/правки получает выбор организации (скрывается при роли «Суперадминистратор», обязателен для «Администратор»/«Просмотр»). Диалог типов
|
||
устройств: кнопки добавления/переименования/удаления — только `isSuperadmin()`, список остаётся видимым всем.
|
||
|
||
**`scripts/seed_demo.py`** — создание демо-пользователей проставляет `organization_id` для ролей `admin`/`viewer` (иначе скрипт сломает новая валидация схемы).
|
||
|
||
**`README.md`** — разделы «Безопасность» (роли, права по организациям, `superadmin`) и «Модель данных» (новые поля/ограничения).
|
||
|
||
## Вне объёма (сознательно)
|
||
- Защита «последнего администратора организации» — не вводится; единственный защищаемый инвариант — последний активный `superadmin`.
|
||
- Полная историческая реконструкция `organization_id` в старых записях журнала для уже удалённых сущностей — остаются `NULL` (только `superadmin`).
|
||
- Типы устройств не разбиваются по организациям (решение 2).
|
||
- Автотесты и правка существующих (`tests/test_users.py` и др., которые создают пользователей без `organization_id`) — на этапе тестирования отдельно, не сейчас.
|
||
|
||
## Проверка (на этапе тестирования, не при реализации)
|
||
- Импорт приложения, синтаксис UI, миграции 0010–0012 применяются, `alembic check` без расхождений.
|
||
- Сценарии: `superadmin` создаёт организацию и в ней `admin`; этот `admin` видит/меняет только свою организацию (префиксы, устройства, журнал, «Обзор»),
|
||
запросы к чужой организации получают 404; `viewer` организации только читает; `superadmin` управляет типами устройств, `admin` организации их только видит;
|
||
удаление организации с привязанными пользователями отклоняется с перечнем; после миграции унаследованный `admin` из `.env` работает как `superadmin`.
|