Files
ipam_control/app/services.py
T
ayurishchevandClaude Opus 5.5 744a025960 Задачи 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>
2026-09-27 11:26:57 +03:00

240 lines
12 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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)