Пентест (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>
15 KiB
Ролевая модель с привязкой к организации и суперадминистратором (изменение 032)
Context
Сейчас роль пользователя (admin/viewer) действует на все организации сразу — любой администратор видит и меняет данные любой организации,
включая журнал аудита. Нужна привязка учётной записи к одной организации: admin/viewer работают только в её пределах, включая чтение
(реестры, «Обзор», журнал). Добавляется роль superadmin — без привязки к организации, видит и меняет всё, и единственный, кто создаёт/меняет
пользователей (включая назначение админов организациям). Уровней прав внутри организации по-прежнему два: чтение или запись.
Решения (согласованы с пользователем)
- Управление пользователями — только
superadmin. Администратор организации не создаёт и не редактирует других пользователей (дажеviewerсвоей организации). - Типы устройств (
device_types) остаются общим справочником. Читают все роли из любой организации; создание/переименование/удаление — толькоsuperadmin. - Журнал аудита изолирован по организации. Админ/viewer организации видит в журнале только события своей организации. Системные события без
привязки к организации (вход/выход, настройки/очистка журнала, ротация, управление пользователями) видит только
superadmin. - Миграция существующих пользователей: все нынешние пользователи с ролью
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.