Пентест (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>
77 lines
4.1 KiB
Python
77 lines
4.1 KiB
Python
from fastapi import APIRouter, Depends, Query
|
|
from pydantic import BaseModel
|
|
from sqlalchemy import and_, func, select
|
|
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, User, Vrf
|
|
from app.security import current_user
|
|
from app.services import capacity, scope_org, utilization # изменение 032
|
|
|
|
router = APIRouter(dependencies=[Depends(current_user)])
|
|
|
|
|
|
class Overview(BaseModel):
|
|
prefixes: int
|
|
vrfs: int
|
|
assigned: int
|
|
capacity: int
|
|
utilization: int
|
|
reserved: int
|
|
top_prefixes: list[s.PrefixOut]
|
|
recent_changes: list[s.AuditOut]
|
|
|
|
|
|
def _ipv4_roots(db: Session):
|
|
"""Активные IPv4-префиксы, не вложенные ни в один другой активный IPv4-префикс того же VRF (по CIDR, не по
|
|
parent_id — дерево может включать неактивные ветки). Основа ёмкости и загрузки «Обзора» (изменение 025, находка №1):
|
|
раньше ёмкость считалась суммой листьев, а назначенные адреса — по всем IPv4-адресам, включая нелистовые префиксы."""
|
|
q = Prefix.__table__.alias("q")
|
|
contained = (
|
|
select(1).select_from(q)
|
|
.where(q.c.vrf_id == Prefix.vrf_id, q.c.id != Prefix.id, q.c.status == PrefixStatus.active,
|
|
func.family(q.c.prefix) == 4, q.c.prefix.op(">>")(Prefix.prefix))
|
|
.exists()
|
|
)
|
|
return select(Prefix.vrf_id, Prefix.prefix).where(Prefix.status == PrefixStatus.active, func.family(Prefix.prefix) == 4, ~contained)
|
|
|
|
|
|
@router.get("/overview", response_model=Overview, tags=["overview"])
|
|
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")
|
|
counts = dict(db.execute(
|
|
select(Address.status, func.count(func.distinct(Address.id)))
|
|
.select_from(Address.__table__.join(roots_cte, and_(roots_cte.c.vrf_id == Address.vrf_id, Address.address.op("<<=")(roots_cte.c.prefix))))
|
|
.where(func.family(Address.address) == 4, Address.status.in_((AddressStatus.assigned, AddressStatus.reserved)))
|
|
.group_by(Address.status)
|
|
).all())
|
|
assigned = counts.get(AddressStatus.assigned, 0)
|
|
reserved = counts.get(AddressStatus.reserved, 0)
|
|
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(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],
|
|
)
|