Files
ipam_control/app/api/v1/journal.py
T
ayurishchevandClaude Sonnet 5 a846d30872 IPAM Manager: API, UI-админка, журнал аудита
Backend (FastAPI, SQLAlchemy 2, Alembic, PostgreSQL 16):
- организации, VRF, префиксы (дерево, использование, автоназначение),
  адреса, операторы связи, устройства и типы устройств; JWT, роли admin/viewer;
- VRF принадлежит организации (составной FK), смена VRF у префикса
  переносит поддерево, имя VRF уникально в организации;
- журнал аудита: поиск и фильтры, ротация (срок/количество), очистка
  по паролю с блокировкой, IP клиента и метаданные запроса
  (X-Forwarded-For только от TRUSTED_PROXIES).

UI (web/, без сборки): экраны и диалоги по макетам «IPAM Manager»,
кликабельные строки реестров, локальные шрифты IBM Plex,
собственные выпадающие списки.

Окружение: docker-compose (postgres + app), миграции Alembic 0001-0004,
scripts/gen_env.py, scripts/seed_demo.py, 11 автотестов (pytest).
Документация: README.md и docs/changes/001-005 (планы и итоги).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-20 12:27:47 +03:00

169 lines
7.5 KiB
Python

"""Журнал: поиск и фильтры, запись, настройки ротации, очистка с подтверждением пароля."""
import ipaddress
import uuid
from datetime import date, datetime, time, timedelta, timezone
from fastapi import APIRouter, Depends, HTTPException, Query
from pydantic import BaseModel, Field
from sqlalchemy import String, cast, delete, func, or_, select
from sqlalchemy.orm import Session
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 audit, commit
router = APIRouter(dependencies=[Depends(current_user)], tags=["journal"])
MAX_ATTEMPTS = 5
LOCK_MINUTES = 10
def _escape_like(q: str) -> str:
return q.replace("\\", "\\\\").replace("%", "\\%").replace("_", "\\_")
def _filtered(stmt, event_type: str, entity_type: str, actor: str, date_from: date | None, date_to: date | None, q: str, client_ip: str = ""):
if event_type:
et, _, action = event_type.partition(".")
stmt = stmt.where(AuditLog.entity_type == et, AuditLog.action == action)
if entity_type:
stmt = stmt.where(AuditLog.entity_type == entity_type)
if actor:
stmt = stmt.where(AuditLog.username == actor.removeprefix("ui:"))
if client_ip.strip():
try:
net = ipaddress.ip_network(client_ip.strip(), strict=False) # точный IP или подсеть
except ValueError:
raise HTTPException(422, "Некорректный IP-адрес или подсеть")
stmt = stmt.where(AuditLog.client_ip.op("<<=")(str(net)))
if date_from:
stmt = stmt.where(AuditLog.ts >= datetime.combine(date_from, time.min, timezone.utc))
if date_to:
stmt = stmt.where(AuditLog.ts < datetime.combine(date_to + timedelta(days=1), time.min, timezone.utc))
if q.strip():
term = _escape_like(q.strip())
like = f"%{term}%"
uid_prefix = _escape_like(q.strip().removeprefix("evt_").lower()) + "%"
stmt = stmt.where(or_(
AuditLog.message.ilike(like, escape="\\"), AuditLog.entity_label.ilike(like, escape="\\"),
cast(AuditLog.uid, String).ilike(uid_prefix, escape="\\"),
func.host(AuditLog.client_ip).ilike(f"{term}%", escape="\\"),
))
return stmt
@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, le=500), offset: int = 0, db: Session = Depends(get_db),
):
stmt = _filtered(select(AuditLog), event_type, entity_type, actor, date_from, date_to, q, client_ip)
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)
class Summary(BaseModel):
total: int
oldest_ts: datetime | None
retention_days: int
max_entries: int
@router.get("/audit/summary", response_model=Summary)
def summary(db: Session = Depends(get_db)):
cfg = get_settings(db)
total, oldest = db.execute(select(func.count(), func.min(AuditLog.ts))).one()
return Summary(total=total, oldest_ts=oldest, **cfg)
class Facets(BaseModel):
event_types: list[str]
entity_types: list[str]
actors: list[str]
@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()
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],
)
@router.get("/audit/{uid}", response_model=s.AuditOut)
def get_entry(uid: uuid.UUID, db: Session = Depends(get_db)):
row = db.scalar(select(AuditLog).where(AuditLog.uid == uid))
if row is None:
raise HTTPException(404, "Запись не найдена")
return s.AuditOut.from_row(row)
# ------------------------------------------------------------------- настройки
class JournalSettings(BaseModel):
retention_days: int = Field(ge=0, le=3650)
max_entries: int = Field(ge=0, le=10_000_000)
@router.get("/journal/settings", response_model=JournalSettings)
def read_settings(db: Session = Depends(get_db)):
return get_settings(db)
class SettingsSaved(JournalSettings):
deleted: int
@router.put("/journal/settings", response_model=SettingsSaved)
def update_settings(body: JournalSettings, db: Session = Depends(get_db), user: User = Depends(admin_user)):
old = get_settings(db)
new = body.model_dump()
save_settings(db, new)
changed = {k: f"{old[k]} → {v}" for k, v in new.items() if old[k] != v}
audit(db, user, "journal", None, "settings_updated", "settings", changed or None,
message="Настройки журнала: " + ("; ".join(f"{k} {v}" for k, v in changed.items()) or "без изменений"))
commit(db)
result = rotate(db) or {"by_age": 0, "by_count": 0} # ротация сразу при сохранении
return SettingsSaved(**new, deleted=result["by_age"] + result["by_count"])
# --------------------------------------------------------------------- очистка
class ClearIn(BaseModel):
password: str
def _lock_state(db: Session, user_id: int) -> tuple[int, int]:
"""(неудачных за окно, секунд до конца блокировки). Блокировка — LOCK_MINUTES после 5-й неудачи в пределах окна."""
now = datetime.now(timezone.utc)
last = db.scalars(select(ClearAttempt.ts).where(ClearAttempt.user_id == user_id).order_by(ClearAttempt.id.desc()).limit(MAX_ATTEMPTS)).all()
window = timedelta(minutes=LOCK_MINUTES)
in_window = sum(1 for ts in last if now - ts <= window)
if len(last) == MAX_ATTEMPTS and last[0] - last[-1] <= window:
remaining = (last[0] + window - now).total_seconds()
if remaining > 0:
return MAX_ATTEMPTS, int(remaining) + 1
return in_window, 0
@router.post("/journal/clear")
def clear_journal(body: ClearIn, db: Session = Depends(get_db), user: User = Depends(admin_user)):
used, retry = _lock_state(db, user.id)
if retry:
raise HTTPException(429, {"message": "Слишком много неверных попыток", "retry_after_seconds": retry})
if not verify_password(body.password, user.password_hash):
db.add(ClearAttempt(user_id=user.id))
db.commit()
used, retry = _lock_state(db, user.id)
if retry:
raise HTTPException(429, {"message": "Слишком много неверных попыток", "retry_after_seconds": retry})
raise HTTPException(403, {"message": "Неверный пароль", "attempts_left": MAX_ATTEMPTS - used})
deleted = db.execute(delete(AuditLog)).rowcount
db.execute(delete(ClearAttempt).where(ClearAttempt.user_id == user.id))
audit(db, user, "journal", None, "cleared", "clear", {"deleted": deleted}, message=f"Журнал очищен: удалено записей {deleted}")
commit(db)
return {"deleted": deleted}