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>
169 lines
7.5 KiB
Python
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}
|