Files

104 lines
15 KiB
Markdown
Raw Permalink Normal View History

# Ролевая модель с привязкой к организации и суперадминистратором (изменение 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`.