Пентест (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>
240 lines
12 KiB
Python
240 lines
12 KiB
Python
import ipaddress
|
||
from datetime import timedelta
|
||
from types import SimpleNamespace
|
||
|
||
from fastapi import HTTPException
|
||
from sqlalchemy import func, select
|
||
from sqlalchemy.exc import IntegrityError
|
||
from sqlalchemy.orm import Session
|
||
|
||
from app.request_context import request_meta
|
||
from app.models import Address, AuditLog, Base, Role
|
||
|
||
MAX_CAPACITY = 2**53 - 1
|
||
MAX_OFFSET = 10_000_000 # верхняя граница offset во всех списках
|
||
|
||
# Политика входа (изменение 026): пороги перебора и срок «известного» IP. Вынесены сюда из app.api.v1.auth
|
||
# (изменение 030, ревью 025-029 находка №2) — app.rotation использует KNOWN_IP_DAYS и не должен зависеть от API-слоя.
|
||
LOGIN_WINDOW = timedelta(minutes=10)
|
||
MAX_PER_LOGIN_IP = 5 # логин + IP — блокирует эту пару
|
||
MAX_PER_IP = 20 # IP — блокирует любые логины с этого IP
|
||
MAX_PER_LOGIN = 50 # логин со всех IP, кроме известных — сигнал широкого перебора, не бьёт по легитимному пользователю
|
||
KNOWN_IP_DAYS = 30 # IP считается известным, если вход с него был успешен не позднее этого срока
|
||
|
||
|
||
SYSTEM = SimpleNamespace(username="system")
|
||
ANONYMOUS = SimpleNamespace(username="anonymous")
|
||
_NOUNS = {
|
||
"organization": ("Организация", "f"), "vrf": ("VRF", "m"), "prefix": ("Префикс", "m"), "address": ("Адрес", "m"),
|
||
"device": ("Устройство", "n"), "device_type": ("Тип устройства", "m"), "isp": ("Оператор", "m"),
|
||
"user": ("Пользователь", "m"),
|
||
}
|
||
_VERBS = {
|
||
"created": ("создан", "создана", "создано"), "updated": ("изменён", "изменена", "изменено"),
|
||
"deleted": ("удалён", "удалена", "удалено"), "assigned": ("назначен", "назначена", "назначено"),
|
||
}
|
||
|
||
|
||
def make_message(entity_type: str, action: str, label: str, diff: dict | None = None) -> str:
|
||
noun, gender = _NOUNS.get(entity_type, (entity_type, "m"))
|
||
verb = _VERBS.get(action, (action,) * 3)["mfn".index(gender)]
|
||
text = f"{noun} {label} {verb}"
|
||
if action == "updated" and diff:
|
||
text += ": " + ", ".join(diff)
|
||
return text
|
||
|
||
|
||
def actor_of(username: str) -> str:
|
||
"""Актор для журнала: служебные — как есть, пользователи UI — с префиксом ui:."""
|
||
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, 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": "адреса", "users": "пользователи"} # изменение 032
|
||
|
||
|
||
def blockers(db: Session, labels) -> dict | None:
|
||
"""Мешающие удалению объекты: `labels` — select одной текстовой колонки; {"total", "items"} (до BLOCKERS_LIMIT), None — если пусто."""
|
||
total = count(db, labels)
|
||
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, 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}", organization_id=organization_id)
|
||
commit(db) # к этому моменту в транзакции только запись журнала
|
||
raise HTTPException(409, reason)
|
||
|
||
|
||
CONFLICT_MSG = "Конфликт с существующими данными: проверьте уникальность значений и связанные объекты"
|
||
|
||
|
||
def commit(db: Session, conflict_msg: str = CONFLICT_MSG):
|
||
try:
|
||
db.commit()
|
||
except IntegrityError:
|
||
db.rollback()
|
||
raise HTTPException(409, conflict_msg)
|
||
|
||
|
||
def flush(db: Session, conflict_msg: str = CONFLICT_MSG):
|
||
"""flush с тем же переводом нарушений уникальности в 409, что и commit."""
|
||
try:
|
||
db.flush()
|
||
except IntegrityError:
|
||
db.rollback()
|
||
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, not_found(what))
|
||
return obj
|
||
|
||
|
||
def capacity(prefix: str) -> int:
|
||
net = ipaddress.ip_network(prefix)
|
||
n = net.num_addresses
|
||
if net.version == 4 and net.prefixlen <= 30:
|
||
n -= 2 # сеть и broadcast
|
||
return min(n, MAX_CAPACITY)
|
||
|
||
|
||
def utilization(used: int, cap: int) -> int:
|
||
return min(round(used * 100 / cap), 100) if cap else 0 # не больше 100 %, даже если в подсети остались адреса сети/broadcast
|
||
|
||
|
||
def network_role(net, ip) -> str | None:
|
||
""""network" / "broadcast" для IPv4 с длиной префикса ≤ 30 (как в capacity()), иначе None."""
|
||
if net.version == 4 and net.prefixlen <= 30:
|
||
if ip == net.network_address:
|
||
return "network"
|
||
if ip == net.broadcast_address:
|
||
return "broadcast"
|
||
return None
|
||
|
||
|
||
def apply_update(obj, data: dict) -> dict:
|
||
changed = {}
|
||
for k, v in data.items():
|
||
if getattr(obj, k) != v:
|
||
changed[k] = v
|
||
setattr(obj, k, v)
|
||
return changed
|
||
|
||
|
||
def like_escape(q: str) -> str:
|
||
return q.replace("\\", "\\\\").replace("%", "\\%").replace("_", "\\_")
|
||
|
||
|
||
def contains(column, q: str):
|
||
"""column ILIKE '%q%' с экранированием % и _ (иначе поиск «%» возвращает всё)."""
|
||
return column.ilike(f"%{like_escape(q.strip())}%", escape="\\")
|
||
|
||
|
||
def count(db: Session, stmt) -> int:
|
||
return db.scalar(select(func.count()).select_from(stmt.subquery())) or 0
|
||
|
||
|
||
def next_free_subnet(parent: str, length: int, occupied: list[tuple[int, int]]) -> str | None:
|
||
"""Первый свободный блок длины `length` внутри `parent`, выровненный по размеру блока.
|
||
`occupied` — занятые диапазоны адресов (включительно); без перебора всех подсетей: перескок за конец пересечения."""
|
||
net = ipaddress.ip_network(parent)
|
||
size = 1 << (net.max_prefixlen - length)
|
||
first, last = int(net.network_address), int(net.broadcast_address)
|
||
cand = first
|
||
for start, end in sorted(occupied):
|
||
if end < cand:
|
||
continue
|
||
if start >= cand + size:
|
||
break # блок cand..cand+size-1 свободен
|
||
cand = -(-(end + 1) // size) * size # за конец занятого диапазона, с выравниванием
|
||
if cand + size - 1 > last:
|
||
return None
|
||
return str(ipaddress.ip_network((type(net.network_address)(cand), length)))
|
||
|
||
|
||
def free_page(net, occupied: list[int], offset: int, limit: int) -> list[str]:
|
||
"""Страница свободных адресов сети: `occupied` — отсортированные занятые адреса (int). Пропуск `offset` — арифметикой по промежуткам,
|
||
без перебора адресов (безопасно для IPv6). Для IPv4 с длиной ≤ 30 адреса сети и broadcast не считаются."""
|
||
first, last = int(net.network_address), int(net.broadcast_address)
|
||
if net.version == 4 and net.prefixlen <= 30:
|
||
first, last = first + 1, last - 1
|
||
cls, out, skip, cur = type(net.network_address), [], offset, first
|
||
for ip in [*occupied, last + 1]:
|
||
if ip < cur:
|
||
continue
|
||
gap_end = min(ip - 1, last)
|
||
gap = gap_end - cur + 1
|
||
if gap > 0:
|
||
if skip >= gap:
|
||
skip -= gap
|
||
else:
|
||
start = cur + skip
|
||
skip = 0
|
||
out.extend(str(cls(a)) for a in range(start, min(gap_end, start + (limit - len(out)) - 1) + 1))
|
||
if len(out) >= limit:
|
||
break
|
||
cur = max(cur, ip + 1)
|
||
return out
|
||
|
||
|
||
def next_free_address(net, occupied: list[tuple[int, int]]) -> str | None:
|
||
"""Первый свободный адрес сети; `occupied` — занятые диапазоны (включительно), без перебора адресов.
|
||
Для IPv4 с длиной ≤ 30 адреса сети и broadcast не выдаются (как в capacity())."""
|
||
first, last = int(net.network_address), int(net.broadcast_address)
|
||
if net.version == 4 and net.prefixlen <= 30:
|
||
first, last = first + 1, last - 1
|
||
cand = first
|
||
for start, end in sorted(occupied):
|
||
if end < cand:
|
||
continue
|
||
if start > cand:
|
||
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)
|