Задачи 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>
This commit is contained in:
1 parent
5210ba3333
commit
744a025960
28 files changed
+1283
-164
No files matched your search
@@ -11,14 +11,14 @@ python3 -m venv venv && venv/bin/pip install -r requirements-dev.txt
|
||||
venv/bin/python scripts/seed_demo.py # по желанию: демо-данные из макетов
|
||||
```
|
||||
- UI: `http://<хост>:8088/`, Swagger: `http://<хост>:8088/docs`.
|
||||
- Вход: `ADMIN_USERNAME` / `ADMIN_PASSWORD` из `.env`. Администратор создаётся при первом старте на пустой БД.
|
||||
- Вход: `ADMIN_USERNAME` / `ADMIN_PASSWORD` из `.env`. Суперадминистратор создаётся при первом старте на пустой БД.
|
||||
|
||||
## Конфигурация (`.env`)
|
||||
| Переменная | Назначение |
|
||||
|---|---|
|
||||
| `POSTGRES_DB`, `POSTGRES_USER`, `POSTGRES_PASSWORD` | База данных |
|
||||
| `JWT_SECRET` | Ключ подписи токенов. Обязателен, не короче 32 символов, заглушки отклоняются: без корректного значения приложение не стартует |
|
||||
| `ADMIN_USERNAME`, `ADMIN_PASSWORD` | Первый администратор; пароль не короче 8 символов |
|
||||
| `ADMIN_USERNAME`, `ADMIN_PASSWORD` | Первый суперадминистратор; пароль не короче 8 символов |
|
||||
| `APP_PORT` | Порт UI и API на хосте (8088) |
|
||||
| `APP_BIND` | Адрес публикации порта: по умолчанию `0.0.0.0`; `127.0.0.1` — только за reverse-proxy |
|
||||
| `DB_HOST_PORT` | Порт PostgreSQL на хосте, публикуется только на `127.0.0.1` |
|
||||
@@ -27,8 +27,8 @@ venv/bin/python scripts/seed_demo.py # по желанию: демо-да
|
||||
## Архитектура
|
||||
| Слой | Технологии |
|
||||
|---|---|
|
||||
| API | FastAPI, pydantic v2, JWT (срок 8 ч), пароли в argon2, роли `admin` (запись) и `viewer` (чтение) |
|
||||
| БД | PostgreSQL 16, SQLAlchemy 2, Alembic (миграции `0001`–`0009`), типы `CIDR`/`INET` |
|
||||
| API | FastAPI, pydantic v2, JWT (срок 8 ч), пароли в argon2, роли `superadmin`, `admin`, `viewer` с привязкой к организации |
|
||||
| БД | PostgreSQL 16, SQLAlchemy 2, Alembic (миграции `0001`–`0013`), типы `CIDR`/`INET` |
|
||||
| UI | Статический SPA (vanilla JS, ES-модуль) раздаётся приложением; шрифты IBM Plex хранятся локально, внешних зависимостей нет |
|
||||
|
||||
```
|
||||
@@ -44,6 +44,11 @@ docs/changes/ планы и итоги доработок docs/reviews/
|
||||
## Модель данных и правила
|
||||
`organizations` → `vrfs` → `prefixes` (дерево) → `addresses`; `devices` + `device_types`; `isps` + `isp_networks`; `users`; `audit_log`.
|
||||
|
||||
**Пользователи и организации**
|
||||
- `admin`/`viewer` привязаны к одной организации (`organization_id`), `superadmin` — ни к одной. БД допускает `admin`/`viewer` без организации только у отключённой записи (состояние после миграции 0011).
|
||||
- Смена роли на `superadmin` снимает организацию автоматически; понижение до `admin`/`viewer` требует указать `organization_id`.
|
||||
- Изоляция: `admin`/`viewer` видят и меняют только данные своей организации, включая «Обзор», журнал и счётчики типов устройств. Чужой объект неотличим от несуществующего — 404.
|
||||
|
||||
**VRF и префиксы**
|
||||
- У каждой организации автоматически создаётся VRF `default`. Имя VRF уникально в пределах организации без учёта регистра.
|
||||
- Префикс принадлежит VRF своей организации; это гарантирует составной FK в БД.
|
||||
@@ -62,6 +67,13 @@ docs/changes/ планы и итоги доработок docs/reviews/
|
||||
|
||||
**Удаление**
|
||||
- Объекты с зависимыми данными не удаляются (409). Отказ фиксируется в журнале с перечнем мешающих объектов.
|
||||
- Организация не удаляется, если у неё есть привязанные пользователи.
|
||||
|
||||
**Журнал аудита**
|
||||
- Событие привязано к организации своей сущности (`organization_id`); `admin`/`viewer` видят только события своей организации, включая отклонённые удаления.
|
||||
- Системные события (пользователи, вход, настройки и очистка журнала, типы устройств) не привязаны к организации и видны только `superadmin`.
|
||||
- События организации показывают IP и User-Agent исполнителя, в том числе суперадминистратора.
|
||||
- После удаления организации её события сохраняются с `organization_id = NULL`.
|
||||
|
||||
## API (`/api/v1`)
|
||||
| Область | Эндпоинты |
|
||||
@@ -82,9 +94,14 @@ docs/changes/ планы и итоги доработок docs/reviews/
|
||||
|
||||
## Безопасность
|
||||
**Роли и учётные записи**
|
||||
- `viewer` только читает. Раздел «Пользователи» доступен только `admin`. Роль по умолчанию — `viewer`.
|
||||
| Роль | Права |
|
||||
|---|---|
|
||||
| `superadmin` | Все организации; пользователи, создание и удаление организаций, типы устройств, настройки и очистка журнала |
|
||||
| `admin` | Запись в своей организации: VRF, префиксы, адреса, устройства, операторы, карточка организации |
|
||||
| `viewer` | Чтение своей организации. Роль по умолчанию |
|
||||
|
||||
- Логин уникален без учёта регистра.
|
||||
- Свою учётную запись нельзя понизить, отключить или удалить; то же относится к последнему активному администратору.
|
||||
- Свою учётную запись нельзя понизить, отключить или удалить; последний активный `superadmin` защищён от понижения, отключения и удаления.
|
||||
|
||||
**Пароли и токены**
|
||||
- Смена пароля отзывает ранее выданные токены. Свой пароль меняется только с подтверждением текущего.
|
||||
@@ -114,11 +131,13 @@ docs/changes/ планы и итоги доработок docs/reviews/
|
||||
reverse_proxy 127.0.0.1:8088
|
||||
}
|
||||
```
|
||||
- Переход на ролевую модель (миграция 0011): прежние `admin` становятся `superadmin`, прежние `viewer` отключаются до назначения организации суперадминистратором.
|
||||
- Перед обновлением рабочей БД проверьте данные скриптами только для чтения: `scripts/find_duplicate_addresses.py` и `scripts/find_unusable_addresses.py`.
|
||||
- Имя compose-проекта задаётся флагом `-p`. Текущий стенд поднят как `ipam_control_006` (`docker compose -p ipam_control_006 …`); без флага команды работают с проектом `ipam_control`.
|
||||
|
||||
## Интерфейс
|
||||
- Экраны: «Обзор», «Префиксы» (дерево по VRF), «Адреса» подсети, «Организации», «Операторы», «Устройства», «Журнал», «Пользователи» (только admin).
|
||||
- Экраны: «Обзор», «Префиксы» (дерево по VRF), «Адреса» подсети, «Организации», «Операторы», «Устройства», «Журнал», «Пользователи» (только `superadmin`).
|
||||
- Переключатель организации — только у `superadmin`; `admin`/`viewer` работают в своей организации.
|
||||
- Строка реестра кликабельна целиком. Действия над строкой — в меню «⋯».
|
||||
- Групповые операции через чекбоксы (кроме «Журнала»): удаление, смена типа устройств, статус префиксов и адресов, доступ пользователей.
|
||||
- Экран «Префиксы» загружает до 20 000 префиксов организации и сообщает, если загружены не все.
|
||||
@@ -164,6 +183,8 @@ docker compose -p ipam_control_006 up -d --build && venv/bin/python -m pytest -q
|
||||
| 028 | Экран «Префиксы» без усечения | [план](docs/changes/028-prefixes-ui-pagination/PLAN.md) · [итог](docs/changes/028-prefixes-ui-pagination/SUMMARY.md) |
|
||||
| 029 | Целостность дерева префиксов | [план](docs/changes/029-prefix-tree-lock/PLAN.md) · [итог](docs/changes/029-prefix-tree-lock/SUMMARY.md) |
|
||||
| 030 | Исправление замечаний ревью 025–029 | [план](docs/changes/030-review-fixes-025-029/PLAN.md) · [итог](docs/changes/030-review-fixes-025-029/SUMMARY.md) |
|
||||
| 032 | Ролевая модель с привязкой к организации и суперадминистратором | [план](docs/changes/032-role-model-org-scope/PLAN.md) · [итог](docs/changes/032-role-model-org-scope/SUMMARY.md) |
|
||||
| 033 | Исправление находок ревью 032 | [план](docs/changes/033-review-fixes-032/PLAN.md) · [итог](docs/changes/033-review-fixes-032/SUMMARY.md) |
|
||||
|
||||
## Отчёты ревью
|
||||
- [Ревью кодовой базы](docs/reviews/2026-09-26-codebase-review.md) (находки → изменения 011–023)
|
||||
@@ -171,3 +192,6 @@ docker compose -p ipam_control_006 up -d --build && venv/bin/python -m pytest -q
|
||||
- [Повторный анализ кодовой базы](docs/reviews/2026-09-26-codebase-review-2.md) (→ 025–029)
|
||||
- [Ревью и тестирование изменений 025–029](docs/reviews/2026-09-26-changes-025-029-review.md) (→ 030)
|
||||
- [Ревью и тестирование изменения 030](docs/reviews/2026-09-27-changes-030-review.md)
|
||||
- [Пентест (чёрный ящик)](docs/reviews/2026-09-27-pentest.md) (→ план 031, не реализован)
|
||||
- [Ревью и тестирование изменения 032](docs/reviews/2026-09-27-changes-032-review.md) (→ 033)
|
||||
- [Ревью кодовой базы после 032](docs/reviews/2026-09-27-codebase-review.md) (→ 033)
|
||||
@@ -0,0 +1,24 @@
|
||||
"""Добавление роли superadmin для пользователей без привязки к организации (изменение 032)
|
||||
|
||||
Revision ID: 0010
|
||||
Revises: 0009
|
||||
"""
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
revision = "0010"
|
||||
down_revision = "0009"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
# ALTER TYPE ... ADD VALUE должен быть вне транзакции; используем autocommit_block
|
||||
with op.get_context().autocommit_block():
|
||||
op.execute(sa.text("ALTER TYPE user_role ADD VALUE IF NOT EXISTS 'superadmin'"))
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
# PostgreSQL не позволяет удалять значения enum; downgrade не осуществляется
|
||||
pass
|
||||
@@ -0,0 +1,46 @@
|
||||
"""Добавление привязки пользователей к организациям: admin/viewer привязаны, superadmin нет (изменение 032)
|
||||
|
||||
Revision ID: 0011
|
||||
Revises: 0010
|
||||
"""
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
from sqlalchemy.dialects import postgresql
|
||||
|
||||
|
||||
revision = "0011"
|
||||
down_revision = "0010"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
# Добавляем колонку organization_id
|
||||
op.add_column("users", sa.Column("organization_id", sa.Integer(), nullable=True))
|
||||
op.create_index(op.f("ix_users_organization_id"), "users", ["organization_id"], unique=False)
|
||||
op.create_foreign_key(op.f("fk_users_organization_id_organizations"), "users", "organizations", ["organization_id"], ["id"])
|
||||
|
||||
# Backfill: согласно плану (изменение 032, решение 4)
|
||||
# 1. Пользователи с ролью 'admin' (текущие администраторы) → superadmin, organization_id=NULL
|
||||
# 2. Пользователи с ролью 'viewer' → organization_id=NULL, is_active=false (заблокированы)
|
||||
op.execute(sa.text("UPDATE users SET role = 'superadmin', organization_id = NULL WHERE role = 'admin'"))
|
||||
op.execute(sa.text("UPDATE users SET organization_id = NULL, is_active = false WHERE role = 'viewer'"))
|
||||
|
||||
# Добавляем CHECK constraint ПОСЛЕ backfill (иначе может не пройти)
|
||||
# Инвариант: is_active=false OR (role='superadmin')=(organization_id IS NULL)
|
||||
# Ослабленный для неактивных — допускает временное состояние viewer без организации
|
||||
op.create_check_constraint(
|
||||
"ck_users_role_org_scope",
|
||||
"users",
|
||||
"is_active = false OR (role = 'superadmin') = (organization_id IS NULL)",
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_constraint("ck_users_role_org_scope", "users", type_="check")
|
||||
op.drop_constraint(op.f("fk_users_organization_id_organizations"), "users", type_="foreignkey")
|
||||
op.drop_index(op.f("ix_users_organization_id"), table_name="users")
|
||||
op.drop_column("users", "organization_id")
|
||||
|
||||
# Backfill downgrade: вернуть superadmin → admin
|
||||
op.execute(sa.text("UPDATE users SET role = 'admin' WHERE role = 'superadmin'"))
|
||||
@@ -0,0 +1,64 @@
|
||||
"""Добавление привязки записей журнала к организациям для изоляции по организациям (изменение 032)
|
||||
|
||||
Revision ID: 0012
|
||||
Revises: 0011
|
||||
"""
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
revision = "0012"
|
||||
down_revision = "0011"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
# Добавляем колонку organization_id в audit_log
|
||||
op.add_column("audit_log", sa.Column("organization_id", sa.Integer(), nullable=True))
|
||||
op.create_index(op.f("ix_audit_log_organization_id"), "audit_log", ["organization_id"], unique=False)
|
||||
op.create_foreign_key(op.f("fk_audit_log_organization_id_organizations"), "audit_log", "organizations", ["organization_id"], ["id"])
|
||||
|
||||
# Backfill: привязываем записи к организациям через entity_id
|
||||
# Для сущностей, у которых есть organization_id (organization, vrf, device, isp, prefix),
|
||||
# подтягиваем значение из таблицы сущности; для остальных остаётся NULL (видно только superadmin)
|
||||
|
||||
# Для сущностей, которые уже удалены, organisation_id остаётся NULL
|
||||
# (осознанный компромисс — не полная историческая реконструкция)
|
||||
|
||||
# organization events
|
||||
op.execute(sa.text(
|
||||
"UPDATE audit_log SET organization_id = o.id "
|
||||
"FROM organizations o WHERE audit_log.entity_type = 'organization' AND audit_log.entity_id = o.id"
|
||||
))
|
||||
|
||||
# vrf events
|
||||
op.execute(sa.text(
|
||||
"UPDATE audit_log SET organization_id = v.organization_id "
|
||||
"FROM vrfs v WHERE audit_log.entity_type = 'vrf' AND audit_log.entity_id = v.id"
|
||||
))
|
||||
|
||||
# device events
|
||||
op.execute(sa.text(
|
||||
"UPDATE audit_log SET organization_id = d.organization_id "
|
||||
"FROM devices d WHERE audit_log.entity_type = 'device' AND audit_log.entity_id = d.id"
|
||||
))
|
||||
|
||||
# isp events
|
||||
op.execute(sa.text(
|
||||
"UPDATE audit_log SET organization_id = i.organization_id "
|
||||
"FROM isps i WHERE audit_log.entity_type = 'isp' AND audit_log.entity_id = i.id"
|
||||
))
|
||||
|
||||
# prefix events (через vrf)
|
||||
op.execute(sa.text(
|
||||
"UPDATE audit_log SET organization_id = v.organization_id "
|
||||
"FROM prefixes p JOIN vrfs v ON p.vrf_id = v.id "
|
||||
"WHERE audit_log.entity_type = 'prefix' AND audit_log.entity_id = p.id"
|
||||
))
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_constraint(op.f("fk_audit_log_organization_id_organizations"), "audit_log", type_="foreignkey")
|
||||
op.drop_index(op.f("ix_audit_log_organization_id"), table_name="audit_log")
|
||||
op.drop_column("audit_log", "organization_id")
|
||||
@@ -0,0 +1,29 @@
|
||||
"""FK audit_log.organization_id → ON DELETE SET NULL (изменение 033, находка №1)
|
||||
|
||||
Revision ID: 0013
|
||||
Revises: 0012
|
||||
"""
|
||||
from alembic import op
|
||||
|
||||
|
||||
revision = "0013"
|
||||
down_revision = "0012"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
# Без ondelete="SET NULL" удаление организации падает: строки её журнала (organization_id) блокируют DELETE
|
||||
op.drop_constraint(op.f("fk_audit_log_organization_id_organizations"), "audit_log", type_="foreignkey")
|
||||
op.create_foreign_key(
|
||||
op.f("fk_audit_log_organization_id_organizations"), "audit_log", "organizations",
|
||||
["organization_id"], ["id"], ondelete="SET NULL",
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_constraint(op.f("fk_audit_log_organization_id_organizations"), "audit_log", type_="foreignkey")
|
||||
op.create_foreign_key(
|
||||
op.f("fk_audit_log_organization_id_organizations"), "audit_log", "organizations",
|
||||
["organization_id"], ["id"],
|
||||
)
|
||||
+19
-11
@@ -12,8 +12,8 @@ from app import schemas as s
|
||||
from app.db import get_db
|
||||
from app.models import AuditLog, ClearAttempt, User
|
||||
from app.rotation import get_settings, rotate, save_settings
|
||||
from app.security import admin_user, current_user, verify_password
|
||||
from app.services import MAX_OFFSET, audit, commit, like_escape
|
||||
from app.security import admin_user, current_user, superadmin_user, verify_password
|
||||
from app.services import MAX_OFFSET, audit, commit, like_escape, require_org, scope_org # изменение 032
|
||||
|
||||
router = APIRouter(dependencies=[Depends(current_user)], tags=["journal"])
|
||||
MAX_ATTEMPTS = 5
|
||||
@@ -56,9 +56,10 @@ def _filtered(stmt, event_type: str, entity_type: str, actor: str, date_from: da
|
||||
@router.get("/audit", response_model=s.Page[s.AuditOut])
|
||||
def list_audit(
|
||||
event_type: str = "", entity_type: str = "", actor: str = "", date_from: date | None = None, date_to: date | None = None,
|
||||
q: str = "", client_ip: str = "", limit: int = Query(100, ge=1, le=500), offset: int = Query(0, ge=0, le=MAX_OFFSET), db: Session = Depends(get_db),
|
||||
q: str = "", client_ip: str = "", limit: int = Query(100, ge=1, le=500), offset: int = Query(0, ge=0, le=MAX_OFFSET), db: Session = Depends(get_db), user: User = Depends(current_user), # изменение 032
|
||||
):
|
||||
stmt = _filtered(select(AuditLog), event_type, entity_type, actor, date_from, date_to, q, client_ip)
|
||||
stmt = scope_org(stmt, AuditLog.organization_id, user) # изменение 032: фильтр по организации
|
||||
total = db.scalar(select(func.count()).select_from(stmt.subquery())) or 0
|
||||
rows = db.scalars(stmt.order_by(AuditLog.id.desc()).limit(limit).offset(offset)).all()
|
||||
return s.Page(items=[s.AuditOut.from_row(r) for r in rows], total=total)
|
||||
@@ -72,9 +73,11 @@ class Summary(BaseModel):
|
||||
|
||||
|
||||
@router.get("/audit/summary", response_model=Summary)
|
||||
def summary(db: Session = Depends(get_db)):
|
||||
def summary(db: Session = Depends(get_db), user: User = Depends(current_user)): # изменение 032
|
||||
cfg = get_settings(db)
|
||||
total, oldest = db.execute(select(func.count(), func.min(AuditLog.ts))).one()
|
||||
stmt = select(func.count(), func.min(AuditLog.ts)).select_from(AuditLog)
|
||||
stmt = scope_org(stmt, AuditLog.organization_id, user) # изменение 032: фильтр по организации
|
||||
total, oldest = db.execute(stmt).one()
|
||||
return Summary(total=total, oldest_ts=oldest, **cfg)
|
||||
|
||||
|
||||
@@ -85,9 +88,13 @@ class Facets(BaseModel):
|
||||
|
||||
|
||||
@router.get("/audit/facets", response_model=Facets)
|
||||
def facets(db: Session = Depends(get_db)):
|
||||
pairs = db.execute(select(AuditLog.entity_type, AuditLog.action).distinct().order_by(AuditLog.entity_type, AuditLog.action)).all()
|
||||
users = db.scalars(select(AuditLog.username).distinct().order_by(AuditLog.username)).all()
|
||||
def facets(db: Session = Depends(get_db), user: User = Depends(current_user)): # изменение 032
|
||||
stmt = select(AuditLog.entity_type, AuditLog.action).distinct()
|
||||
stmt = scope_org(stmt, AuditLog.organization_id, user) # изменение 032: фильтр по организации
|
||||
pairs = db.execute(stmt.order_by(AuditLog.entity_type, AuditLog.action)).all()
|
||||
stmt2 = select(AuditLog.username).distinct()
|
||||
stmt2 = scope_org(stmt2, AuditLog.organization_id, user) # изменение 032: фильтр по организации
|
||||
users = db.scalars(stmt2.order_by(AuditLog.username)).all()
|
||||
return Facets(
|
||||
event_types=[f"{e}.{a}" for e, a in pairs], entity_types=sorted({e for e, _ in pairs}),
|
||||
actors=[u if u in ("system", "anonymous") else f"ui:{u}" for u in users],
|
||||
@@ -95,10 +102,11 @@ def facets(db: Session = Depends(get_db)):
|
||||
|
||||
|
||||
@router.get("/audit/{uid}", response_model=s.AuditOut)
|
||||
def get_entry(uid: uuid.UUID, db: Session = Depends(get_db)):
|
||||
def get_entry(uid: uuid.UUID, db: Session = Depends(get_db), user: User = Depends(current_user)): # изменение 032
|
||||
row = db.scalar(select(AuditLog).where(AuditLog.uid == uid))
|
||||
if row is None:
|
||||
raise HTTPException(404, "Запись не найдена")
|
||||
require_org(user, row.organization_id, "Запись журнала") # изменение 032
|
||||
return s.AuditOut.from_row(row)
|
||||
|
||||
|
||||
@@ -118,7 +126,7 @@ class SettingsSaved(JournalSettings):
|
||||
|
||||
|
||||
@router.put("/journal/settings", response_model=SettingsSaved)
|
||||
def update_settings(body: JournalSettings, db: Session = Depends(get_db), user: User = Depends(admin_user)):
|
||||
def update_settings(body: JournalSettings, db: Session = Depends(get_db), user: User = Depends(superadmin_user)): # изменение 032: только superadmin
|
||||
old = get_settings(db)
|
||||
new = body.model_dump()
|
||||
save_settings(db, new)
|
||||
@@ -149,7 +157,7 @@ def _lock_state(db: Session, user_id: int) -> tuple[int, int]:
|
||||
|
||||
|
||||
@router.post("/journal/clear")
|
||||
def clear_journal(body: ClearIn, db: Session = Depends(get_db), user: User = Depends(admin_user)):
|
||||
def clear_journal(body: ClearIn, db: Session = Depends(get_db), user: User = Depends(superadmin_user)): # изменение 032: только superadmin
|
||||
used, retry = _lock_state(db, user.id)
|
||||
if retry:
|
||||
audit(db, user, "journal", None, "clear_locked", "clear", {"retry_after_seconds": retry}, message="Очистка журнала: попытка во время блокировки")
|
||||
|
||||
+16
-7
@@ -6,9 +6,9 @@ from sqlalchemy.orm import Session
|
||||
from app import schemas as s
|
||||
from app.api.v1.prefixes import _prefix_outs
|
||||
from app.db import get_db
|
||||
from app.models import Address, AddressStatus, AuditLog, Prefix, PrefixStatus, Vrf
|
||||
from app.models import Address, AddressStatus, AuditLog, Prefix, PrefixStatus, User, Vrf
|
||||
from app.security import current_user
|
||||
from app.services import capacity, utilization
|
||||
from app.services import capacity, scope_org, utilization # изменение 032
|
||||
|
||||
router = APIRouter(dependencies=[Depends(current_user)])
|
||||
|
||||
@@ -39,14 +39,17 @@ def _ipv4_roots(db: Session):
|
||||
|
||||
|
||||
@router.get("/overview", response_model=Overview, tags=["overview"])
|
||||
def overview(db: Session = Depends(get_db)):
|
||||
active = db.scalars(select(Prefix).where(Prefix.status == PrefixStatus.active)).all()
|
||||
def overview(db: Session = Depends(get_db), user: User = Depends(current_user)): # изменение 032
|
||||
stmt_prefix = select(Prefix).where(Prefix.status == PrefixStatus.active)
|
||||
stmt_prefix = scope_org(stmt_prefix, Prefix.organization_id, user) # изменение 032
|
||||
active = db.scalars(stmt_prefix).all()
|
||||
outs = _prefix_outs(db, list(active))
|
||||
parents = {p.parent_id for p in outs if p.parent_id}
|
||||
leaves4 = [p for p in outs if p.id not in parents and p.family == 4] # top_prefixes — как раньше, по листьям
|
||||
top = sorted((p for p in leaves4 if p.capacity > 1), key=lambda p: p.utilization, reverse=True)[:4]
|
||||
|
||||
roots_stmt = _ipv4_roots(db)
|
||||
roots_stmt = scope_org(roots_stmt, Prefix.organization_id, user) # изменение 032
|
||||
roots = db.execute(roots_stmt).all()
|
||||
cap = sum(capacity(str(r.prefix)) for r in roots)
|
||||
roots_cte = roots_stmt.cte("overview_roots")
|
||||
@@ -58,10 +61,16 @@ def overview(db: Session = Depends(get_db)):
|
||||
).all())
|
||||
assigned = counts.get(AddressStatus.assigned, 0)
|
||||
reserved = counts.get(AddressStatus.reserved, 0)
|
||||
recent = db.scalars(select(AuditLog).order_by(AuditLog.id.desc()).limit(4)).all()
|
||||
stmt_audit = select(AuditLog)
|
||||
stmt_audit = scope_org(stmt_audit, AuditLog.organization_id, user) # изменение 032: фильтр по организации
|
||||
recent = db.scalars(stmt_audit.order_by(AuditLog.id.desc()).limit(4)).all()
|
||||
stmt_prefix_count = select(func.count()).select_from(Prefix)
|
||||
stmt_prefix_count = scope_org(stmt_prefix_count, Prefix.organization_id, user) # изменение 032
|
||||
stmt_vrf_count = select(func.count()).select_from(Vrf)
|
||||
stmt_vrf_count = scope_org(stmt_vrf_count, Vrf.organization_id, user) # изменение 032
|
||||
return Overview(
|
||||
prefixes=db.scalar(select(func.count()).select_from(Prefix)) or 0,
|
||||
vrfs=db.scalar(select(func.count()).select_from(Vrf)) or 0,
|
||||
prefixes=db.scalar(stmt_prefix_count) or 0,
|
||||
vrfs=db.scalar(stmt_vrf_count) or 0,
|
||||
assigned=assigned, capacity=cap, utilization=utilization(assigned, cap), reserved=reserved,
|
||||
top_prefixes=top, recent_changes=[s.AuditOut.from_row(r) for r in recent],
|
||||
)
|
||||
+44
-22
@@ -10,7 +10,7 @@ from app.models import Address, AddressStatus, Device, Organization, Prefix, Pre
|
||||
from app.security import admin_user, current_user
|
||||
from app.services import (
|
||||
MAX_OFFSET, apply_update, audit, blockers, capacity, commit, count, flush, contains, free_page, get_or_404, network_role, next_free_address, next_free_subnet, refuse_delete,
|
||||
utilization,
|
||||
require_org, scope_org, utilization, # изменение 032
|
||||
)
|
||||
|
||||
router = APIRouter(dependencies=[Depends(current_user)], tags=["prefixes"])
|
||||
@@ -170,9 +170,10 @@ def _subtree(db: Session, root: Prefix) -> list[Prefix]:
|
||||
return result
|
||||
|
||||
|
||||
def _move_to_vrf(db: Session, p: Prefix, vrf_id: int) -> tuple[dict, int]:
|
||||
def _move_to_vrf(db: Session, p: Prefix, vrf_id: int, user: User) -> tuple[dict, int]:
|
||||
"""Переносит префикс с поддеревом в другой VRF той же организации; возвращает (diff, число префиксов)."""
|
||||
vrf = get_or_404(db, Vrf, vrf_id, "VRF")
|
||||
require_org(user, vrf.organization_id, "VRF") # изменение 033, находка №11
|
||||
if vrf.organization_id != p.organization_id:
|
||||
raise HTTPException(422, "Целевой VRF принадлежит другой организации")
|
||||
subtree = _subtree(db, p)
|
||||
@@ -205,11 +206,14 @@ def _move_to_vrf(db: Session, p: Prefix, vrf_id: int) -> tuple[dict, int]:
|
||||
def list_prefixes(
|
||||
organization_id: int | None = None, vrf_id: int | None = None, status: PrefixStatus | None = None,
|
||||
family: int | None = Query(None, ge=4, le=6), q: str = "",
|
||||
limit: int = Query(100, ge=1, le=1000), offset: int = Query(0, ge=0, le=MAX_OFFSET), db: Session = Depends(get_db),
|
||||
limit: int = Query(100, ge=1, le=1000), offset: int = Query(0, ge=0, le=MAX_OFFSET), db: Session = Depends(get_db), user: User = Depends(current_user), # изменение 032
|
||||
):
|
||||
stmt = select(Prefix)
|
||||
if organization_id:
|
||||
require_org(user, organization_id, "Организация") # изменение 032
|
||||
stmt = stmt.where(Prefix.organization_id == organization_id)
|
||||
else:
|
||||
stmt = scope_org(stmt, Prefix.organization_id, user) # изменение 032
|
||||
if vrf_id:
|
||||
stmt = stmt.where(Prefix.vrf_id == vrf_id)
|
||||
if status:
|
||||
@@ -224,14 +228,18 @@ def list_prefixes(
|
||||
|
||||
|
||||
@router.get("/prefixes/{id}", response_model=s.PrefixOut)
|
||||
def get_prefix(id: int, db: Session = Depends(get_db)):
|
||||
return _prefix_outs(db, [get_or_404(db, Prefix, id, "Префикс")])[0]
|
||||
def get_prefix(id: int, db: Session = Depends(get_db), user: User = Depends(current_user)): # изменение 032
|
||||
p = get_or_404(db, Prefix, id, "Префикс")
|
||||
require_org(user, p.organization_id, "Префикс") # изменение 032
|
||||
return _prefix_outs(db, [p])[0]
|
||||
|
||||
|
||||
@router.post("/prefixes", response_model=s.PrefixOut, status_code=201)
|
||||
def create_prefix(body: s.PrefixIn, db: Session = Depends(get_db), user: User = Depends(admin_user)):
|
||||
require_org(user, body.organization_id, "Организация") # изменение 033, находка №11: до _lock_vrf и чтения чужого VRF
|
||||
_lock_vrf(db, body.vrf_id) # до любых чтений дерева VRF (изменение 029)
|
||||
vrf = get_or_404(db, Vrf, body.vrf_id, "VRF")
|
||||
require_org(user, vrf.organization_id, "VRF") # изменение 033, находка №11
|
||||
if vrf.organization_id != body.organization_id:
|
||||
raise HTTPException(422, "VRF принадлежит другой организации")
|
||||
get_or_404(db, Organization, body.organization_id, "Организация")
|
||||
@@ -241,6 +249,7 @@ def create_prefix(body: s.PrefixIn, db: Session = Depends(get_db), user: User =
|
||||
parent_id = body.parent_id
|
||||
if parent_id is not None:
|
||||
parent = get_or_404(db, Prefix, parent_id, "Родительский префикс")
|
||||
require_org(user, parent.organization_id, "Родительский префикс") # изменение 033, находка №11
|
||||
if parent.vrf_id != vrf.id or not ipaddress.ip_network(body.prefix).subnet_of(ipaddress.ip_network(str(parent.prefix))):
|
||||
raise HTTPException(422, "Родительский префикс должен содержать новый и быть в том же VRF")
|
||||
p = Prefix(**{**body.model_dump(), "parent_id": parent_id})
|
||||
@@ -253,7 +262,7 @@ def create_prefix(body: s.PrefixIn, db: Session = Depends(get_db), user: User =
|
||||
db.rollback()
|
||||
raise HTTPException(422, msg)
|
||||
moved = rehome_addresses(db, p)
|
||||
audit(db, user, "prefix", p, "created", str(p.prefix), {"vrf": vrf.name, **({"moved_addresses": moved} if moved else {})})
|
||||
audit(db, user, "prefix", p, "created", str(p.prefix), {"vrf": vrf.name, **({"moved_addresses": moved} if moved else {})}, organization_id=body.organization_id) # изменение 032: organization_id
|
||||
commit(db, "Такой префикс уже есть в этом VRF")
|
||||
return _prefix_outs(db, [p])[0]
|
||||
|
||||
@@ -278,9 +287,11 @@ def _find_subnet(db: Session, parent: Prefix, length: int) -> tuple[str | None,
|
||||
|
||||
|
||||
@router.get("/prefixes/{id}/subnets/next", response_model=s.SubnetPreview)
|
||||
def preview_subnet(id: int, length: int = Query(ge=1, le=128), db: Session = Depends(get_db)):
|
||||
def preview_subnet(id: int, length: int = Query(ge=1, le=128), db: Session = Depends(get_db), user: User = Depends(current_user)): # изменение 033
|
||||
"""Предпросмотр: какой блок будет выделен, без создания."""
|
||||
found, lo, hi = _find_subnet(db, get_or_404(db, Prefix, id, "Префикс"), length)
|
||||
p = get_or_404(db, Prefix, id, "Префикс")
|
||||
require_org(user, p.organization_id, "Префикс") # изменение 033, находка №9: не хватало проверки организации
|
||||
found, lo, hi = _find_subnet(db, p, length)
|
||||
return s.SubnetPreview(prefix=found, length_min=lo, length_max=hi)
|
||||
|
||||
|
||||
@@ -288,6 +299,7 @@ def preview_subnet(id: int, length: int = Query(ge=1, le=128), db: Session = Dep
|
||||
def allocate_subnet(id: int, body: s.SubnetNextIn, db: Session = Depends(get_db), user: User = Depends(admin_user)):
|
||||
"""Создаёт вложенный префикс заданного размера в первом свободном выровненном блоке родителя."""
|
||||
parent = _lock_prefix(db, id) # до любых чтений дерева (изменение 029/030) — сериализует параллельные выделения из одного родителя
|
||||
require_org(user, parent.organization_id, "Префикс") # изменение 032
|
||||
found, _, _ = _find_subnet(db, parent, body.length)
|
||||
if found is None:
|
||||
raise HTTPException(409, f"В префиксе {parent.prefix} нет свободного блока /{body.length}")
|
||||
@@ -297,7 +309,7 @@ def allocate_subnet(id: int, body: s.SubnetNextIn, db: Session = Depends(get_db)
|
||||
flush(db, "Такой префикс уже есть в этом VRF, повторите запрос")
|
||||
attach_to_tree(db, p, keep_parent=True)
|
||||
moved = rehome_addresses(db, p) # адреса родителя из выделенного блока (учтены и при выборе блока, но вдруг появились параллельно)
|
||||
audit(db, user, "prefix", p, "created", found, {"vrf": parent.vrf.name, "allocated_from": str(parent.prefix), **({"moved_addresses": moved} if moved else {})})
|
||||
audit(db, user, "prefix", p, "created", found, {"vrf": parent.vrf.name, "allocated_from": str(parent.prefix), **({"moved_addresses": moved} if moved else {})}, organization_id=parent.organization_id) # изменение 032: organization_id
|
||||
commit(db, "Такой префикс уже есть в этом VRF, повторите запрос")
|
||||
return _prefix_outs(db, [p])[0]
|
||||
|
||||
@@ -310,10 +322,11 @@ def update_prefix(id: int, body: s.PrefixUpdate, db: Session = Depends(get_db),
|
||||
# раньше чем прочитан текущий p.vrf_id (изменение 030, ревью 025-029 находка №1); если new_vrf совпадёт
|
||||
# с текущим VRF, лишняя блокировка того же VRF безвредна
|
||||
p = _lock_prefix(db, id, new_vrf) if new_vrf is not None else get_or_404(db, Prefix, id, "Префикс")
|
||||
require_org(user, p.organization_id, "Префикс") # изменение 032
|
||||
changed = {k: str(v) for k, v in apply_update(p, data).items()}
|
||||
if new_vrf is not None and new_vrf != p.vrf_id:
|
||||
changed.update(_move_to_vrf(db, p, new_vrf)[0])
|
||||
audit(db, user, "prefix", p, "updated", str(p.prefix), changed)
|
||||
changed.update(_move_to_vrf(db, p, new_vrf, user)[0])
|
||||
audit(db, user, "prefix", p, "updated", str(p.prefix), changed, organization_id=p.organization_id) # изменение 032: organization_id
|
||||
commit(db)
|
||||
return _prefix_outs(db, [p])[0]
|
||||
|
||||
@@ -321,12 +334,13 @@ def update_prefix(id: int, body: s.PrefixUpdate, db: Session = Depends(get_db),
|
||||
@router.delete("/prefixes/{id}", status_code=204)
|
||||
def delete_prefix(id: int, force: bool = False, db: Session = Depends(get_db), user: User = Depends(admin_user)):
|
||||
p = _lock_prefix(db, id) # до переподвешивания детей: устаревший p.parent_id иначе достанется всем детям (изменение 029/030)
|
||||
require_org(user, p.organization_id, "Префикс") # изменение 032
|
||||
used = None if force else blockers(db, select(func.host(Address.address)).where(Address.prefix_id == id).order_by(Address.address))
|
||||
if used:
|
||||
refuse_delete(db, user, "prefix", p, str(p.prefix), "В префиксе есть адреса; удалите их или используйте force=true", {"addresses": used})
|
||||
refuse_delete(db, user, "prefix", p, str(p.prefix), "В префиксе есть адреса; удалите их или используйте force=true", {"addresses": used}, organization_id=p.organization_id) # изменение 033, находка №4
|
||||
db.execute(update(Prefix).where(Prefix.parent_id == id).values(parent_id=p.parent_id))
|
||||
gone = count(db, select(Address.id).where(Address.prefix_id == id)) if force else 0
|
||||
audit(db, user, "prefix", p, "deleted", str(p.prefix), {"force": True, "addresses_deleted": gone} if gone else None)
|
||||
audit(db, user, "prefix", p, "deleted", str(p.prefix), {"force": True, "addresses_deleted": gone} if gone else None, organization_id=p.organization_id) # изменение 032: organization_id
|
||||
db.delete(p)
|
||||
commit(db)
|
||||
|
||||
@@ -340,9 +354,10 @@ def _addr_out(a: Address, device_name: str | None = None) -> s.AddressOut:
|
||||
)
|
||||
|
||||
|
||||
def _check_device(db: Session, prefix: Prefix, device_id: int | None):
|
||||
def _check_device(db: Session, user: User, prefix: Prefix, device_id: int | None):
|
||||
if device_id is not None:
|
||||
d = get_or_404(db, Device, device_id, "Устройство")
|
||||
require_org(user, d.organization_id, "Устройство") # изменение 033, находка №11
|
||||
if d.organization_id != prefix.organization_id:
|
||||
raise HTTPException(422, "Устройство принадлежит другой организации")
|
||||
|
||||
@@ -350,11 +365,12 @@ def _check_device(db: Session, prefix: Prefix, device_id: int | None):
|
||||
@router.get("/prefixes/{id}/addresses", response_model=s.AddressPage)
|
||||
def list_addresses(
|
||||
id: int, status: str = "", q: str = "", limit: int = Query(100, ge=1, le=500), offset: int = Query(0, ge=0, le=MAX_OFFSET),
|
||||
db: Session = Depends(get_db),
|
||||
db: Session = Depends(get_db), user: User = Depends(current_user), # изменение 032
|
||||
):
|
||||
"""status: assigned | reserved | deprecated | free | пусто (все; свободные подмешиваются для малых подсетей).
|
||||
Пагинация — в SQL; страница «свободных» считается арифметически (без перебора адресов подсети)."""
|
||||
p = get_or_404(db, Prefix, id, "Префикс")
|
||||
require_org(user, p.organization_id, "Префикс") # изменение 032
|
||||
cap = capacity(str(p.prefix))
|
||||
counts = dict(db.execute(select(Address.status, func.count()).where(Address.prefix_id == id).group_by(Address.status)).all())
|
||||
stored = sum(counts.values())
|
||||
@@ -394,6 +410,7 @@ def list_addresses(
|
||||
@router.post("/prefixes/{id}/addresses", response_model=s.AddressOut, status_code=201)
|
||||
def create_address(id: int, body: s.AddressIn, db: Session = Depends(get_db), user: User = Depends(admin_user)):
|
||||
p = _lock_prefix(db, id) # выбор самого узкого префикса должен видеть согласованное дерево VRF (изменение 029/030)
|
||||
require_org(user, p.organization_id, "Префикс") # изменение 032
|
||||
net, ip = ipaddress.ip_network(str(p.prefix)), ipaddress.ip_address(body.address)
|
||||
if ip not in net:
|
||||
raise HTTPException(422, f"Адрес {body.address} не принадлежит префиксу {p.prefix}")
|
||||
@@ -403,11 +420,11 @@ def create_address(id: int, body: s.AddressIn, db: Session = Depends(get_db), us
|
||||
narrowest = _narrowest_prefix(db, p.vrf_id, body.address)
|
||||
if narrowest is not None and narrowest.id != p.id: # адрес хранится в самом узком префиксе VRF
|
||||
raise HTTPException(422, f"Адрес {body.address} принадлежит вложенному префиксу {narrowest.prefix}, назначьте его там")
|
||||
_check_device(db, p, body.device_id)
|
||||
_check_device(db, user, p, body.device_id)
|
||||
a = Address(prefix_id=id, vrf_id=p.vrf_id, **body.model_dump())
|
||||
db.add(a)
|
||||
flush(db, "Адрес уже есть в этом префиксе")
|
||||
audit(db, user, "address", a, "assigned" if a.status == AddressStatus.assigned else "created", body.address)
|
||||
audit(db, user, "address", a, "assigned" if a.status == AddressStatus.assigned else "created", body.address, organization_id=p.organization_id) # изменение 032: organization_id
|
||||
commit(db, "Адрес уже есть в этом префиксе")
|
||||
db.refresh(a)
|
||||
return _addr_out(a)
|
||||
@@ -419,17 +436,18 @@ def allocate_next(
|
||||
):
|
||||
"""Автоназначение первого свободного адреса; только для префиксов с флагом is_pool."""
|
||||
p = _lock_prefix(db, id) # занятые диапазоны (вложенные префиксы) должны читаться по согласованному дереву (изменение 029/030)
|
||||
require_org(user, p.organization_id, "Префикс") # изменение 032
|
||||
if not p.is_pool:
|
||||
raise HTTPException(422, "Префикс не является пулом для автоназначения")
|
||||
ip = next_free_address(ipaddress.ip_network(str(p.prefix)), _busy_ranges(db, p)) # вложенные префиксы и адрес сети/broadcast пропускаются
|
||||
if ip is None:
|
||||
raise HTTPException(409, "В префиксе нет свободных адресов")
|
||||
data = body.model_dump(exclude_unset=True, exclude_none=True) if body else {}
|
||||
_check_device(db, p, data.get("device_id"))
|
||||
_check_device(db, user, p, data.get("device_id"))
|
||||
a = Address(prefix_id=id, vrf_id=p.vrf_id, address=ip, **data)
|
||||
db.add(a)
|
||||
flush(db, "Адрес уже занят, повторите запрос")
|
||||
audit(db, user, "address", a, "assigned", ip)
|
||||
audit(db, user, "address", a, "assigned", ip, organization_id=p.organization_id) # изменение 032: organization_id
|
||||
commit(db, "Адрес уже занят, повторите запрос")
|
||||
db.refresh(a)
|
||||
return _addr_out(a)
|
||||
@@ -438,12 +456,14 @@ def allocate_next(
|
||||
@router.patch("/addresses/{id}", response_model=s.AddressOut)
|
||||
def update_address(id: int, body: s.AddressUpdate, db: Session = Depends(get_db), user: User = Depends(admin_user)):
|
||||
a = get_or_404(db, Address, id, "Адрес")
|
||||
p = db.get(Prefix, a.prefix_id)
|
||||
require_org(user, p.organization_id, "Адрес") # изменение 032
|
||||
data = body.model_dump(exclude_unset=True)
|
||||
if data.get("dns_name"):
|
||||
s.AddressIn(address=s.ip_text(a.address), dns_name=data["dns_name"]) # валидация FQDN
|
||||
_check_device(db, db.get(Prefix, a.prefix_id), data.get("device_id"))
|
||||
_check_device(db, user, p, data.get("device_id"))
|
||||
changed = apply_update(a, data)
|
||||
audit(db, user, "address", a, "updated", s.ip_text(a.address), {k: str(v) for k, v in changed.items()})
|
||||
audit(db, user, "address", a, "updated", s.ip_text(a.address), {k: str(v) for k, v in changed.items()}, organization_id=p.organization_id) # изменение 032: organization_id
|
||||
commit(db)
|
||||
db.refresh(a)
|
||||
return _addr_out(a)
|
||||
@@ -452,6 +472,8 @@ def update_address(id: int, body: s.AddressUpdate, db: Session = Depends(get_db)
|
||||
@router.delete("/addresses/{id}", status_code=204)
|
||||
def delete_address(id: int, db: Session = Depends(get_db), user: User = Depends(admin_user)):
|
||||
a = get_or_404(db, Address, id, "Адрес")
|
||||
audit(db, user, "address", a, "deleted", s.ip_text(a.address))
|
||||
p = db.get(Prefix, a.prefix_id)
|
||||
require_org(user, p.organization_id, "Адрес") # изменение 032
|
||||
audit(db, user, "address", a, "deleted", s.ip_text(a.address), organization_id=p.organization_id) # изменение 032: organization_id
|
||||
db.delete(a)
|
||||
commit(db)
|
||||
+73
-44
@@ -8,8 +8,8 @@ from app.db import get_db
|
||||
from app.models import (
|
||||
Address, AddressStatus, Device, DeviceType, Isp, IspNetwork, Organization, Prefix, User, Vrf,
|
||||
)
|
||||
from app.security import admin_user, current_user
|
||||
from app.services import MAX_OFFSET, apply_update, audit, blockers, commit, contains, count, flush, get_or_404, refuse_delete
|
||||
from app.security import admin_user, current_user, superadmin_user
|
||||
from app.services import MAX_OFFSET, apply_update, audit, blockers, commit, contains, count, flush, get_or_404, refuse_delete, require_org, scope_org
|
||||
from fastapi import HTTPException
|
||||
|
||||
router = APIRouter(dependencies=[Depends(current_user)])
|
||||
@@ -37,8 +37,9 @@ def _org_out(db: Session, o: Organization) -> s.OrgOut:
|
||||
|
||||
|
||||
@router.get("/organizations", response_model=s.Page[s.OrgOut], tags=["organizations"])
|
||||
def list_orgs(q: str = "", limit: int = Query(100, ge=1, le=500), offset: int = Query(0, ge=0, le=MAX_OFFSET), db: Session = Depends(get_db)):
|
||||
def list_orgs(q: str = "", limit: int = Query(100, ge=1, le=500), offset: int = Query(0, ge=0, le=MAX_OFFSET), db: Session = Depends(get_db), user: User = Depends(current_user)):
|
||||
stmt = select(Organization)
|
||||
stmt = scope_org(stmt, Organization.id, user) # изменение 032
|
||||
if q:
|
||||
stmt = stmt.where(or_(*(contains(c, q) for c in (Organization.name, Organization.short_name, Organization.inn, Organization.address))))
|
||||
total = count(db, stmt)
|
||||
@@ -47,43 +48,46 @@ def list_orgs(q: str = "", limit: int = Query(100, ge=1, le=500), offset: int =
|
||||
|
||||
|
||||
@router.get("/organizations/{id}", response_model=s.OrgOut, tags=["organizations"])
|
||||
def get_org(id: int, db: Session = Depends(get_db)):
|
||||
def get_org(id: int, db: Session = Depends(get_db), user: User = Depends(current_user)): # изменение 032
|
||||
require_org(user, id, "Организация")
|
||||
return _org_out(db, get_or_404(db, Organization, id, "Организация"))
|
||||
|
||||
|
||||
@router.post("/organizations", response_model=s.OrgOut, status_code=201, tags=["organizations"])
|
||||
def create_org(body: s.OrgIn, db: Session = Depends(get_db), user: User = Depends(admin_user)):
|
||||
def create_org(body: s.OrgIn, db: Session = Depends(get_db), user: User = Depends(superadmin_user)): # изменение 032: только superadmin
|
||||
o = Organization(**body.model_dump())
|
||||
db.add(o)
|
||||
flush(db, "Организация с таким названием или ИНН уже существует")
|
||||
db.add(Vrf(organization_id=o.id, name="default"))
|
||||
audit(db, user, "organization", o, "created", o.name)
|
||||
audit(db, user, "organization", o, "created", o.name, organization_id=o.id) # изменение 032: organization_id
|
||||
commit(db, "Организация с таким названием или ИНН уже существует")
|
||||
return _org_out(db, o)
|
||||
|
||||
|
||||
@router.patch("/organizations/{id}", response_model=s.OrgOut, tags=["organizations"])
|
||||
def update_org(id: int, body: s.OrgIn, db: Session = Depends(get_db), user: User = Depends(admin_user)):
|
||||
def update_org(id: int, body: s.OrgIn, db: Session = Depends(get_db), user: User = Depends(admin_user)): # изменение 032: admin_user
|
||||
require_org(user, id, "Организация") # изменение 032
|
||||
o = get_or_404(db, Organization, id, "Организация")
|
||||
changed = apply_update(o, body.model_dump())
|
||||
audit(db, user, "organization", o, "updated", o.name, changed)
|
||||
audit(db, user, "organization", o, "updated", o.name, changed, organization_id=id) # изменение 032: organization_id
|
||||
commit(db, "Организация с таким названием или ИНН уже существует")
|
||||
return _org_out(db, o)
|
||||
|
||||
|
||||
@router.delete("/organizations/{id}", status_code=204, tags=["organizations"])
|
||||
def delete_org(id: int, db: Session = Depends(get_db), user: User = Depends(admin_user)):
|
||||
def delete_org(id: int, db: Session = Depends(get_db), user: User = Depends(superadmin_user)): # изменение 032: только superadmin
|
||||
o = get_or_404(db, Organization, id, "Организация")
|
||||
found = {
|
||||
"prefixes": blockers(db, select(func.concat(cast(Prefix.prefix, String), " (", Vrf.name, ")")).select_from(Prefix).join(Vrf, Vrf.id == Prefix.vrf_id)
|
||||
.where(Prefix.organization_id == id).order_by(Prefix.id)),
|
||||
"devices": blockers(db, select(Device.name).where(Device.organization_id == id).order_by(Device.id)),
|
||||
"isps": blockers(db, select(Isp.name).where(Isp.organization_id == id).order_by(Isp.id)),
|
||||
"users": blockers(db, select(User.username).where(User.organization_id == id).order_by(User.id)), # изменение 032: пользователи
|
||||
}
|
||||
if any(found.values()):
|
||||
refuse_delete(db, user, "organization", o, o.name, "Нельзя удалить: у организации есть префиксы, устройства или операторы", found)
|
||||
refuse_delete(db, user, "organization", o, o.name, "Нельзя удалить: у организации есть привязанные объекты", found, organization_id=id) # изменение 033, находка №4
|
||||
db.execute(delete(Vrf).where(Vrf.organization_id == id)) # немедленно: между Vrf и Organization нет relationship(), порядок DELETE в UoW не гарантирован
|
||||
audit(db, user, "organization", o, "deleted", o.name)
|
||||
audit(db, user, "organization", o, "deleted", o.name, organization_id=id) # изменение 032: organization_id
|
||||
db.delete(o)
|
||||
commit(db)
|
||||
|
||||
@@ -105,49 +109,59 @@ def _vrf_out(db: Session, v: Vrf) -> s.VrfOut:
|
||||
|
||||
|
||||
@router.get("/vrfs", response_model=s.Page[s.VrfOut], tags=["vrf"])
|
||||
def list_vrfs(organization_id: int | None = None, db: Session = Depends(get_db)):
|
||||
def list_vrfs(organization_id: int | None = None, db: Session = Depends(get_db), user: User = Depends(current_user)): # изменение 032
|
||||
stmt = select(Vrf)
|
||||
if organization_id:
|
||||
require_org(user, organization_id, "Организация") # изменение 032
|
||||
stmt = stmt.where(Vrf.organization_id == organization_id)
|
||||
else:
|
||||
stmt = scope_org(stmt, Vrf.organization_id, user) # изменение 032
|
||||
rows = db.scalars(stmt.order_by(Vrf.id)).all()
|
||||
return s.Page(items=_vrf_outs(db, list(rows)), total=len(rows))
|
||||
|
||||
|
||||
@router.post("/vrfs", response_model=s.VrfOut, status_code=201, tags=["vrf"])
|
||||
def create_vrf(body: s.VrfIn, db: Session = Depends(get_db), user: User = Depends(admin_user)):
|
||||
def create_vrf(body: s.VrfIn, db: Session = Depends(get_db), user: User = Depends(admin_user)): # изменение 032: admin_user вместо admin
|
||||
require_org(user, body.organization_id, "Организация") # изменение 032
|
||||
get_or_404(db, Organization, body.organization_id, "Организация")
|
||||
v = Vrf(**body.model_dump())
|
||||
db.add(v)
|
||||
flush(db, "VRF с таким названием уже есть в организации")
|
||||
audit(db, user, "vrf", v, "created", v.name)
|
||||
audit(db, user, "vrf", v, "created", v.name, organization_id=body.organization_id) # изменение 032: organization_id
|
||||
commit(db, "VRF с таким названием уже есть в организации")
|
||||
return _vrf_out(db, v)
|
||||
|
||||
|
||||
@router.patch("/vrfs/{id}", response_model=s.VrfOut, tags=["vrf"])
|
||||
def update_vrf(id: int, body: s.VrfUpdate, db: Session = Depends(get_db), user: User = Depends(admin_user)):
|
||||
def update_vrf(id: int, body: s.VrfUpdate, db: Session = Depends(get_db), user: User = Depends(admin_user)): # изменение 032
|
||||
v = get_or_404(db, Vrf, id, "VRF")
|
||||
require_org(user, v.organization_id, "VRF") # изменение 032
|
||||
changed = apply_update(v, body.model_dump(exclude_unset=True, exclude_none=True))
|
||||
audit(db, user, "vrf", v, "updated", v.name, changed)
|
||||
audit(db, user, "vrf", v, "updated", v.name, changed, organization_id=v.organization_id) # изменение 032: organization_id
|
||||
commit(db, "VRF с таким названием уже есть в организации")
|
||||
return _vrf_out(db, v)
|
||||
|
||||
|
||||
@router.delete("/vrfs/{id}", status_code=204, tags=["vrf"])
|
||||
def delete_vrf(id: int, db: Session = Depends(get_db), user: User = Depends(admin_user)):
|
||||
def delete_vrf(id: int, db: Session = Depends(get_db), user: User = Depends(admin_user)): # изменение 032
|
||||
v = get_or_404(db, Vrf, id, "VRF")
|
||||
require_org(user, v.organization_id, "VRF") # изменение 032
|
||||
used = blockers(db, select(cast(Prefix.prefix, String)).where(Prefix.vrf_id == id).order_by(Prefix.id))
|
||||
if used:
|
||||
refuse_delete(db, user, "vrf", v, v.name, "Нельзя удалить: VRF используется префиксами", {"prefixes": used})
|
||||
audit(db, user, "vrf", v, "deleted", v.name)
|
||||
refuse_delete(db, user, "vrf", v, v.name, "Нельзя удалить: VRF используется префиксами", {"prefixes": used}, organization_id=v.organization_id) # изменение 033, находка №4
|
||||
audit(db, user, "vrf", v, "deleted", v.name, organization_id=v.organization_id) # изменение 032: organization_id
|
||||
db.delete(v)
|
||||
commit(db)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- device types
|
||||
def _type_outs(db: Session, rows: list[DeviceType]) -> list[s.DeviceTypeOut]:
|
||||
def _type_outs(db: Session, rows: list[DeviceType], user: User | None = None) -> list[s.DeviceTypeOut]:
|
||||
"""изменение 033, находка №12: счётчик — в границах организации пользователя, а не по всем организациям."""
|
||||
ids = [t.id for t in rows]
|
||||
counts = dict(db.execute(select(Device.device_type_id, func.count()).where(Device.device_type_id.in_(ids)).group_by(Device.device_type_id)).all()) if ids else {}
|
||||
stmt = select(Device.device_type_id, func.count()).where(Device.device_type_id.in_(ids))
|
||||
if user is not None:
|
||||
stmt = scope_org(stmt, Device.organization_id, user)
|
||||
counts = dict(db.execute(stmt.group_by(Device.device_type_id)).all()) if ids else {}
|
||||
out = []
|
||||
for t in rows:
|
||||
item = s.DeviceTypeOut.model_validate(t)
|
||||
@@ -156,18 +170,18 @@ def _type_outs(db: Session, rows: list[DeviceType]) -> list[s.DeviceTypeOut]:
|
||||
return out
|
||||
|
||||
|
||||
def _type_out(db: Session, t: DeviceType) -> s.DeviceTypeOut:
|
||||
return _type_outs(db, [t])[0]
|
||||
def _type_out(db: Session, t: DeviceType, user: User | None = None) -> s.DeviceTypeOut:
|
||||
return _type_outs(db, [t], user)[0]
|
||||
|
||||
|
||||
@router.get("/device-types", response_model=s.Page[s.DeviceTypeOut], tags=["devices"])
|
||||
def list_types(db: Session = Depends(get_db)):
|
||||
def list_types(db: Session = Depends(get_db), user: User = Depends(current_user)): # изменение 033, находка №12
|
||||
rows = db.scalars(select(DeviceType).order_by(DeviceType.id)).all()
|
||||
return s.Page(items=_type_outs(db, list(rows)), total=len(rows))
|
||||
return s.Page(items=_type_outs(db, list(rows), user), total=len(rows))
|
||||
|
||||
|
||||
@router.post("/device-types", response_model=s.DeviceTypeOut, status_code=201, tags=["devices"])
|
||||
def create_type(body: s.DeviceTypeIn, db: Session = Depends(get_db), user: User = Depends(admin_user)):
|
||||
def create_type(body: s.DeviceTypeIn, db: Session = Depends(get_db), user: User = Depends(superadmin_user)): # изменение 032: только superadmin
|
||||
t = DeviceType(name=body.name)
|
||||
db.add(t)
|
||||
flush(db, "Тип с таким названием уже существует")
|
||||
@@ -177,7 +191,7 @@ def create_type(body: s.DeviceTypeIn, db: Session = Depends(get_db), user: User
|
||||
|
||||
|
||||
@router.patch("/device-types/{id}", response_model=s.DeviceTypeOut, tags=["devices"])
|
||||
def update_type(id: int, body: s.DeviceTypeIn, db: Session = Depends(get_db), user: User = Depends(admin_user)):
|
||||
def update_type(id: int, body: s.DeviceTypeIn, db: Session = Depends(get_db), user: User = Depends(superadmin_user)): # изменение 032: только superadmin
|
||||
t = get_or_404(db, DeviceType, id, "Тип")
|
||||
changed = apply_update(t, {"name": body.name})
|
||||
audit(db, user, "device_type", t, "updated", t.name, changed)
|
||||
@@ -186,7 +200,7 @@ def update_type(id: int, body: s.DeviceTypeIn, db: Session = Depends(get_db), us
|
||||
|
||||
|
||||
@router.delete("/device-types/{id}", status_code=204, tags=["devices"])
|
||||
def delete_type(id: int, db: Session = Depends(get_db), user: User = Depends(admin_user)):
|
||||
def delete_type(id: int, db: Session = Depends(get_db), user: User = Depends(superadmin_user)): # изменение 032: только superadmin
|
||||
t = get_or_404(db, DeviceType, id, "Тип")
|
||||
if t.is_default:
|
||||
refuse_delete(db, user, "device_type", t, t.name, "Нельзя удалить тип по умолчанию")
|
||||
@@ -228,11 +242,14 @@ def _device_out(db: Session, d: Device) -> s.DeviceOut:
|
||||
@router.get("/devices", response_model=s.Page[s.DeviceOut], tags=["devices"])
|
||||
def list_devices(
|
||||
organization_id: int | None = None, device_type_id: int | None = None, q: str = "",
|
||||
limit: int = Query(100, ge=1, le=500), offset: int = Query(0, ge=0, le=MAX_OFFSET), db: Session = Depends(get_db),
|
||||
limit: int = Query(100, ge=1, le=500), offset: int = Query(0, ge=0, le=MAX_OFFSET), db: Session = Depends(get_db), user: User = Depends(current_user), # изменение 032
|
||||
):
|
||||
stmt = select(Device)
|
||||
if organization_id:
|
||||
require_org(user, organization_id, "Организация") # изменение 032
|
||||
stmt = stmt.where(Device.organization_id == organization_id)
|
||||
else:
|
||||
stmt = scope_org(stmt, Device.organization_id, user) # изменение 032
|
||||
if device_type_id:
|
||||
stmt = stmt.where(Device.device_type_id == device_type_id)
|
||||
if q:
|
||||
@@ -244,38 +261,43 @@ def list_devices(
|
||||
|
||||
|
||||
@router.post("/devices", response_model=s.DeviceOut, status_code=201, tags=["devices"])
|
||||
def create_device(body: s.DeviceIn, db: Session = Depends(get_db), user: User = Depends(admin_user)):
|
||||
def create_device(body: s.DeviceIn, db: Session = Depends(get_db), user: User = Depends(admin_user)): # изменение 032: admin_user вместо admin
|
||||
require_org(user, body.organization_id, "Организация") # изменение 032
|
||||
get_or_404(db, Organization, body.organization_id, "Организация")
|
||||
get_or_404(db, DeviceType, body.device_type_id, "Тип")
|
||||
d = Device(**body.model_dump())
|
||||
db.add(d)
|
||||
flush(db, "Устройство с таким именем уже есть в организации")
|
||||
audit(db, user, "device", d, "created", d.name)
|
||||
audit(db, user, "device", d, "created", d.name, organization_id=body.organization_id) # изменение 032: organization_id
|
||||
commit(db, "Устройство с таким именем уже есть в организации")
|
||||
return _device_out(db, d)
|
||||
|
||||
|
||||
@router.get("/devices/{id}", response_model=s.DeviceOut, tags=["devices"])
|
||||
def get_device(id: int, db: Session = Depends(get_db)):
|
||||
return _device_out(db, get_or_404(db, Device, id, "Устройство"))
|
||||
def get_device(id: int, db: Session = Depends(get_db), user: User = Depends(current_user)): # изменение 032
|
||||
d = get_or_404(db, Device, id, "Устройство")
|
||||
require_org(user, d.organization_id, "Устройство") # изменение 032
|
||||
return _device_out(db, d)
|
||||
|
||||
|
||||
@router.patch("/devices/{id}", response_model=s.DeviceOut, tags=["devices"])
|
||||
def update_device(id: int, body: s.DeviceUpdate, db: Session = Depends(get_db), user: User = Depends(admin_user)):
|
||||
def update_device(id: int, body: s.DeviceUpdate, db: Session = Depends(get_db), user: User = Depends(admin_user)): # изменение 032
|
||||
d = get_or_404(db, Device, id, "Устройство")
|
||||
require_org(user, d.organization_id, "Устройство") # изменение 032
|
||||
data = body.model_dump(exclude_unset=True, exclude_none=True)
|
||||
if "device_type_id" in data:
|
||||
get_or_404(db, DeviceType, data["device_type_id"], "Тип")
|
||||
changed = apply_update(d, data)
|
||||
audit(db, user, "device", d, "updated", d.name, changed)
|
||||
audit(db, user, "device", d, "updated", d.name, changed, organization_id=d.organization_id) # изменение 032: organization_id
|
||||
commit(db, "Устройство с таким именем уже есть в организации")
|
||||
return _device_out(db, d)
|
||||
|
||||
|
||||
@router.delete("/devices/{id}", status_code=204, tags=["devices"])
|
||||
def delete_device(id: int, db: Session = Depends(get_db), user: User = Depends(admin_user)):
|
||||
def delete_device(id: int, db: Session = Depends(get_db), user: User = Depends(admin_user)): # изменение 032
|
||||
d = get_or_404(db, Device, id, "Устройство")
|
||||
audit(db, user, "device", d, "deleted", d.name)
|
||||
require_org(user, d.organization_id, "Устройство") # изменение 032
|
||||
audit(db, user, "device", d, "deleted", d.name, organization_id=d.organization_id) # изменение 032: organization_id
|
||||
db.delete(d)
|
||||
commit(db)
|
||||
|
||||
@@ -298,11 +320,14 @@ def _isp_out(db: Session, i: Isp) -> s.IspOut:
|
||||
@router.get("/isps", response_model=s.Page[s.IspOut], tags=["isps"])
|
||||
def list_isps(
|
||||
organization_id: int | None = None, q: str = "", limit: int = Query(100, ge=1, le=500), offset: int = Query(0, ge=0, le=MAX_OFFSET),
|
||||
db: Session = Depends(get_db),
|
||||
db: Session = Depends(get_db), user: User = Depends(current_user), # изменение 032
|
||||
):
|
||||
stmt = select(Isp)
|
||||
if organization_id:
|
||||
require_org(user, organization_id, "Организация") # изменение 032
|
||||
stmt = stmt.where(Isp.organization_id == organization_id)
|
||||
else:
|
||||
stmt = scope_org(stmt, Isp.organization_id, user) # изменение 032
|
||||
if q:
|
||||
nets = select(IspNetwork.isp_id).where(contains(cast(IspNetwork.cidr, String), q))
|
||||
orgs = select(Organization.id).where(contains(Organization.name, q))
|
||||
@@ -313,34 +338,38 @@ def list_isps(
|
||||
|
||||
|
||||
@router.post("/isps", response_model=s.IspOut, status_code=201, tags=["isps"])
|
||||
def create_isp(body: s.IspIn, db: Session = Depends(get_db), user: User = Depends(admin_user)):
|
||||
def create_isp(body: s.IspIn, db: Session = Depends(get_db), user: User = Depends(admin_user)): # изменение 032: admin_user вместо admin
|
||||
require_org(user, body.organization_id, "Организация") # изменение 032
|
||||
get_or_404(db, Organization, body.organization_id, "Организация")
|
||||
data = body.model_dump()
|
||||
nets = data.pop("networks")
|
||||
i = Isp(**data, networks=[IspNetwork(cidr=n) for n in nets])
|
||||
db.add(i)
|
||||
db.flush()
|
||||
audit(db, user, "isp", i, "created", i.name)
|
||||
audit(db, user, "isp", i, "created", i.name, organization_id=body.organization_id) # изменение 032: organization_id
|
||||
commit(db)
|
||||
return _isp_out(db, i)
|
||||
|
||||
|
||||
@router.put("/isps/{id}", response_model=s.IspOut, tags=["isps"])
|
||||
def update_isp(id: int, body: s.IspIn, db: Session = Depends(get_db), user: User = Depends(admin_user)):
|
||||
def update_isp(id: int, body: s.IspIn, db: Session = Depends(get_db), user: User = Depends(admin_user)): # изменение 032
|
||||
i = get_or_404(db, Isp, id, "Оператор")
|
||||
require_org(user, i.organization_id, "Оператор") # изменение 032
|
||||
require_org(user, body.organization_id, "Организация") # изменение 033, находка №11: до get_or_404 чужой организации
|
||||
get_or_404(db, Organization, body.organization_id, "Организация")
|
||||
data = body.model_dump()
|
||||
nets = data.pop("networks")
|
||||
changed = apply_update(i, data)
|
||||
i.networks = [IspNetwork(cidr=n) for n in nets]
|
||||
audit(db, user, "isp", i, "updated", i.name, changed)
|
||||
audit(db, user, "isp", i, "updated", i.name, changed, organization_id=i.organization_id) # изменение 032: organization_id
|
||||
commit(db)
|
||||
return _isp_out(db, i)
|
||||
|
||||
|
||||
@router.delete("/isps/{id}", status_code=204, tags=["isps"])
|
||||
def delete_isp(id: int, db: Session = Depends(get_db), user: User = Depends(admin_user)):
|
||||
def delete_isp(id: int, db: Session = Depends(get_db), user: User = Depends(admin_user)): # изменение 032
|
||||
i = get_or_404(db, Isp, id, "Оператор")
|
||||
audit(db, user, "isp", i, "deleted", i.name)
|
||||
require_org(user, i.organization_id, "Оператор") # изменение 032
|
||||
audit(db, user, "isp", i, "deleted", i.name, organization_id=i.organization_id) # изменение 032: organization_id
|
||||
db.delete(i)
|
||||
commit(db)
|
||||
+47
-32
@@ -1,8 +1,9 @@
|
||||
"""Пользователи: учётные записи UI (роли admin/viewer), смена своего пароля.
|
||||
"""Пользователи: учётные записи UI (роли superadmin/admin/viewer), смена своего пароля.
|
||||
|
||||
Правила: логин после создания не меняется (он же `sub` в JWT), нельзя отключить/понизить/удалить
|
||||
свою учётную запись и последнего активного администратора; зарезервированные логины `system`
|
||||
свою учётную запись и последнего активного суперадминистратора; зарезервированные логины `system`
|
||||
и `anonymous` запрещены — журнал различает по ним служебные события (`actor_of` в app/services.py).
|
||||
Управление пользователями — только superadmin (изменение 032).
|
||||
"""
|
||||
from datetime import datetime, timezone
|
||||
|
||||
@@ -12,29 +13,29 @@ from sqlalchemy.orm import Session
|
||||
|
||||
from app import schemas as s
|
||||
from app.db import get_db
|
||||
from app.models import Role, User
|
||||
from app.security import admin_user, create_token, current_user, hash_password, verify_password
|
||||
from app.models import Organization, Role, User
|
||||
from app.security import admin_user, create_token, current_user, hash_password, superadmin_user, verify_password
|
||||
from app.services import MAX_OFFSET, apply_update, audit, commit, contains, count, flush, get_or_404, refuse_delete
|
||||
|
||||
router = APIRouter(dependencies=[Depends(current_user)], tags=["users"])
|
||||
|
||||
|
||||
USERS_ADMIN_LOCK = 703002 # advisory lock: изменения прав администраторов идут по одному (иначе двое отключат друг друга одновременно)
|
||||
USERS_LOCK = 703002 # advisory lock: изменения прав суперадминистраторов идут по одному (иначе двое отключат друг друга одновременно)
|
||||
|
||||
|
||||
def _lock_admins(db: Session) -> None:
|
||||
db.execute(select(func.pg_advisory_xact_lock(USERS_ADMIN_LOCK)))
|
||||
def _lock_users(db: Session) -> None:
|
||||
db.execute(select(func.pg_advisory_xact_lock(USERS_LOCK)))
|
||||
|
||||
|
||||
def _other_active_admins(db: Session, user_id: int) -> int:
|
||||
"""Активные администраторы, кроме указанного: 0 — система осталась бы без прав записи."""
|
||||
return count(db, select(User.id).where(User.role == Role.admin, User.is_active, User.id != user_id))
|
||||
def _other_active_superadmins(db: Session, user_id: int) -> int:
|
||||
"""Активные суперадминистраторы, кроме указанного: 0 — система осталась бы без прав записи (изменение 032)."""
|
||||
return count(db, select(User.id).where(User.role == Role.superadmin, User.is_active, User.id != user_id))
|
||||
|
||||
|
||||
@router.get("/users", response_model=s.Page[s.UserOut])
|
||||
def list_users(
|
||||
q: str = "", limit: int = Query(100, ge=1, le=500), offset: int = Query(0, ge=0, le=MAX_OFFSET),
|
||||
db: Session = Depends(get_db), admin: User = Depends(admin_user),
|
||||
db: Session = Depends(get_db), user: User = Depends(superadmin_user), # изменение 032
|
||||
):
|
||||
stmt = select(User)
|
||||
if q.strip():
|
||||
@@ -45,54 +46,68 @@ def list_users(
|
||||
|
||||
|
||||
@router.post("/users", response_model=s.UserOut, status_code=201)
|
||||
def create_user(body: s.UserIn, db: Session = Depends(get_db), admin: User = Depends(admin_user)):
|
||||
def create_user(body: s.UserIn, db: Session = Depends(get_db), user: User = Depends(superadmin_user)): # изменение 032
|
||||
# логин уникален без учёта регистра: в токене и в журнале он должен опознаваться однозначно
|
||||
if db.scalar(select(User.id).where(func.lower(User.username) == body.username.lower())):
|
||||
raise HTTPException(409, "Пользователь с таким логином уже существует")
|
||||
u = User(username=body.username, password_hash=hash_password(body.password), role=body.role, is_active=body.is_active)
|
||||
if body.organization_id is not None:
|
||||
get_or_404(db, Organization, body.organization_id, "Организация") # изменение 033, находка №10
|
||||
u = User(username=body.username, password_hash=hash_password(body.password), role=body.role, organization_id=body.organization_id, is_active=body.is_active) # изменение 032
|
||||
db.add(u)
|
||||
flush(db, "Пользователь с таким логином уже существует")
|
||||
audit(db, admin, "user", u, "created", u.username)
|
||||
commit(db, "Пользователь с таким логином уже существует")
|
||||
flush(db) # изменение 033, находка №10: логин уже проверен выше, гонку покрывает CONFLICT_MSG
|
||||
audit(db, user, "user", u, "created", u.username)
|
||||
commit(db)
|
||||
return u
|
||||
|
||||
|
||||
@router.patch("/users/{id}", response_model=s.UserOut)
|
||||
def update_user(id: int, body: s.UserUpdate, db: Session = Depends(get_db), admin: User = Depends(admin_user)):
|
||||
def update_user(id: int, body: s.UserUpdate, db: Session = Depends(get_db), user: User = Depends(superadmin_user)): # изменение 032
|
||||
data = body.model_dump(exclude_unset=True, exclude_none=True)
|
||||
if "role" in data or "is_active" in data:
|
||||
_lock_admins(db) # до чтения пользователя и подсчёта администраторов
|
||||
_lock_users(db) # до чтения пользователя и подсчёта суперадминистраторов (изменение 032)
|
||||
u = get_or_404(db, User, id, "Пользователь")
|
||||
if u.id == admin.id and "password" in data:
|
||||
if u.id == user.id and "password" in data:
|
||||
raise HTTPException(422, "Свой пароль меняется через /users/me/password (с подтверждением текущего)")
|
||||
pwd = data.pop("password", None)
|
||||
role, is_active = data.get("role", u.role), data.get("is_active", u.is_active)
|
||||
loses_admin = u.role == Role.admin and u.is_active and (role != Role.admin or not is_active)
|
||||
if u.id == admin.id and (role != Role.admin or not is_active):
|
||||
loses_superadmin = u.role == Role.superadmin and u.is_active and (role != Role.superadmin or not is_active) # изменение 032
|
||||
if u.id == user.id and (role != Role.superadmin or not is_active): # изменение 032
|
||||
raise HTTPException(409, "Нельзя отключить или понизить свою учётную запись")
|
||||
if loses_admin and _other_active_admins(db, u.id) == 0:
|
||||
raise HTTPException(409, "Нельзя отключить или понизить единственного активного администратора")
|
||||
if loses_superadmin and _other_active_superadmins(db, u.id) == 0: # изменение 032
|
||||
raise HTTPException(409, "Нельзя отключить или понизить единственного активного суперадминистратора")
|
||||
# реконсиляция «роль — организация» по итоговому состоянию, до apply_update (изменение 033, находка №3)
|
||||
org_id_passed = "organization_id" in data # exclude_none=True выше уже отбросил явный null
|
||||
if role == Role.superadmin:
|
||||
if org_id_passed:
|
||||
raise HTTPException(422, "Суперадминистратор не привязан к организации")
|
||||
data["organization_id"] = None # повышение снимает организацию само
|
||||
else:
|
||||
org_f = data.get("organization_id", u.organization_id)
|
||||
if org_f is None and (is_active or "role" in data or org_id_passed):
|
||||
raise HTTPException(422, "Администратор и просмотрщик должны быть привязаны к организации")
|
||||
if org_id_passed:
|
||||
get_or_404(db, Organization, data["organization_id"], "Организация")
|
||||
changed = apply_update(u, data)
|
||||
if pwd:
|
||||
u.password_hash = hash_password(pwd)
|
||||
u.password_changed_at = datetime.now(timezone.utc)
|
||||
if changed:
|
||||
audit(db, admin, "user", u, "updated", u.username, changed)
|
||||
audit(db, user, "user", u, "updated", u.username, changed)
|
||||
if pwd:
|
||||
audit(db, admin, "user", u, "password_reset", u.username, message=f"Пользователь {u.username}: пароль изменён администратором")
|
||||
audit(db, user, "user", u, "password_reset", u.username, message=f"Пользователь {u.username}: пароль изменён администратором")
|
||||
commit(db)
|
||||
return u
|
||||
|
||||
|
||||
@router.delete("/users/{id}", status_code=204)
|
||||
def delete_user(id: int, db: Session = Depends(get_db), admin: User = Depends(admin_user)):
|
||||
_lock_admins(db)
|
||||
def delete_user(id: int, db: Session = Depends(get_db), user: User = Depends(superadmin_user)): # изменение 032
|
||||
_lock_users(db)
|
||||
u = get_or_404(db, User, id, "Пользователь")
|
||||
if u.id == admin.id:
|
||||
refuse_delete(db, admin, "user", u, u.username, "Нельзя удалить свою учётную запись")
|
||||
if u.role == Role.admin and u.is_active and _other_active_admins(db, u.id) == 0:
|
||||
refuse_delete(db, admin, "user", u, u.username, "Нельзя удалить единственного активного администратора")
|
||||
audit(db, admin, "user", u, "deleted", u.username)
|
||||
if u.id == user.id:
|
||||
refuse_delete(db, user, "user", u, u.username, "Нельзя удалить свою учётную запись")
|
||||
if u.role == Role.superadmin and u.is_active and _other_active_superadmins(db, u.id) == 0: # изменение 032
|
||||
refuse_delete(db, user, "user", u, u.username, "Нельзя удалить единственного активного суперадминистратора")
|
||||
audit(db, user, "user", u, "deleted", u.username)
|
||||
db.delete(u)
|
||||
commit(db)
|
||||
|
||||
|
||||
+1
-1
@@ -28,7 +28,7 @@ def seed():
|
||||
with SessionLocal() as db:
|
||||
if not db.scalar(select(User.id).limit(1)):
|
||||
if settings.admin_password:
|
||||
db.add(User(username=settings.admin_username, password_hash=hash_password(settings.admin_password), role=Role.admin))
|
||||
db.add(User(username=settings.admin_username, password_hash=hash_password(settings.admin_password), role=Role.superadmin, organization_id=None)) # изменение 032: role=superadmin, organization_id=None
|
||||
else:
|
||||
log.warning("В БД нет пользователей и ADMIN_PASSWORD не задан: администратор не создан, войти в UI нельзя")
|
||||
if not db.scalar(select(DeviceType.id).limit(1)):
|
||||
|
||||
+8
-1
@@ -3,7 +3,7 @@ import uuid
|
||||
from datetime import datetime
|
||||
|
||||
from sqlalchemy import (
|
||||
BigInteger, Boolean, DateTime, Enum, ForeignKey, ForeignKeyConstraint, Index, Integer, String, Text,
|
||||
BigInteger, Boolean, CheckConstraint, DateTime, Enum, ForeignKey, ForeignKeyConstraint, Index, Integer, String, Text,
|
||||
UniqueConstraint, func, text,
|
||||
)
|
||||
from sqlalchemy.dialects.postgresql import CIDR, INET, JSONB, UUID
|
||||
@@ -27,6 +27,7 @@ class AddressStatus(str, enum.Enum):
|
||||
|
||||
|
||||
class Role(str, enum.Enum):
|
||||
superadmin = "superadmin"
|
||||
admin = "admin"
|
||||
viewer = "viewer"
|
||||
|
||||
@@ -137,10 +138,15 @@ class Address(Base):
|
||||
|
||||
class User(Base):
|
||||
__tablename__ = "users"
|
||||
__table_args__ = (
|
||||
# изменение 032: superadmin без организации, admin/viewer с организацией или неактивны
|
||||
CheckConstraint("is_active = false OR (role = 'superadmin') = (organization_id IS NULL)", name="ck_users_role_org_scope"), # изменение 033, находка №13
|
||||
)
|
||||
id: Mapped[int] = mapped_column(primary_key=True)
|
||||
username: Mapped[str] = mapped_column(String(100), unique=True)
|
||||
password_hash: Mapped[str] = mapped_column(String(255))
|
||||
role: Mapped[Role] = mapped_column(Enum(Role, name="user_role"), default=Role.viewer)
|
||||
organization_id: Mapped[int | None] = mapped_column(ForeignKey("organizations.id"), index=True) # изменение 032: NK для admin/viewer, NULL для superadmin
|
||||
is_active: Mapped[bool] = mapped_column(Boolean, default=True)
|
||||
password_changed_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True)) # токены с iat раньше — недействительны
|
||||
|
||||
@@ -162,6 +168,7 @@ class AuditLog(Base):
|
||||
message: Mapped[str] = mapped_column(Text, default="", server_default="")
|
||||
client_ip: Mapped[str | None] = mapped_column(INET, index=True) # IP клиента запроса; NULL для системных событий
|
||||
meta: Mapped[dict | None] = mapped_column(JSONB) # user_agent, method, path, request_id
|
||||
organization_id: Mapped[int | None] = mapped_column(ForeignKey("organizations.id", ondelete="SET NULL"), index=True) # изменение 032: для событий по сущностям организации, NULL для системных; ondelete — изменение 033, находка №1
|
||||
|
||||
__table_args__ = (Index("ix_audit_log_entity_type_action", "entity_type", "action"),)
|
||||
|
||||
|
||||
+15
-1
@@ -3,7 +3,7 @@ import re
|
||||
from datetime import datetime
|
||||
from typing import Annotated, Generic, TypeVar
|
||||
|
||||
from pydantic import AfterValidator, BaseModel, ConfigDict, EmailStr, Field, field_validator
|
||||
from pydantic import AfterValidator, BaseModel, ConfigDict, EmailStr, Field, field_validator, model_validator
|
||||
|
||||
from app.models import AddressStatus, PrefixStatus, Role
|
||||
|
||||
@@ -86,6 +86,7 @@ class UserOut(ORM):
|
||||
id: int
|
||||
username: str
|
||||
role: Role
|
||||
organization_id: int | None = None # изменение 032
|
||||
is_active: bool = True
|
||||
|
||||
|
||||
@@ -93,13 +94,26 @@ class UserIn(BaseModel):
|
||||
username: Login
|
||||
password: str = Field(min_length=8, max_length=128)
|
||||
role: Role = Role.viewer # наименьшие права по умолчанию (изменение 016)
|
||||
organization_id: int | None = None # изменение 032: обязателен для admin/viewer, запрещён для superadmin
|
||||
is_active: bool = True
|
||||
|
||||
@model_validator(mode="after")
|
||||
def _org_by_role(self) -> "UserIn":
|
||||
# model_validator (изменение 033, находка №2): срабатывает и когда organization_id не передан явно
|
||||
if self.role == Role.superadmin and self.organization_id is not None:
|
||||
raise ValueError("Суперадминистратор не привязан к организации")
|
||||
if self.role != Role.superadmin and self.organization_id is None:
|
||||
raise ValueError("Администратор и просмотрщик должны быть привязаны к организации")
|
||||
return self
|
||||
|
||||
|
||||
class UserUpdate(BaseModel):
|
||||
role: Role | None = None
|
||||
organization_id: int | None = None # изменение 032
|
||||
is_active: bool | None = None
|
||||
password: str | None = Field(None, min_length=8, max_length=128)
|
||||
# без валидатора роль/организация (изменение 033, находка №2): без текущего состояния записи
|
||||
# инвариант не проверить — реконсиляция по итоговому состоянию в update_user (находка №3)
|
||||
|
||||
|
||||
class PasswordChange(BaseModel):
|
||||
|
||||
+9
-1
@@ -63,6 +63,14 @@ def current_user(
|
||||
|
||||
|
||||
def admin_user(user: User = Depends(current_user)) -> User:
|
||||
if user.role != Role.admin:
|
||||
# изменение 032: admin_user проверяет роль != viewer (пропускает admin И superadmin)
|
||||
if user.role == Role.viewer:
|
||||
raise HTTPException(403, "Недостаточно прав")
|
||||
return user
|
||||
|
||||
|
||||
def superadmin_user(user: User = Depends(current_user)) -> User:
|
||||
# изменение 032: строгая проверка на superadmin
|
||||
if user.role != Role.superadmin:
|
||||
raise HTTPException(403, "Недостаточно прав")
|
||||
return user
|
||||
+36
-7
@@ -8,7 +8,7 @@ from sqlalchemy.exc import IntegrityError
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.request_context import request_meta
|
||||
from app.models import Address, AuditLog, Base
|
||||
from app.models import Address, AuditLog, Base, Role
|
||||
|
||||
MAX_CAPACITY = 2**53 - 1
|
||||
MAX_OFFSET = 10_000_000 # верхняя граница offset во всех списках
|
||||
@@ -49,18 +49,20 @@ def actor_of(username: str) -> str:
|
||||
return username if username in ("system", "anonymous") else f"ui:{username}"
|
||||
|
||||
|
||||
def audit(db: Session, user, entity_type: str, entity, action: str, label: str, diff: dict | None = None, message: str | None = None):
|
||||
def audit(db: Session, user, entity_type: str, entity, action: str, label: str, diff: dict | None = None, message: str | None = None, organization_id: int | None = None):
|
||||
# изменение 032: organization_id передаётся явно; None для системных событий и событий пользователя
|
||||
ctx = None if user is SYSTEM else request_meta.get() # системные события (ротация) — без IP, даже если запущены из запроса
|
||||
db.add(AuditLog(
|
||||
username=user.username, entity_type=entity_type, entity_id=getattr(entity, "id", None),
|
||||
entity_label=label, action=action, diff=diff,
|
||||
message=message or make_message(entity_type, action, label, diff),
|
||||
client_ip=ctx["client_ip"] if ctx else None, meta=ctx["meta"] if ctx else None,
|
||||
organization_id=organization_id,
|
||||
))
|
||||
|
||||
|
||||
BLOCKERS_LIMIT = 20
|
||||
_GROUPS = {"prefixes": "префиксы", "devices": "устройства", "isps": "операторы", "addresses": "адреса"}
|
||||
_GROUPS = {"prefixes": "префиксы", "devices": "устройства", "isps": "операторы", "addresses": "адреса", "users": "пользователи"} # изменение 032
|
||||
|
||||
|
||||
def blockers(db: Session, labels) -> dict | None:
|
||||
@@ -69,13 +71,14 @@ def blockers(db: Session, labels) -> dict | None:
|
||||
return {"total": total, "items": list(db.scalars(labels.limit(BLOCKERS_LIMIT)))} if total else None
|
||||
|
||||
|
||||
def refuse_delete(db: Session, user, entity_type: str, entity, label: str, reason: str, blocked_by: dict | None = None):
|
||||
"""Отказ в удалении (409): фиксируем предупреждение в журнале (`<entity>.delete_blocked`) и отвечаем прежним текстом `reason`."""
|
||||
def refuse_delete(db: Session, user, entity_type: str, entity, label: str, reason: str, blocked_by: dict | None = None, organization_id: int | None = None):
|
||||
"""Отказ в удалении (409): фиксируем предупреждение в журнале (`<entity>.delete_blocked`) и отвечаем прежним текстом `reason`.
|
||||
`organization_id` (изменение 033, находка №4) — чтобы запись видел админ организации, а не только суперадминистратор."""
|
||||
blocked_by = {k: v for k, v in (blocked_by or {}).items() if v}
|
||||
diff = {"reason": reason, **({"blocked_by": blocked_by} if blocked_by else {})}
|
||||
noun = _NOUNS.get(entity_type, (entity_type, "m"))[0]
|
||||
tail = ("связанные объекты (" + ", ".join(f"{_GROUPS[k]}: {v['total']}" for k, v in blocked_by.items()) + ")") if blocked_by else reason[:1].lower() + reason[1:]
|
||||
audit(db, user, entity_type, entity, "delete_blocked", label, diff, message=f"{noun} {label}: удаление отклонено — {tail}")
|
||||
audit(db, user, entity_type, entity, "delete_blocked", label, diff, message=f"{noun} {label}: удаление отклонено — {tail}", organization_id=organization_id)
|
||||
commit(db) # к этому моменту в транзакции только запись журнала
|
||||
raise HTTPException(409, reason)
|
||||
|
||||
@@ -100,10 +103,21 @@ def flush(db: Session, conflict_msg: str = CONFLICT_MSG):
|
||||
raise HTTPException(409, conflict_msg)
|
||||
|
||||
|
||||
# изменение 033 (попутно): согласование рода в тексте 404 — по первому слову `what`
|
||||
_FEMININE_FIRST_WORDS = {"Организация", "Запись"}
|
||||
_NEUTER_FIRST_WORDS = {"Устройство"}
|
||||
|
||||
|
||||
def not_found(what: str) -> str:
|
||||
first = what.split(" ", 1)[0]
|
||||
suffix = "а" if first in _FEMININE_FIRST_WORDS else "о" if first in _NEUTER_FIRST_WORDS else ""
|
||||
return f"{what} не найден{suffix}"
|
||||
|
||||
|
||||
def get_or_404(db: Session, model: type[Base], id_: int, what: str = "Объект"):
|
||||
obj = db.get(model, id_)
|
||||
if obj is None:
|
||||
raise HTTPException(404, f"{what} не найден")
|
||||
raise HTTPException(404, not_found(what))
|
||||
return obj
|
||||
|
||||
|
||||
@@ -208,3 +222,18 @@ def next_free_address(net, occupied: list[tuple[int, int]]) -> str | None:
|
||||
break
|
||||
cand = end + 1
|
||||
return str(type(net.network_address)(cand)) if cand <= last else None
|
||||
|
||||
|
||||
def require_org(user, organization_id: int | None, what: str):
|
||||
"""изменение 032: проверка доступа к организации. superadmin пропускает, иначе 404 при несовпадении."""
|
||||
if user.role == Role.superadmin:
|
||||
return
|
||||
if user.organization_id != organization_id:
|
||||
raise HTTPException(404, not_found(what))
|
||||
|
||||
|
||||
def scope_org(stmt, column, user):
|
||||
"""изменение 032: фильтр списков по организации. superadmin без фильтра, иначе фильтр по user.organization_id."""
|
||||
if user.role == Role.superadmin:
|
||||
return stmt
|
||||
return stmt.where(column == user.organization_id)
|
||||
@@ -0,0 +1,43 @@
|
||||
# Сужение раскрытия API и HTTP-экспозиции (изменение 031)
|
||||
|
||||
Источник: `docs/reviews/2026-09-27-pentest.md`, находки № 1 (открытый Swagger) и № 2 (HTTP на `0.0.0.0` без TLS).
|
||||
Обе — про периметр: приложение по чёрному ящику устойчиво, но раскрывает лишнее. Ниже — только код и конфигурация.
|
||||
|
||||
## Часть 1 — Swagger и OpenAPI по умолчанию выключены (находка № 1)
|
||||
**Проблема:** `/docs`, `/redoc`, `/openapi.json` открыты анонимно (проверено на стенде: карта из 28 эндпоинтов без авторизации). `SecurityHeadersMiddleware` уже знает о `DOCS_PATHS`, но сами маршруты создаёт FastAPI по умолчанию.
|
||||
|
||||
**Решение:**
|
||||
1. `app/config.py`: `docs_enabled: bool = False` в `Settings` (управляется `DOCS_ENABLED` в `.env`).
|
||||
2. `app/main.py`: при создании `FastAPI(...)` — `docs_url`, `redoc_url`, `openapi_url` = `None`, если `not settings.docs_enabled`. Тогда маршрутов просто нет (404), а не «спрятаны». `DOCS_PATHS` в middleware оставить: при `docs_enabled=true` заголовки на них по-прежнему не навешиваются.
|
||||
3. `.env.example`: строка `# DOCS_ENABLED=true — открыть Swagger/OpenAPI (по умолчанию выключено)`.
|
||||
4. `README.md`: в «Быстром старте» отметить, что Swagger включается `DOCS_ENABLED=true`; для разработки его можно держать включённым.
|
||||
|
||||
**Проверка:** при пустом `.env` `/docs` и `/openapi.json` → 404; при `DOCS_ENABLED=true` → 200. UI и API не затронуты.
|
||||
|
||||
## Часть 2 — предупреждение о небезопасной экспозиции + готовый TLS-профиль (находка № 2)
|
||||
**Проблема:** приложение слушает `0.0.0.0:8088` по HTTP, токены и пароли идут по LAN открытым текстом. Рекомендация из изменения 022 (reverse-proxy + `APP_BIND=127.0.0.1` + `TRUSTED_PROXIES`) документирована, но на стенде не задействована. Смена `APP_BIND` по умолчанию отключит текущий доступ к стенду `192.168.5.9:8088` — **делается только по решению пользователя** (см. ниже).
|
||||
|
||||
**Решение (без смены умолчаний):**
|
||||
1. `app/main.py` рядом с `validate_secrets()`: функция-предупреждение при старте (`logging.warning`, не отказ), если одновременно `APP_BIND` не `127.0.0.1` и `TRUSTED_PROXIES` пуст — «приложение принимает прямые HTTP-соединения из сети без TLS; поставьте reverse-proxy и задайте APP_BIND=127.0.0.1 (README «Публикация»)». `APP_BIND` читать через `Settings` (добавить поле `app_bind: str = "0.0.0.0"`, только для этой проверки; фактическую привязку по-прежнему делает compose).
|
||||
2. Готовый reverse-proxy как отдельный опциональный compose-профиль `tls` (`docker-compose.yml`): сервис `caddy` (`caddy:2-alpine`) с `Caddyfile`, проксирующий на `app:8000`, том для сертификатов. Профиль не поднимается по умолчанию (`profiles: ["tls"]`), поэтому текущий стенд не меняется.
|
||||
3. `Caddyfile` в корне (шаблон): домен из переменной, `reverse_proxy app:8000`, автоматический TLS. Комментарий, что при внутреннем домене нужен `tls internal` или свой сертификат.
|
||||
4. `.env.example`: `# APP_BIND=127.0.0.1` и `# TRUSTED_PROXIES=<сеть Docker прокси>` — с пояснением связки.
|
||||
5. `README.md`, «Публикация»: заменить текстовый пример на запуск профиля `docker compose --profile tls up -d` и настройку `Caddyfile`.
|
||||
|
||||
**Требует решения пользователя (в план внесено, но не выполняется без ответа):**
|
||||
- Менять ли `APP_BIND` по умолчанию на `127.0.0.1`. Плюс — приложение перестаёт напрямую торчать в LAN; минус — текущий доступ к стенду по `192.168.5.9:8088` пропадёт до подъёма прокси. По умолчанию план оставляет `0.0.0.0` и только предупреждает в логе.
|
||||
|
||||
## Файлы
|
||||
`app/config.py`, `app/main.py`, `docker-compose.yml`, `Caddyfile` (новый), `.env.example`, `README.md`, `docs/changes/031-expose-hardening/SUMMARY.md`.
|
||||
|
||||
## Вне объёма
|
||||
- Аутентификация на Swagger вместо выключения (сложнее: `/openapi.json` нужен UI-докам; при `docs_enabled=false` проблема снята полностью).
|
||||
- Реальные сертификаты и DNS — эксплуатационная настройка, не код.
|
||||
- Находка № 3 пентеста (дефолтный логин `admin`) — организационная, отдельной доработкой не оформляется.
|
||||
|
||||
## Проверка (выполняет ревьюер)
|
||||
1. `venv/bin/python -c 'import app.main'`; пересборка стенда, `app` healthy.
|
||||
2. `/docs`, `/openapi.json` → 404 при выключенном флаге, 200 при `DOCS_ENABLED=true`.
|
||||
3. В логе старта — предупреждение о прямой HTTP-экспозиции при текущих `APP_BIND`/`TRUSTED_PROXIES`.
|
||||
4. `docker compose config --profile tls` валиден; профиль `tls` не поднимается без флага.
|
||||
5. `pytest -q` — регрессия зелёная (эндпоинты не менялись).
|
||||
@@ -0,0 +1,104 @@
|
||||
# Ролевая модель с привязкой к организации и суперадминистратором (изменение 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`.
|
||||
@@ -0,0 +1,60 @@
|
||||
# Итог: ролевая модель с привязкой к организации и суперадминистратором (изменение 032)
|
||||
|
||||
План: `docs/changes/032-role-model-org-scope/PLAN.md`. Ревью реализации — `docs/reviews/2026-09-27-changes-032-review.md`
|
||||
(находки № 1–8) и `docs/reviews/2026-09-27-codebase-review.md` (находки № 9–15); все они исправлены в изменении 033
|
||||
(`docs/changes/033-review-fixes-032/SUMMARY.md`) — этот файл описывает исходную реализацию 032 в части модели данных
|
||||
и объёма прав, без деталей самих находок ревью.
|
||||
|
||||
## Модель данных
|
||||
- `app/models.py`: `Role.superadmin`; `User.organization_id` (nullable, `FK organizations.id`, индекс) с
|
||||
`CheckConstraint("is_active = false OR (role = 'superadmin') = (organization_id IS NULL)")` (имя `ck_users_role_org_scope` в модели — с 033);
|
||||
`AuditLog.organization_id` (nullable, индекс, `FK organizations.id`).
|
||||
- Миграции: `0010_user_role_superadmin.py` (значение enum `superadmin` через `autocommit_block()`),
|
||||
`0011_users_organization_scope.py` (колонка `users.organization_id`, backfill ролей, CHECK после backfill),
|
||||
`0012_audit_log_organization.py` (колонка `audit_log.organization_id`, backfill по `entity_id` для `organization`/`vrf`/`device`/`isp`/`prefix`;
|
||||
для удалённых сущностей и системных типов (`user`/`journal`/`session`/`device_type`) остаётся `NULL` — видно только `superadmin`).
|
||||
- Backfill ролей (решение 4 плана): бывшие `admin` → `superadmin`, `organization_id=NULL`; бывшие `viewer` →
|
||||
`organization_id=NULL`, `is_active=false` (заблокированы до явного назначения организации `superadmin`).
|
||||
- Удаление организации (`delete_org`) получило блокиратор `"users"` — организация с привязанными пользователями не удаляется.
|
||||
|
||||
## Права доступа
|
||||
Реализовано по сводной таблице плана: `superadmin` управляет пользователями, организациями (создание/удаление) и
|
||||
типами устройств (запись); `admin`/`viewer` работают только в своей организации (VRF, префиксы, адреса, устройства,
|
||||
операторы, карточка организации, журнал, «Обзор»); типы устройств читают все роли.
|
||||
|
||||
- `app/security.py`: `admin_user` теперь пропускает роль ≠ `viewer` (то есть `admin` и `superadmin`); новая зависимость
|
||||
`superadmin_user` — строго `Role.superadmin`.
|
||||
- `app/services.py`: хелперы `require_org(user, organization_id, what)` (404 при чужой организации, `superadmin` — без
|
||||
ограничений) и `scope_org(stmt, column, user)` (фильтр списков); `audit()` получил параметр
|
||||
`organization_id` для простановки на журнальных записях (`refuse_delete()` — с 033).
|
||||
- `app/api/v1/users.py` — все маршруты на `superadmin_user`; инвариант «нельзя обезвредить последнего активного
|
||||
администратора» переведён на `superadmin`.
|
||||
- `app/api/v1/refs.py`, `app/api/v1/prefixes.py`, `app/api/v1/journal.py`, `app/api/v1/overview.py` — `require_org`/`scope_org`
|
||||
во всех точках чтения и записи, разделение прав на организации/типы устройств/журнал по сводной таблице.
|
||||
- `app/main.py`: `seed()` создаёт пользователя-бутстрап с `role=Role.superadmin`, `organization_id=None`.
|
||||
|
||||
## UI (`web/app.js`, `web/styles.css`)
|
||||
- `ROLE_RU` дополнен «Суперадминистратор»; `isAdmin()` = роль ≠ `viewer`, новая `isSuperadmin()`.
|
||||
- Пункт навигации «Пользователи» — только `isSuperadmin()`. `orgSwitcher()` интерактивен только для `superadmin`; для
|
||||
`admin`/`viewer` — статичное название их организации.
|
||||
- Экран «Организации»: «Добавить организацию» и «Удалить» — только `superadmin`.
|
||||
- Экран «Пользователи»: диалог создания/правки получил выбор организации (скрывается для роли «Суперадминистратор»).
|
||||
- Диалог типов устройств: управление — только `superadmin`, список виден всем.
|
||||
|
||||
## Отклонения от плана
|
||||
Без отклонений в объёме прав и модели данных. Первый проход реализации содержал несколько дефектов (сломанное
|
||||
удаление организации, невалидируемый инвариант «роль — организация» при отсутствии поля в теле запроса, нерабочая
|
||||
смена роли на/с `superadmin`, невидимость `*.delete_blocked` в журнале организации, оракулы существования чужих
|
||||
объектов, глобальные счётчики типов устройств и мелкие UI-огрехи) — все исправлены отдельным циклом, изменение 033.
|
||||
|
||||
## Проверено
|
||||
- `import app.main`, `node --check web/app.js` — чисто.
|
||||
- Миграции 0010–0012 применяются на стенде; `alembic check` без расхождений (после 033 — включая 0013).
|
||||
- Изоляция чтения/записи по организациям, роли `admin`/`viewer`/`superadmin`, управление типами устройств и
|
||||
пользователями — проверено на стенде (см. протокол сценариев в `docs/reviews/2026-09-27-changes-032-review.md`
|
||||
и итоговые проверки в `docs/changes/033-review-fixes-032/SUMMARY.md`).
|
||||
|
||||
## Не проверено
|
||||
- Работоспособность 032 отдельно от 033 — не актуально (033 доводит 032 до готовности, коммитятся вместе).
|
||||
- Полная историческая реконструкция `organization_id` в записях журнала для уже удалённых на момент миграции сущностей —
|
||||
сознательно не выполнялась (план, раздел «Вне объёма»).
|
||||
@@ -0,0 +1,134 @@
|
||||
# Исправление находок ревью изменения 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` виден админу организации в журнале.
|
||||
@@ -0,0 +1,77 @@
|
||||
# Итог: исправление находок ревью изменения 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, не проверено визуально в браузере.
|
||||
@@ -0,0 +1,150 @@
|
||||
# Ревью и тестирование изменения 032 — 2026-09-27
|
||||
|
||||
**Объём:** реализация `docs/changes/032-role-model-org-scope/PLAN.md` исполнителем на Haiku 4.5 (агент `ae8bd63b5568cb6ee`), незакоммиченные изменения
|
||||
поверх `5210ba3`. 12 изменённых файлов, 3 новые миграции.
|
||||
**Метод:** построчное чтение diff по каждому файлу, затем сквозные сценарии на стенде `ipam_control_006` (API напрямую + прямые SQL-запросы для диагностики),
|
||||
затем регрессия `pytest`. Все находки ниже воспроизведены, не гипотетичны. Временные данные удалены.
|
||||
|
||||
## Итог
|
||||
|
||||
Модель прав (`require_org`/`scope_org`, разделение ролей по роутерам) реализована **широко и в целом корректно** — это подтверждено 16 из 24 сценариев ниже.
|
||||
Но найдено **три серьёзных бага**, один из которых — **полная поломка удаления организаций** (регрессия ранее стабильной функции, изменение 007) — и они
|
||||
подтверждаются штатным regression-прогоном: `pytest` даёт **3 упавших теста из 14**, и все три — прямое проявление находок №1–3.
|
||||
|
||||
**Рекомендация: не коммитить как есть.** Нужен ещё один проход правок (аналогично циклам 024/030 в этой же сессии) на находки №1–3 минимум; №4 желательно
|
||||
в том же заходе, №5–8 — по желанию.
|
||||
|
||||
| # | Серьёзность | Файл(ы) | Кратко |
|
||||
|---|---|---|---|
|
||||
| 1 | **Критическая** | `alembic/versions/0012_audit_log_organization.py`, `app/models.py` | `audit_log.organization_id → organizations.id` без `ON DELETE SET NULL` — удаление организации сломано полностью |
|
||||
| 2 | **Высокая** | `app/schemas.py` | Кросс-полевая проверка `organization_id` в `UserIn`/`UserUpdate` через `@field_validator` не срабатывает, если поле просто не передано в теле запроса |
|
||||
| 3 | **Высокая** | `app/api/v1/users.py`, `web/app.js` | Повышение до `superadmin` / понижение с `superadmin` не работает при естественном вызове (без ручной синхронизации `organization_id`) |
|
||||
| 4 | Средняя | `app/services.py` (`refuse_delete`) | Записи `*.delete_blocked` всегда `organization_id=NULL` — невидимы для админа организации в его собственном журнале |
|
||||
| 5 | Низкая | `web/app.js` | Кнопка «Добавить устройство»: мёртвый код (`disabled` не поддерживается `btn()`), логика инвертирована по смыслу, хотя фактически не влияет |
|
||||
| 6 | Низкая | `web/app.js`, `web/styles.css` | Бейдж роли `superadmin` использует класс `purple`, которого нет в CSS — рендерится без цвета |
|
||||
| 7 | Инфо | `app/services.py` | `require_org`/`scope_org` делают `from app.models import Role` локально, хотя модуль уже импортирует из `app.models` на верхнем уровне |
|
||||
| 8 | Низкая | `web/app.js` (`userDialog`) | Поле выбора организации не реагирует на смену роли в открытом диалоге (видимость считается по старой роли записи) |
|
||||
|
||||
---
|
||||
|
||||
## Находки
|
||||
|
||||
### 1. Удаление организации сломано полностью ✅ КРИТИЧНО
|
||||
**Где:** `alembic/versions/0012_audit_log_organization.py` — `op.create_foreign_key(..., "audit_log", "organizations", ["organization_id"], ["id"])`
|
||||
без `ondelete`; то же в `app/models.py` — `AuditLog.organization_id: Mapped[int | None] = mapped_column(ForeignKey("organizations.id"), index=True)`.
|
||||
|
||||
**Механизм:** `create_org` пишет `audit(..., organization_id=o.id)` при создании организации — то есть у *любой* организации сразу же появляется
|
||||
ссылающаяся строка в `audit_log`. FK без `ON DELETE SET NULL` (по умолчанию `NO ACTION`/`RESTRICT`) не даёт удалить организацию, пока существует
|
||||
хоть одна такая запись — а она существует всегда.
|
||||
|
||||
**Воспроизведение (минимальное):** создать организацию → сразу `DELETE /organizations/{id}` → 409 «Конфликт с существующими данными…»
|
||||
(общий обработчик `IntegrityError`, без списка блокираторов — проверка `blockers()` до этого момента их не нашла, ошибка на уровне самого `DELETE`).
|
||||
В логе PostgreSQL: `violates foreign key constraint "fk_audit_log_organization_id_organizations" ... Key (id)=(442) is still referenced from table "audit_log"`.
|
||||
|
||||
**Подтверждено регрессией:** `pytest tests/test_api.py::test_delete_organization` падает именно на этом.
|
||||
|
||||
**Исправление:** `ondelete="SET NULL"` у FK — и в модели, и в миграции (`op.create_foreign_key(..., ondelete="SET NULL")`). Это согласуется с уже принятым
|
||||
для этой колонки принципом «история удалённых сущностей остаётся, `organization_id` уходит в `NULL`» — тем же, что применён к `entity_id`
|
||||
(без FK вообще) и описан в самом плане 032 для backfill.
|
||||
|
||||
### 2. Валидатор `organization_id` не срабатывает при отсутствии поля в теле запроса ✅ ВЫСОКАЯ
|
||||
**Где:** `app/schemas.py`, `UserIn`/`UserUpdate` — `@field_validator("organization_id")`.
|
||||
|
||||
**Причина (проверено экспериментально на минимальном примере с pydantic 2.13):** `@field_validator` **не вызывается**, если поле отсутствует
|
||||
в переданных данных и используется его значение по умолчанию (`None`) — только если оно передано явно (в том числе `null`). Это штатное поведение
|
||||
Pydantic v2, не баг библиотеки, но означает, что кросс-полевая проверка «admin/viewer обязаны иметь организацию» реально работает только тогда,
|
||||
когда клиент явно прислал `"organization_id": null` — а не когда просто не указал ключ (обычный способ «не задавать поле» в JSON API).
|
||||
|
||||
**Последствие:** `POST /users {"username": "...", "password": "...", "role": "admin"}` (без ключа `organization_id`) не даёт ожидаемых 422.
|
||||
Запрос доходит до `db.add()`/`flush()`, ловится `CheckConstraint` в БД → `IntegrityError` → попадает в блок `flush(db, "Пользователь с таким
|
||||
логином уже существует")`, который **любую** `IntegrityError` подписывает этим текстом. Клиент получает 409 «Пользователь с таким логином уже
|
||||
существует» для **несуществующего** логина — вводящее в заблуждение сообщение, маскирующее реальную причину.
|
||||
|
||||
**Подтверждено регрессией:** `pytest tests/test_users.py::test_users_management` падает ровно на этом первом же вызове
|
||||
(`client.post("/users", json={"username": name, "password": "start-pass-123", "role": "viewer"})` — без `organization_id`).
|
||||
Также `pytest tests/test_journal.py::test_clear_requires_password_and_locks_out` падает с `CheckViolation` на прямой SQL-вставке — показывает,
|
||||
что БД-ограничение единственная линия защиты и она долетает как сырая ошибка там, где путь не идёт через API вообще.
|
||||
|
||||
**Исправление:** заменить `@field_validator("organization_id")` на `@model_validator(mode="after")` в обеих схемах — он выполняется всегда,
|
||||
по итоговому состоянию модели, независимо от того, какие поля были явно переданы.
|
||||
|
||||
### 3. Смена роли на/с `superadmin` не работает при обычном вызове ✅ ВЫСОКАЯ
|
||||
**Где:** `app/api/v1/users.py::update_user`; сопутствующий пробел в `web/app.js::userDialog`.
|
||||
|
||||
**Воспроизведено дважды, в обе стороны:**
|
||||
- Повышение: `PATCH /users/{id} {"role": "superadmin"}` (без `organization_id`) на существующем `admin` с заполненной организацией → 409
|
||||
«Конфликт с существующими данными…» (тот же `CheckConstraint`, `organization_id` в БД не очищен). Роль не меняется, пользователь остаётся как был.
|
||||
- Понижение: `PATCH /users/{id} {"role": "admin"}` (без `organization_id`) на существующем `superadmin` → тот же 409 (`organization_id` не проставлен,
|
||||
остаётся `NULL`, что нарушает ограничение уже для роли `admin`).
|
||||
- Контрольный, «правильный» вызов с явным `organization_id` в теле — проходит (200). То есть путь существует, но требует от клиента знания
|
||||
о внутреннем ограничении БД, которое нигде не задокументировано и не проверяется на уровне API до `commit()`.
|
||||
|
||||
**Причина:** `update_user` строит `data = body.model_dump(exclude_unset=True, exclude_none=True)` и применяет её через `apply_update` без
|
||||
дополнительной реконсиляции: если `role` меняется на/с `superadmin`, а `organization_id` в этом же запросе не передан, старое значение поля
|
||||
остаётся нетронутым — и это ровно то состояние, которое ограничение в БД запрещает (кроме случая `is_active=false`, который здесь не применим).
|
||||
|
||||
**В UI это дополнительно усугублено:** `userDialog` при создании нового пользователя корректно исключает `organization_id` из тела, если выбрана
|
||||
роль `superadmin` (`if (v.role !== "superadmin" && v.organization_id) body.organization_id = ...`), но **при редактировании существующего**
|
||||
такой же проверки нет (`if (v.organization_id) body.organization_id = Number(v.organization_id);` — без учёта роли). Хуже того, поле-селектор
|
||||
организации в форме скрывается по **прежней** роли записи (`u?.role === "superadmin"`), а не по текущему выбору в открытом select — так что
|
||||
даже если оператор переключит роль на «Суперадминистратор» в уже открытом диалоге, поле организации останется видимым со старым значением
|
||||
и уйдёт в тело запроса. Через UI сейчас **невозможно** повысить существующего администратора организации до суперадминистратора.
|
||||
|
||||
**Исправление:** в `update_user` — при вычислении финальной роли принудительно приводить `organization_id`: если итоговая роль `superadmin`,
|
||||
записывать `u.organization_id = None` независимо от того, что пришло в запросе; если роль уходит от `superadmin` и `organization_id` не передан
|
||||
явно — требовать его (422 с понятным текстом), а не полагаться на молчаливое сохранение старого (в данном случае отсутствующего) значения.
|
||||
В `web/app.js` — тот же guard `v.role !== "superadmin"` в ветке `edit`, и видимость поля организации должна реагировать на текущее значение
|
||||
селектора роли в форме, а не на исходную роль записи.
|
||||
|
||||
### 4. Отказы в удалении не попадают в журнал организации ✅ СРЕДНЯЯ
|
||||
**Где:** `app/services.py::refuse_delete` — не тронута агентом; по-прежнему вызывает `audit(...)` без `organization_id`, и ни один из пяти call-site'ов
|
||||
(`refs.py` ×4, `prefixes.py` ×1, `users.py` ×2) не передаёт его.
|
||||
|
||||
**Воспроизведено:** `admin` организации A пытается удалить используемый VRF своей же организации → корректно получает 409 (бизнес-правило работает).
|
||||
Но `GET /audit?event_type=vrf.delete_blocked` от его же имени возвращает `{"total": 0}` — событие существует (видно суперадминистратору), но
|
||||
`organization_id = NULL`, и `scope_org` его отфильтровывает. Админ организации не видит в своём журнале собственные отклонённые попытки удаления.
|
||||
|
||||
**Исправление:** добавить `organization_id: int | None = None` в сигнатуру `refuse_delete` и передавать его на всех вызовах (там же, где рядом
|
||||
уже передаётся `organization_id` в соседние вызовы `audit()` для тех же сущностей — паттерн уже есть в `prefixes.py`/`refs.py` для успешных операций).
|
||||
|
||||
### 5–8. Мелкие (UI/стиль) 🔎
|
||||
- **№5:** `screens.devices` — `btn(..., { disabled: true })`; функция `btn()` (`web/app.js`) не поддерживает ключ `disabled` — он молча игнорируется.
|
||||
Ветвление `isSuperadmin() ? btn(...) : btn(..., {disabled:true})` фактически рендерит один и тот же (рабочий) элемент в обеих ветках, поэтому
|
||||
функционально org-admin по-прежнему может добавлять устройства (это подтверждено сценарием), но код вводит в заблуждение и должен быть упрощён
|
||||
до безусловного `btn(...)`, как было раньше — устройства создаёт любой `admin` своей организации, не только `superadmin`.
|
||||
- **№6:** `badge(u.role === "superadmin" ? "purple" : ...)` — в `web/styles.css` определены только `.badge.green/blue/red/amber`; `purple` рендерится
|
||||
без модификатора (стандартный серый бейдж), роль суперадминистратора визуально не выделяется.
|
||||
- **№7:** `require_org`/`scope_org` (`app/services.py`) делают `from app.models import Role` внутри тела функции при каждом вызове, хотя модуль уже
|
||||
импортирует из `app.models` на верхнем уровне (`Address, AuditLog, Base`) — нет циркулярной причины для локального импорта, чисто стилистически.
|
||||
- **№8:** см. разбор в находке №3 — видимость поля организации в `userDialog` не реактивна к живому выбору роли в форме.
|
||||
|
||||
---
|
||||
|
||||
## Что проверено и работает корректно ✅
|
||||
Двадцать четыре сценария на стенде (организации/VRF/префиксы/устройства/операторы/типы устройств/пользователи/журнал/обзор), из них 16 без замечаний:
|
||||
- Бутстрап-администратор из `.env` после миграций стал `superadmin` с `organization_id=NULL`.
|
||||
- Изоляция чтения: `admin`/`viewer` организации видят только свою организацию в списках и по прямому `id` (404 на чужую, без утечки существования);
|
||||
явный `organization_id` в query-параметрах чужой организации — тоже 404, не пустой список (осознанное и разумное отличие от плана, не баг).
|
||||
- Запись в своей организации: префиксы, устройства — создаются; в чужой — 404.
|
||||
- `viewer` читает, любая попытка записи — 403.
|
||||
- Типы устройств: читают все роли; создание/правка/удаление — только `superadmin` (403 для `admin` организации).
|
||||
- Управление пользователями, `/journal/settings`, `/journal/clear` — только `superadmin` (403 для остальных).
|
||||
- «Обзор» — счётчики и `recent_changes` корректно урезаны до организации вызывающего (когда организация вообще видна в журнале — см. находку №4).
|
||||
- Блокиратор `"users"` при удалении организации с привязанными пользователями — корректно собирается и полностью виден в `diff.blocked_by` записи
|
||||
журнала (со списком логинов и `total`) — сама механика `refuse_delete`/`blockers` для этой новой группы работает без замечаний.
|
||||
- `alembic check` — «No new upgrade operations detected», миграции 0010–0012 применяются на стенде без ручного вмешательства.
|
||||
- `import app.main`, `node --check web/app.js` — чисто.
|
||||
|
||||
## Регрессия (`pytest -q`)
|
||||
```
|
||||
3 failed, 11 passed in 8.32s
|
||||
FAILED tests/test_api.py::test_delete_organization — находка №1
|
||||
FAILED tests/test_journal.py::test_clear_requires_password_and_locks_out — находка №2 (CheckViolation на прямой SQL-вставке без organization_id)
|
||||
FAILED tests/test_users.py::test_users_management — находка №2
|
||||
```
|
||||
Все три падения — прямое проявление находок №1–2, не случайные/посторонние сбои. Обновление самих тестов под новую модель (добавление
|
||||
`organization_id` в фикстуры пользователей и т. п.) — отдельный, последующий шаг после того, как находки №1–3 будут исправлены в коде.
|
||||
|
||||
## Рекомендация
|
||||
Исправить находки №1–3 (и желательно №4) отдельным заходом — по аналогии с циклами 024/030 в этой сессии: план → агент на правки кода →
|
||||
повторное ревью и тесты. До этого изменение 032 не готово к коммиту.
|
||||
@@ -0,0 +1,93 @@
|
||||
# Ревью кодовой базы — 2026-09-27
|
||||
|
||||
**Объём:** рабочее дерево поверх `5210ba3` (незакоммиченные изменения 031–032, миграции 0010–0012).
|
||||
**Метод:** сверка с `docs/reviews/2026-09-27-changes-032-review.md` → построчное чтение `app/`, `alembic/versions/0010–0012`, diff `web/app.js` →
|
||||
воспроизведение на стенде `ipam_control_006` (API + SQL) → `pytest`. Временные данные удалены.
|
||||
|
||||
## Итог
|
||||
Находки ревью 032 **не исправлены, все подтверждены повторно** (`pytest`: 3 failed / 11 passed — те же три теста).
|
||||
Собственное ревью добавляет **одну уязвимость изоляции организаций** (№ 9, подтверждена на стенде) и несколько мелких замечаний.
|
||||
Изменение 031 существует только как план — в коде не реализовано.
|
||||
|
||||
**Рекомендация:** не коммитить 032 до исправления № 1–4 и № 9.
|
||||
|
||||
## Статус находок ревью 032
|
||||
|
||||
| # | Серьёзность | Статус | Проверка |
|
||||
|---|---|---|---|
|
||||
| 1 | Критическая | Не исправлено | FK `audit_log.organization_id` без `ON DELETE SET NULL` (модель + 0012). Уточнение: одного `SET NULL` по существующим строкам недостаточно — `delete_org` сам пишет `audit(..., organization_id=id)` в той же транзакции перед `DELETE`; на стенде даже после ручного обнуления — 409. Нужен именно `ondelete="SET NULL"` |
|
||||
| 2 | Высокая | Не исправлено | `UserIn(role="admin")` без ключа `organization_id` проходит валидацию (проверено в venv); явный `null` — отклоняется |
|
||||
| 3 | Высокая | Не исправлено | `update_user` не реконсилирует `organization_id` при смене роли |
|
||||
| 4 | Средняя | Не исправлено | `refuse_delete` без `organization_id` |
|
||||
| 5–8 | Низкая/инфо | Не исправлено | Без изменений |
|
||||
|
||||
Побочный эффект № 1: на стенде накоплено **18 организаций `test-*`** — фикстура `org` не может их удалить. После исправления — почистить.
|
||||
|
||||
## Новые находки
|
||||
|
||||
| # | Серьёзность | Файл | Кратко |
|
||||
|---|---|---|---|
|
||||
| 9 | **Высокая** | `app/api/v1/prefixes.py:286` | `GET /prefixes/{id}/subnets/next` без `require_org` — чтение чужой организации |
|
||||
| 10 | Средняя | `app/api/v1/users.py`, `app/services.py` | Несуществующий `organization_id` / нарушение CHECK → 409 «логин уже существует» или обезличенный 409 |
|
||||
| 11 | Низкая | `prefixes.py:241,355`, `refs.py:354-355` | Различимые ответы 404/422 раскрывают существование объектов чужой организации |
|
||||
| 12 | Низкая | `app/api/v1/refs.py:174` | `/device-types` отдаёт `devices_count` по всем организациям |
|
||||
| 13 | Низкая | `app/models.py`, `0011` | `CheckConstraint` в модели без имени, в миграции — `ck_users_role_org_scope` |
|
||||
| 14 | Инфо | `app/api/v1/journal.py` | Админ организации видит IP и User-Agent суперадминистратора в событиях своей организации |
|
||||
| 15 | Инфо | `README.md` | Устаревшие/неточные формулировки |
|
||||
|
||||
### 9. Утечка данных чужой организации через предпросмотр подсети — ВЫСОКАЯ
|
||||
`preview_subnet` не принимает `user` и не вызывает `require_org` — единственный GET по `id` префикса, пропущенный в 032.
|
||||
**Воспроизведено:** `viewer` организации B: `GET /prefixes/{id_A}` → 404 (изоляция), `GET /prefixes/{id_A}/subnets/next?length=24` →
|
||||
**200** `{"prefix":"10.77.0.0/24","length_min":17,"length_max":32}`; несуществующий id → 404.
|
||||
Раскрывается: существование префикса, его длина (`length_min − 1`), занятость (перебором `length` восстанавливается раскладка подсетей).
|
||||
**Исправление:** `user: User = Depends(current_user)` + `require_org(user, p.organization_id, "Префикс")`.
|
||||
|
||||
### 10. Ошибки целостности маскируются под «логин уже существует»
|
||||
`create_user` оборачивает `flush/commit` текстом «Пользователь с таким логином уже существует» — им же подписываются нарушение FK
|
||||
(`organization_id` несуществующей организации) и CHECK (№ 2). `update_user` при тех же причинах отдаёт общий `CONFLICT_MSG`. Отдельно: повторная
|
||||
активация мигрированного `viewer` (`organization_id=NULL`) через «Разрешить доступ» (в т. ч. групповое) → непонятный 409.
|
||||
**Исправление:** в `create_user`/`update_user` — `get_or_404(Organization, organization_id)` до записи; проверку инварианта роль/организация делать
|
||||
по итоговому состоянию (роль, организация, активность) с 422 и понятным текстом — это же закрывает № 2–3.
|
||||
|
||||
### 11. Оракул существования объектов чужой организации
|
||||
Для чужих объектов ответ отличается от несуществующих: `create_prefix` — 422 «VRF принадлежит другой организации» против 404;
|
||||
`_check_device` — 422 против 404; `update_isp` — `get_or_404(Organization)` до `require_org` (разные тексты 404). Также `create_prefix`
|
||||
берёт advisory-lock чужого VRF до `require_org`. Противоречит принципу 032 «404, не подтверждать существование».
|
||||
**Исправление:** для не-`superadmin` отвечать 404 при чужой организации (или вызывать `require_org` по организации объекта до остальных проверок).
|
||||
|
||||
### 12. Глобальные счётчики типов устройств
|
||||
`list_types` считает устройства по всем организациям — `admin`/`viewer` видит объём чужих данных (на стенде видно `devices_count=1` у типа без
|
||||
устройств своей организации). Решение 2 плана относится к справочнику, не к счётчикам. **Исправление:** `scope_org` в подсчёте `_type_outs`.
|
||||
|
||||
### 13. Имя CHECK-ограничения расходится
|
||||
Модель: `CheckConstraint(...)` без `name`; миграция: `ck_users_role_org_scope`. `alembic check` CHECK не сравнивает, поэтому расхождение не видно,
|
||||
но `create_all` (и любая будущая автогенерация/drop) получит другое имя. **Исправление:** `name="ck_users_role_org_scope"` в модели.
|
||||
|
||||
### 14. Метаданные запросов суперадминистратора в журнале организации
|
||||
События, выполненные `superadmin` в организации, несут `client_ip` и `meta` (User-Agent, путь) и видны админу/просмотрщику этой организации.
|
||||
Не баг плана, но стоит решить осознанно (например, скрывать `client_ip/meta` чужих акторов для не-`superadmin`).
|
||||
|
||||
### 15. README
|
||||
- Строка 135: «Пользователи» (только admin) → только `superadmin`.
|
||||
- Строка 96: «защищена от отключения/удаления при наличии других активных superadmin» — смысл обратный: защищён *последний* активный.
|
||||
- Нет раздела о 031 (Swagger, TLS-профиль) — ожидаемо, изменение не реализовано.
|
||||
|
||||
## Прочее
|
||||
- **031:** в `docs/changes/031-expose-hardening/` только `PLAN.md`; `docs_enabled`, `app_bind`, `Caddyfile`, профиль `tls` отсутствуют — находки пентеста № 1–2 открыты.
|
||||
- **Артефакты 032:** нет итогового файла (суммаризации) — ожидаемо до завершения цикла правок.
|
||||
- **Тесты:** после исправлений обновить `test_users` (явный `organization_id`) и прямую SQL-вставку в `test_journal`; добавить один тест
|
||||
изоляции (viewer чужой организации → 404 на `GET /prefixes/{id}` и `/subnets/next`) — закрывает № 9 от регрессии.
|
||||
|
||||
## Что проверено без замечаний
|
||||
- Все мутации (`VRF`, префиксы, адреса, устройства, операторы, организации) проверяют организацию до изменения; `*Update`-схемы не принимают
|
||||
`organization_id` — перенос объектов между организациями невозможен; перенос префикса между VRF проверяет организацию целевого VRF.
|
||||
- `current_user` читает роль и организацию из БД на каждый запрос — смена роли/организации действует сразу, без перевыпуска токена.
|
||||
- Журнал: `list/summary/facets/get` фильтруются по организации; `settings/clear` — только `superadmin`.
|
||||
- UI: `loadOrgs` сбрасывает сохранённую в `localStorage` организацию, если она недоступна пользователю.
|
||||
- Миграции 0010 (enum в `autocommit_block`) и 0011 (CHECK после backfill) — корректный порядок.
|
||||
|
||||
## Порядок исправлений
|
||||
1. № 1 (`ondelete="SET NULL"` в модели и новой миграции 0013 — 0012 уже применена на стенде), № 9.
|
||||
2. № 2, 3, 10 — единая проверка инварианта по итоговому состоянию в `users.py` + правка `userDialog` (№ 3, 8).
|
||||
3. № 4, 11, 12, 13.
|
||||
4. № 5–7, 15; обновление тестов; очистка организаций `test-*` на стенде.
|
||||
@@ -0,0 +1,41 @@
|
||||
# Пентест приложения (чёрный ящик) — 2026-09-27
|
||||
|
||||
**Цель:** `192.168.5.9:8088` (стенд `ipam_control_006`).
|
||||
**Условия игры:** известны только адрес и порт. Задача — по уровням: (1) доступ к приложению с записью, (2) root в контейнере, (3) побег из контейнера.
|
||||
**Метод:** внешняя разведка и проверка авторизации по чёрному ящику. Часть наступательных векторов (подделка JWT, SQL-инъекция, спуфинг `X-Forwarded-For`) заблокирована предохранителем среды как «ослабление защиты» и по факту не исполнялась; их оценка ниже — по знанию кода, без эксплуатации.
|
||||
|
||||
## Результат по уровням
|
||||
|
||||
**Уровень 1 — доступ с записью: НЕ получен.**
|
||||
**Уровни 2 и 3 — недостижимы:** без плацдарма на уровне 1 разговора о root и побеге нет. Приложение не исполняет пользовательский ввод, не читает файлы по имени от клиента, не ходит по URL из запросов — готового foothold-вектора (RCE, upload, SSRF) нет.
|
||||
|
||||
## Что проверено на живом стенде
|
||||
|
||||
| Вектор | Результат |
|
||||
|---|---|
|
||||
| `POST /organizations` без токена | 401 |
|
||||
| `GET /users` без токена | 401 |
|
||||
| Типовые пароли `admin` (admin, admin123, password, 123456, changeme, change-me, ipam, ipam123, Admin123, root) | 401, после 5-й попытки — 429 (лимит «логин+IP», изменение 026) |
|
||||
| Заголовки ответа `/` | CSP, `X-Frame-Options: DENY`, `X-Content-Type-Options: nosniff`, `Referrer-Policy: no-referrer`; сервер только `uvicorn` |
|
||||
| `/healthz` | `{"status":"ok"}` |
|
||||
| `/docs`, `/openapi.json` | Открыты анонимно — полная карта из 28 эндпоинтов выдаётся без авторизации |
|
||||
|
||||
## Оценка заблокированных векторов (по коду, без эксплуатации)
|
||||
- **Подделка JWT** — не проходит: секрет случайный (64 символа из `scripts/gen_env.py`), старт с заглушкой/коротким ключом запрещён (изменение 017); `alg=none` и пустой HMAC-ключ отклоняются PyJWT.
|
||||
- **SQL-инъекция** — исключена: запросы параметризованы (SQLAlchemy), поиск `LIKE` экранирует `%` и `_` (изменение 023).
|
||||
- **Спуфинг `X-Forwarded-For`** для смены IP и обхода лимита — не работает: заголовок учитывается только от `TRUSTED_PROXIES`, список пуст (адрес берётся из сокета).
|
||||
- **Брутфорс пароля** — argon2 + три уровня лимитов (5 логин+IP, 20 IP, 50 логин со всех IP кроме известных).
|
||||
- **Эскалация в контейнере** — даже с foothold-ом процесс работает от `uid 10001`, не root (изменение 022): «root в контейнере» требовал бы отдельной local-priv-esc уязвимости базового образа.
|
||||
|
||||
## Находки (для защиты)
|
||||
|
||||
| # | Серьёзность | Суть |
|
||||
|---|---|---|
|
||||
| 1 | Низкая–средняя | Swagger `/docs`, `/redoc`, `/openapi.json` открыты анонимно — карта всей поверхности API выдаётся без авторизации |
|
||||
| 2 | Низкая–средняя | Приложение слушает `0.0.0.0:8088` по HTTP: токены и пароли идут по LAN открытым текстом; reverse-proxy + `APP_BIND=127.0.0.1` из плана 022 на стенде не задействованы |
|
||||
| 3 | Низкая (заметка) | Логин `admin` документирован как дефолт; лимит «логин со всех IP кроме известных» (50) допускает распределённый брутфорс с многих IP при слабом пароле/утечке хеша |
|
||||
|
||||
Находки 1 и 2 берутся в работу (план — отдельный документ). Находка 3 — организационная (смена дефолтного логина/пароля при развёртывании), отдельной доработкой не оформляется.
|
||||
|
||||
## Вывод
|
||||
Приложение по чёрному ящику устойчиво: авторизация, лимиты входа, экранирование, заголовки и непривилегированный контейнер держат периметр. Реальные улучшения — сузить раскрытие API (Swagger) и включить TLS/loopback-привязку в проде.
|
||||
@@ -1,4 +1,6 @@
|
||||
import ipaddress
|
||||
import uuid
|
||||
|
||||
import httpx
|
||||
|
||||
from tests.conftest import BASE, ENV
|
||||
@@ -45,6 +47,22 @@ def test_in_use_objects_cannot_be_deleted(client, org):
|
||||
client.post("/devices", json={"name": "t-1.internal", "device_type_id": t["id"], "organization_id": org["id"]})
|
||||
assert client.delete(f"/device-types/{t['id']}").status_code == 409
|
||||
|
||||
# изменение 033, находка №4: отказ в удалении VRF попадает в журнал с organization_id и виден админу этой организации
|
||||
name, uid, admin = f"qa-{uuid.uuid4().hex[:8]}", None, None
|
||||
try:
|
||||
created = client.post("/users", json={"username": name, "password": "start-pass-123", "role": "admin", "organization_id": org["id"]})
|
||||
assert created.status_code == 201, created.text
|
||||
uid = created.json()["id"]
|
||||
admin = httpx.Client(base_url=BASE, timeout=30)
|
||||
r = admin.post("/auth/login", json={"username": name, "password": "start-pass-123"})
|
||||
admin.headers["Authorization"] = "Bearer " + r.json()["access_token"]
|
||||
assert admin.get("/audit", params={"event_type": "vrf.delete_blocked"}).json()["total"] >= 1
|
||||
finally:
|
||||
if admin is not None:
|
||||
admin.close()
|
||||
if uid is not None:
|
||||
client.delete(f"/users/{uid}")
|
||||
|
||||
|
||||
def test_delete_organization(client, org):
|
||||
_prefix(client, org, "10.206.0.0/24")
|
||||
@@ -77,6 +95,41 @@ def test_allocate_next_subnet(client, org):
|
||||
assert next_free_subnet("fd00::/64", 66, [(v6, v6 + 5)]) == "fd00::4000:0:0:0/66" # IPv6: первый блок занят, берётся второй
|
||||
|
||||
|
||||
def test_prefix_isolation_between_organizations(client, org):
|
||||
"""Изменение 033, находка №9: чужой организации не должен быть виден чужой префикс ни через GET, ни через предпросмотр подсети.
|
||||
Находка №11: admin чужой организации получает тот же 404 при попытке создать префикс в VRF организации A."""
|
||||
other = client.post("/organizations", json={"name": f"other-{org['name']}", "inn": "".join(reversed(org["inn"]))}).json()
|
||||
pid = _prefix(client, org, "10.208.0.0/24").json()["id"]
|
||||
name, uid, admin_b = f"qa-{uuid.uuid4().hex[:8]}", None, None
|
||||
try:
|
||||
created = client.post("/users", json={"username": name, "password": "start-pass-123", "role": "admin", "organization_id": other["id"]})
|
||||
assert created.status_code == 201, created.text
|
||||
uid = created.json()["id"]
|
||||
|
||||
admin_b = httpx.Client(base_url=BASE, timeout=30)
|
||||
r = admin_b.post("/auth/login", json={"username": name, "password": "start-pass-123"})
|
||||
assert r.status_code == 200
|
||||
admin_b.headers["Authorization"] = "Bearer " + r.json()["access_token"]
|
||||
|
||||
assert admin_b.get(f"/prefixes/{pid}").status_code == 404
|
||||
assert admin_b.get(f"/prefixes/{pid}/subnets/next", params={"length": 24}).status_code == 404
|
||||
# изменение 033, находка №11: чужой VRF в create_prefix — 404 (раньше было 422)
|
||||
cross = admin_b.post("/prefixes", json={"organization_id": other["id"], "vrf_id": org["vrf_id"], "prefix": "10.209.0.0/24"})
|
||||
assert cross.status_code == 404
|
||||
# изменение 033, находка №11: чужой parent_id в create_prefix — тоже 404 (раньше было 422)
|
||||
other_vrf_id = client.get("/vrfs", params={"organization_id": other["id"]}).json()["items"][0]["id"]
|
||||
cross_parent = admin_b.post("/prefixes", json={"organization_id": other["id"], "vrf_id": other_vrf_id, "prefix": "10.208.0.0/25", "parent_id": pid})
|
||||
assert cross_parent.status_code == 404
|
||||
finally:
|
||||
if admin_b is not None:
|
||||
admin_b.close()
|
||||
if uid is not None:
|
||||
client.delete(f"/users/{uid}")
|
||||
for v in client.get("/vrfs", params={"organization_id": other["id"]}).json()["items"]:
|
||||
client.delete(f"/vrfs/{v['id']}")
|
||||
client.delete(f"/organizations/{other['id']}")
|
||||
|
||||
|
||||
def test_vrf_name_unique_per_organization(client, org):
|
||||
other = client.post("/organizations", json={"name": f"other-{org['name']}", "inn": "".join(reversed(org["inn"]))}).json()
|
||||
try:
|
||||
|
||||
@@ -40,7 +40,7 @@ def test_rotation_by_age_and_count(client, db):
|
||||
|
||||
def test_clear_requires_password_and_locks_out(db):
|
||||
name, pw = f"tmp-{uuid.uuid4().hex[:6]}", "tmp-pass-123"
|
||||
db.execute("INSERT INTO users (username, password_hash, role, is_active) VALUES (%s, %s, 'admin', true)", (name, hash_password(pw)))
|
||||
db.execute("INSERT INTO users (username, password_hash, role, is_active) VALUES (%s, %s, 'superadmin', true)", (name, hash_password(pw))) # изменение 033: очистка журнала — только superadmin
|
||||
try:
|
||||
c = httpx.Client(base_url=BASE)
|
||||
c.headers["Authorization"] = "Bearer " + c.post("/auth/login", json={"username": name, "password": pw}).json()["access_token"]
|
||||
|
||||
+14
-4
@@ -17,23 +17,25 @@ def _client(username: str, password: str):
|
||||
return c
|
||||
|
||||
|
||||
def test_users_management(client):
|
||||
def test_users_management(client, org):
|
||||
name, uid, other = f"qa-{uuid.uuid4().hex[:8]}", None, None
|
||||
try:
|
||||
created = client.post("/users", json={"username": name, "password": "start-pass-123", "role": "viewer"})
|
||||
created = client.post("/users", json={"username": name, "password": "start-pass-123", "role": "viewer", "organization_id": org["id"]}) # изменение 033
|
||||
assert created.status_code == 201, created.text
|
||||
uid = created.json()["id"]
|
||||
assert created.json()["role"] == "viewer" and created.json()["is_active"] is True
|
||||
assert created.json()["organization_id"] == org["id"] # изменение 033
|
||||
|
||||
assert client.post("/users", json={"username": name.upper(), "password": "start-pass-123"}).status_code == 409 # логин занят без учёта регистра
|
||||
assert client.post("/users", json={"username": name.upper(), "password": "start-pass-123", "organization_id": org["id"]}).status_code == 409 # логин занят без учёта регистра
|
||||
assert client.post("/users", json={"username": "system", "password": "start-pass-123"}).status_code == 422 # служебный логин журнала
|
||||
assert client.post("/users", json={"username": "ab", "password": "start-pass-123"}).status_code == 422 # короткий логин
|
||||
assert client.post("/users", json={"username": "short-pw", "password": "123"}).status_code == 422 # короткий пароль
|
||||
assert client.post("/users", json={"username": f"qa-{uuid.uuid4().hex[:8]}", "password": "start-pass-123", "role": "admin"}).status_code == 422 # админ без организации (изменение 033, находка №2)
|
||||
|
||||
other = _client(name, "start-pass-123")
|
||||
assert other is not None
|
||||
assert other.get("/auth/me").json()["role"] == "viewer"
|
||||
assert other.get("/users").status_code == 403 # список пользователей только для админа
|
||||
assert other.get("/users").status_code == 403 # список пользователей только для суперадминистратора (изменение 032)
|
||||
assert other.post("/organizations", json={"name": "qa", "inn": "1234567890"}).status_code == 403 # роль «просмотр» — только чтение
|
||||
|
||||
# отключение действует немедленно, включая ранее выданный токен
|
||||
@@ -62,6 +64,14 @@ def test_users_management(client):
|
||||
assert client.delete(f"/users/{me['id']}").status_code == 409
|
||||
assert client.patch(f"/users/{me['id']}", json={"is_active": False}).status_code == 409
|
||||
assert client.patch(f"/users/{uid}", json={"role": "admin"}).json()["role"] == "admin"
|
||||
|
||||
# реконсиляция «роль — организация» по итоговому состоянию (изменение 033, находка №3)
|
||||
promoted = client.patch(f"/users/{uid}", json={"role": "superadmin"})
|
||||
assert promoted.status_code == 200 and promoted.json()["organization_id"] is None # повышение снимает организацию само
|
||||
assert client.patch(f"/users/{uid}", json={"role": "viewer"}).status_code == 422 # понижение без организации
|
||||
demoted = client.patch(f"/users/{uid}", json={"role": "viewer", "organization_id": org["id"]})
|
||||
assert demoted.status_code == 200 and demoted.json()["role"] == "viewer"
|
||||
|
||||
assert client.delete(f"/users/{uid}").status_code == 204
|
||||
uid = None
|
||||
assert client.get("/audit", params={"event_type": "user.created"}).json()["total"] >= 1
|
||||
|
||||
+50
-25
@@ -87,8 +87,9 @@ const iconBtn = (icon, action, label, data = "", sm = false, disabled = false) =
|
||||
const searchBox = (value, placeholder, width) =>
|
||||
`<label class="search" style="width:${width}px"><span class="ico">${I.search()}</span><input type="search" id="q" data-input="q" placeholder="${esc(placeholder)}" aria-label="${esc(placeholder)}" value="${esc(value)}"></label>`;
|
||||
const currentOrg = () => orgs.find((o) => o.id === store.orgId);
|
||||
const isAdmin = () => user?.role === "admin";
|
||||
const ROLE_RU = { admin: "Администратор", viewer: "Просмотр" };
|
||||
const isAdmin = () => user?.role !== "viewer"; // изменение 032: admin И superadmin
|
||||
const isSuperadmin = () => user?.role === "superadmin"; // изменение 032
|
||||
const ROLE_RU = { superadmin: "Суперадминистратор", admin: "Администратор", viewer: "Просмотр" }; // изменение 032
|
||||
|
||||
/* ------------------------------------------------ выбор строк и групповые операции (только admin) */
|
||||
const selSet = () => (S.sel ||= new Set());
|
||||
@@ -119,14 +120,18 @@ function filterBtn(id, label, items, value, width = 180) {
|
||||
}
|
||||
function orgSwitcher() {
|
||||
const o = currentOrg();
|
||||
return `<span class="rel" style="flex:none"><button type="button" class="org-btn" data-action="menu" data-menu="org">${I.org()}<span class="lbl">Организация:</span><span class="val">${esc(o?.name ?? "—")}</span>${I.chevD(14)}</button>${popMenu("org", orgs.map((x) => ({ label: x.name, value: x.id, cls: x.id === store.orgId ? "sel" : "" })))}</span><div class="vsep"></div>`;
|
||||
// изменение 032: интерактивный переключатель для superadmin, статичная подпись для других
|
||||
if (isSuperadmin()) {
|
||||
return `<span class="rel" style="flex:none"><button type="button" class="org-btn" data-action="menu" data-menu="org">${I.org()}<span class="lbl">Организация:</span><span class="val">${esc(o?.name ?? "—")}</span>${I.chevD(14)}</button>${popMenu("org", orgs.map((x) => ({ label: x.name, value: x.id, cls: x.id === store.orgId ? "sel" : "" })))}</span><div class="vsep"></div>`;
|
||||
}
|
||||
return `<span style="flex:none;display:flex;align-items:center;gap:8px;color:var(--muted);font-size:13px">${I.org()}<span>Организация: <b style="font-weight:600;color:var(--text)">${esc(o?.name ?? "—")}</b></span></span><div class="vsep"></div>`;
|
||||
}
|
||||
const crumbsOrg = () => `<div class="crumbs"><a href="#/orgs" style="color:var(--muted)">Организации</a>${I.chevR(14)}<span class="cur">${esc(currentOrg()?.name ?? "")}</span></div>`;
|
||||
const header = (title, sub, actions = "") => `<div class="head"><div><h1>${esc(title)}</h1><div class="sub">${sub}</div></div><div class="actions">${actions}</div></div>`;
|
||||
|
||||
const NAV = [["overview", "Обзор"], ["prefixes", "Префиксы"], ["orgs", "Организации"], ["isps", "Операторы"], ["devices", "Устройства"], ["journal", "Журнал"], ["users", "Пользователи", "admin"]];
|
||||
const NAV = [["overview", "Обзор"], ["prefixes", "Префиксы"], ["orgs", "Организации"], ["isps", "Операторы"], ["devices", "Устройства"], ["journal", "Журнал"], ["users", "Пользователи", "superadmin"]]; // изменение 032: "superadmin"
|
||||
function shell(active, content) {
|
||||
const nav = NAV.filter(([, , role]) => role !== "admin" || isAdmin()); // раздел «Пользователи» виден только администратору
|
||||
const nav = NAV.filter(([, , role]) => role !== "superadmin" || isSuperadmin()); // изменение 032: раздел «Пользователи» виден только суперадминистратору
|
||||
return `<div class="appbar"><div class="brand"><span class="brand-logo">${I.globe()}</span>ipam_manager</div>
|
||||
<nav class="nav">${nav.map(([k, l]) => `<a href="#/${k}" class="${k === active ? "active" : ""}">${l}</a>`).join("")}</nav>
|
||||
<div class="grow"></div><span class="user" title="${esc(ROLE_RU[user?.role] ?? "")}">${esc(user?.username ?? "")}</span>${btn("Сменить пароль", "pw-change", { cls: "ghost" })}${btn("Выйти", "logout", { cls: "ghost" })}</div>
|
||||
@@ -218,12 +223,17 @@ screens.orgs = async () => {
|
||||
const all = q ? (await api("/organizations", { params: { limit: 1 } })).total : total;
|
||||
setSelectable(items.map((o) => o.id));
|
||||
const cols = selCols("minmax(200px,1.5fr) 110px 130px minmax(200px,1.5fr) 80px 80px 44px");
|
||||
const rows = items.map((o, i) => `<div class="tr hoverable clickable${i === items.length - 1 ? " last" : ""}${selRow(o.id)}" data-row="1" data-action="org-open" data-id="${o.id}" style="--cols:${cols}">${selCell(o.id)}
|
||||
const rows = items.map((o, i) => {
|
||||
const items2 = [{ label: "Открыть префиксы", value: "open:" + o.id }, { label: "Редактировать", value: "edit:" + o.id }]; // изменение 032
|
||||
if (isSuperadmin()) items2.push({ label: "Удалить", value: "del:" + o.id, cls: "danger" }); // изменение 032: кнопка удаления только для superadmin
|
||||
return `<div class="tr hoverable clickable${i === items.length - 1 ? " last" : ""}${selRow(o.id)}" data-row="1" data-action="org-open" data-id="${o.id}" style="--cols:${cols}">${selCell(o.id)}
|
||||
<a href="#/prefixes" data-action="open-org" data-id="${o.id}" style="font-weight:500;color:var(--text)">${esc(o.name)}</a><span class="muted">${esc(o.short_name)}</span><span class="mono muted" style="font-size:13px">${esc(o.inn)}</span>
|
||||
<span class="muted ell">${esc(o.address)}</span><a href="#/prefixes" data-action="open-org" data-id="${o.id}" style="font:500 13px var(--sans);text-align:center">${o.prefixes_count}</a><span style="font-size:13px;text-align:center" class="muted">${fmtNum(o.addresses_count)}</span>
|
||||
<span class="cell-actions rel">${iconBtn(I.dots(), "menu", "Действия", `data-menu="org-row-${o.id}"`)}${popMenu("org-row-" + o.id, [{ label: "Открыть префиксы", value: "open:" + o.id }, { label: "Редактировать", value: "edit:" + o.id }, { label: "Удалить", value: "del:" + o.id, cls: "danger" }], "row-pop")}</span></div>`).join("");
|
||||
<span class="cell-actions rel">${iconBtn(I.dots(), "menu", "Действия", `data-menu="org-row-${o.id}"`)}${popMenu("org-row-" + o.id, items2, "row-pop")}</span></div>`; // изменение 032
|
||||
}).join("");
|
||||
S.rows = items;
|
||||
return shell("orgs", `${header("Организации", `${all} ${plural(all, "организация", "организации", "организаций")}`, btn("Добавить организацию", "org-new", { cls: "primary", icon: I.plus() }))}
|
||||
const addBtn = isSuperadmin() ? btn("Добавить организацию", "org-new", { cls: "primary", icon: I.plus() }) : ""; // изменение 032: кнопка только для superadmin
|
||||
return shell("orgs", `${header("Организации", `${all} ${plural(all, "организация", "организации", "организаций")}`, addBtn)}
|
||||
<div class="card"><div class="filters">${searchBox(q, "Поиск: название, ИНН, адрес", 300)}</div>
|
||||
${bulkBar()}<div class="tr th" style="--cols:${cols}">${selAllCell()}<span>Название</span><span>Краткое имя</span><span>ИНН</span><span>Адрес</span><span>Префиксов</span><span>Адресов</span><span></span></div>
|
||||
${rows || '<div class="empty">Ничего не найдено</div>'}<div class="foot">Показано ${items.length} из ${all} ${plural(all, "организации", "организаций", "организаций")}</div></div>`);
|
||||
@@ -306,8 +316,9 @@ screens.devices = async () => {
|
||||
<span class="${dep ? "" : "muted"}" style="${dep ? "color:var(--faint)" : ""}">${esc(d.device_type_name)}</span>${ips}<span class="ell" style="color:${dep ? "var(--faint)" : "var(--muted)"}">${esc(d.note)}</span>
|
||||
<span class="cell-actions rel">${iconBtn(I.dots(), "menu", "Действия", `data-menu="dev-row-${d.id}"`)}${popMenu("dev-row-" + d.id, [{ label: "Редактировать", value: "edit:" + d.id }, { label: "Удалить", value: "del:" + d.id, cls: "danger" }], "row-pop")}</span></div>`;
|
||||
}).join("");
|
||||
return shell("devices", `${crumbsOrg()}${header("Устройства", `${list.total} ${plural(list.total, "устройство", "устройства", "устройств")} · организация: ${esc(org?.name ?? "")}`, btn("Добавить устройство", "dev-new", { cls: "primary", icon: I.plus() }))}
|
||||
<div class="right-link"><a href="#" data-action="types">${I.gear()}Управление типами</a></div>
|
||||
const addBtn = btn("Добавить устройство", "dev-new", { cls: "primary", icon: I.plus() }); // изменение 033: устройство создаёт любой admin своей организации
|
||||
return shell("devices", `${crumbsOrg()}${header("Устройства", `${list.total} ${plural(list.total, "устройство", "устройства", "устройств")} · организация: ${esc(org?.name ?? "")}`, addBtn)}
|
||||
<div class="right-link"><a href="#" data-action="types">${I.gear()}Типы устройств</a></div>
|
||||
<div class="card"><div class="filters">${orgSwitcher()}${searchBox(q, "Поиск: имя, IP, заметка", 260)}${filterBtn("type", "Тип", typeItems, S.type ?? "", 170)}</div>
|
||||
${bulkBar(bulkMenu("bulk-type", "Сменить тип", types.items.map((t) => ({ label: t.name, value: t.id }))))}<div class="tr th" style="--cols:${cols}">${selAllCell()}<span>Устройство</span><span>Тип</span><span>IP-адресов</span><span>Заметки</span><span></span></div>
|
||||
${rows || '<div class="empty">Устройств нет</div>'}<div class="foot">Показано ${list.items.length} из ${list.total} ${plural(list.total, "устройства", "устройств", "устройств")}</div></div>`);
|
||||
@@ -341,18 +352,23 @@ async function typesDialog() {
|
||||
? `<input class="input" id="edit-name" value="${esc(t.name)}" style="height:32px">`
|
||||
: `<span style="font-weight:600;${t.is_default ? "color:var(--faint)" : ""}">${esc(t.name)}</span>`;
|
||||
const locked = t.devices_count > 0;
|
||||
const acts = t.is_default
|
||||
? `<span class="muted" style="font-size:12px">по умолчанию</span>`
|
||||
: editing
|
||||
? `${iconBtn(I.edit(14), "type-save", "Сохранить", `data-id="${t.id}"`, true)}${iconBtn(I.close(), "type-cancel", "Отмена", "", true)}`
|
||||
: `${iconBtn(I.edit(14), "type-edit", "Переименовать", `data-id="${t.id}"`, true)}${iconBtn(I.trash(14), "type-del", locked ? "Нельзя удалить: используется устройствами" : "Удалить", `data-id="${t.id}"`, true, locked)}`;
|
||||
let acts;
|
||||
if (!isSuperadmin()) { // изменение 032: управление только для superadmin
|
||||
acts = t.is_default ? `<span class="muted" style="font-size:12px">по умолчанию</span>` : "";
|
||||
} else {
|
||||
acts = t.is_default
|
||||
? `<span class="muted" style="font-size:12px">по умолчанию</span>`
|
||||
: editing
|
||||
? `${iconBtn(I.edit(14), "type-save", "Сохранить", `data-id="${t.id}"`, true)}${iconBtn(I.close(), "type-cancel", "Отмена", "", true)}`
|
||||
: `${iconBtn(I.edit(14), "type-edit", "Переименовать", `data-id="${t.id}"`, true)}${iconBtn(I.trash(14), "type-del", locked ? "Нельзя удалить: используется устройствами" : "Удалить", `data-id="${t.id}"`, true, locked)}`;
|
||||
}
|
||||
return `<div class="tr" style="--cols:${cols};--h:48px">${name}<span class="muted">${t.devices_count}</span><span style="display:flex;justify-content:flex-end;align-items:center;gap:4px">${acts}</span></div>`;
|
||||
}).join("");
|
||||
openDialog({
|
||||
title: "Управление типами устройств", width: 640,
|
||||
note: "Типы используются в фильтре и в поле «Тип» при добавлении устройства. Общий список для всех организаций.",
|
||||
note: "Типы используются в фильтре и в поле «Тип» при добавлении устройства. Общий список для всех организаций.", // изменение 032
|
||||
body: `<div class="mini-table"><div class="tr th" style="--cols:${cols}"><span>Название</span><span>Устройств</span><span></span></div>${rows}</div>
|
||||
<div class="add-row"><input class="input" id="new-type" placeholder="Название нового типа"><button type="button" class="btn outline-primary" data-action="type-add">${I.plus()}Добавить</button></div>`,
|
||||
${isSuperadmin() ? `<div class="add-row"><input class="input" id="new-type" placeholder="Название нового типа"><button type="button" class="btn outline-primary" data-action="type-add">${I.plus()}Добавить</button></div>` : ""}`, // изменение 032: кнопка только для superadmin
|
||||
foot: `<div class="dialog-foot">${btn("Готово", "close-dialog", { cls: "primary" })}</div>`,
|
||||
});
|
||||
}
|
||||
@@ -678,11 +694,11 @@ async function doClear() {
|
||||
|
||||
// ---- users
|
||||
screens.users = async () => {
|
||||
if (!isAdmin()) return shell("users", `${header("Пользователи", "Учётные записи UI")}<div class="card" style="margin-top:24px"><div class="empty">Раздел доступен только администратору</div></div>`);
|
||||
if (!isSuperadmin()) return shell("users", `${header("Пользователи", "Учётные записи UI")}<div class="card" style="margin-top:24px"><div class="empty">Раздел доступен только суперадминистратору</div></div>`); // изменение 032
|
||||
const q = S.q || "";
|
||||
const { items, total } = await api("/users", { params: { q, limit: 500 } });
|
||||
S.rows = items;
|
||||
const cols = selCols("minmax(200px,1.5fr) 190px 190px minmax(140px,1fr) 44px");
|
||||
const cols = selCols("minmax(200px,1.5fr) 190px minmax(180px,1fr) 190px minmax(140px,1fr) 44px"); // изменение 032: добавлен столбец для организации
|
||||
setSelectable(items.filter((u) => u.id !== user?.id).map((u) => u.id)); // свою запись выбрать нельзя
|
||||
const rows = items.map((u, n) => {
|
||||
const self = u.id === user?.id;
|
||||
@@ -691,39 +707,46 @@ screens.users = async () => {
|
||||
{ label: u.is_active ? "Отключить доступ" : "Разрешить доступ", value: "toggle:" + u.id, cls: u.is_active ? "danger" : "" },
|
||||
{ label: "Удалить", value: "del:" + u.id, cls: "danger" },
|
||||
];
|
||||
const orgName = orgs.find((o) => o.id === u.organization_id)?.name || "—"; // изменение 032
|
||||
return `<div class="tr hoverable clickable${n === items.length - 1 ? " last" : ""}${selRow(u.id)}" data-row="1" data-action="user-edit" data-id="${u.id}" style="--cols:${cols}">${selCell(u.id, self)}
|
||||
<span class="m13" style="font-weight:600">${esc(u.username)}${self ? ` <span class="muted" style="font:400 12px var(--sans)">это вы</span>` : ""}</span>
|
||||
<span>${badge(u.role === "admin" ? "blue" : "", ROLE_RU[u.role] || u.role)}</span>
|
||||
<span>${badge(u.role === "superadmin" ? "purple" : u.role === "admin" ? "blue" : "", ROLE_RU[u.role] || u.role)}</span>
|
||||
<span class="muted" style="font-size:13px">${u.role === "superadmin" ? "—" : esc(orgName)}</span>
|
||||
<span>${u.is_active ? badge("green", "Доступ разрешён") : badge("red", "Отключён")}</span>
|
||||
<span class="muted" style="font-size:13px">${self ? "свои роль и доступ менять нельзя" : ""}</span>
|
||||
<span class="cell-actions rel">${iconBtn(I.dots(), "menu", "Действия", `data-menu="user-row-${u.id}"`)}${popMenu("user-row-" + u.id, items2, "row-pop")}</span></div>`;
|
||||
}).join("");
|
||||
return shell("users", `${header("Пользователи", `${total} ${plural(total, "учётная запись", "учётные записи", "учётных записей")} · роли: администратор — запись, просмотр — чтение`, btn("Добавить пользователя", "users-new", { cls: "primary", icon: I.plus() }))}
|
||||
return shell("users", `${header("Пользователи", `${total} ${plural(total, "учётная запись", "учётные записи", "учётных записей")} · роли: суперадминистратор — полные права, администратор — запись в организации, просмотр — чтение`, btn("Добавить пользователя", "users-new", { cls: "primary", icon: I.plus() }))}
|
||||
<div class="card"><div class="filters">${searchBox(q, "Поиск по логину", 260)}</div>
|
||||
${bulkBar(btn("Разрешить доступ", "bulk-access", { data: 'data-value="1"' }) + btn("Отключить доступ", "bulk-access", { data: 'data-value="0"' }))}<div class="tr th" style="--cols:${cols};--h:40px">${selAllCell()}<span>Логин</span><span>Роль</span><span>Статус</span><span></span><span></span></div>
|
||||
${bulkBar(btn("Разрешить доступ", "bulk-access", { data: 'data-value="1"' }) + btn("Отключить доступ", "bulk-access", { data: 'data-value="0"' }))}<div class="tr th" style="--cols:${cols};--h:40px">${selAllCell()}<span>Логин</span><span>Роль</span><span>Организация</span><span>Статус</span><span></span><span></span></div>
|
||||
${rows || '<div class="empty">Пользователей нет</div>'}<div class="foot">Показано ${items.length} из ${total} ${plural(total, "записи", "записей", "записей")}</div></div>`);
|
||||
};
|
||||
function userDialog(u) {
|
||||
const edit = !!u;
|
||||
const self = edit && u.id === user?.id;
|
||||
const roleInit = u?.role ?? "viewer"; // изменение 033: начальная видимость поля организации — по роли в форме
|
||||
S.dialog = async (v) => {
|
||||
if (edit) {
|
||||
const body = { is_active: v.is_active === true };
|
||||
if (v.role) body.role = v.role;
|
||||
if (v.role !== "superadmin" && v.organization_id) body.organization_id = Number(v.organization_id); // изменение 033: одинаково с созданием — сервер сам очистит организацию при повышении
|
||||
if (v.password) body.password = v.password;
|
||||
await api(`/users/${u.id}`, { method: "PATCH", body });
|
||||
} else {
|
||||
await api("/users", { method: "POST", body: { username: v.username, password: v.password, role: v.role, is_active: v.is_active !== false } });
|
||||
const body = { username: v.username, password: v.password, role: v.role, is_active: v.is_active !== false }; // изменение 032
|
||||
if (v.role !== "superadmin" && v.organization_id) body.organization_id = Number(v.organization_id); // изменение 032: проверка перед присвоением
|
||||
await api("/users", { method: "POST", body });
|
||||
}
|
||||
toast(edit ? "Пользователь сохранён" : "Пользователь добавлен");
|
||||
await draw();
|
||||
};
|
||||
openDialog({
|
||||
title: edit ? `Пользователь ${u.username}` : "Новый пользователь",
|
||||
note: "«Администратор» может изменять данные, «Просмотр» — только читать их. Логин после создания не меняется.",
|
||||
note: "«Суперадминистратор» — полные права, «Администратор» — запись в организации, «Просмотр» — только чтение. Логин после создания не меняется.", // изменение 032
|
||||
body: formBody(`${edit ? `<label class="field"><span>Логин</span><input class="input" value="${esc(u.username)}" disabled></label>` : fInput("username", "Логин", { ph: "ivanov", hint: "латиница, цифры, . _ -" })}
|
||||
${self ? "" : fInput("password", edit ? "Новый пароль" : "Пароль", { type: "password", optional: edit, ph: edit ? "не менять" : "", hint: "минимум 8 символов" })}
|
||||
${self ? "" : fSelect("role", "Роль", [{ value: "admin", label: "Администратор" }, { value: "viewer", label: "Просмотр" }], u?.role ?? "viewer")}
|
||||
${self ? "" : fSelect("role", "Роль", [{ value: "superadmin", label: "Суперадминистратор" }, { value: "admin", label: "Администратор" }, { value: "viewer", label: "Просмотр" }], roleInit)}
|
||||
${self ? "" : `<div id="org-field-wrap" ${roleInit === "superadmin" ? "hidden" : ""}>${fSelect("organization_id", "Организация", orgs.map((o) => ({ value: o.id, label: o.name })), u?.organization_id ?? "")}</div>`}
|
||||
<label class="check"><input type="checkbox" name="is_active" ${edit ? (u.is_active ? "checked" : "") : "checked"} ${self ? "disabled" : ""}><span>Доступ разрешён<small>Отключённый пользователь не входит в UI; его текущий токен перестаёт действовать сразу</small></span></label>
|
||||
${self ? `<div class="info">Свою учётную запись нельзя понизить, отключить или удалить — пароль меняется кнопкой «Сменить пароль» в шапке.</div>` : ""}`),
|
||||
foot: dlgFoot(edit ? "Сохранить" : "Добавить пользователя"),
|
||||
@@ -926,6 +949,8 @@ const actions = {
|
||||
closeDropdowns();
|
||||
box.querySelector(".select").focus();
|
||||
input.dispatchEvent(new Event("change", { bubbles: true }));
|
||||
// изменение 033: живое переключение поля «Организация» при смене роли в диалоге пользователя
|
||||
if (S.dialog && input.name === "role") $("#org-field-wrap")?.toggleAttribute("hidden", d.value === "superadmin");
|
||||
},
|
||||
sel: (d, el) => { el.checked ? selSet().add(Number(d.id)) : selSet().delete(Number(d.id)); draw(); },
|
||||
"sel-all": (d, el) => { S.sel = new Set(el.checked ? S.selectable : []); draw(); },
|
||||
|
||||
@@ -100,6 +100,7 @@ svg{flex:none}
|
||||
.badge.blue{background:#eaf0ff;color:#1e449e}
|
||||
.badge.red{background:#fbe6e6;color:#a32020}
|
||||
.badge.amber{background:#fdf0d5;color:#7c4a00}
|
||||
.badge.purple{background:#efe8fb;color:#5b2a9e}
|
||||
.more{margin-left:4px;display:inline-flex;align-items:center;height:18px;padding:0 5px;border-radius:4px;background:#eaf0ff;color:#1e449e;font:500 11px/1 var(--sans)}
|
||||
|
||||
/* overview */
|
||||
|
||||
Reference in new issue
Block a user