Files
ipam_control/app/api/v1/journal.py
T
ayurishchevandClaude Opus 5.5 13e17fbb47 Задачи 011-024: доработки по ревью кодовой базы и исправление находок
Ревью кодовой базы (docs/reviews/2026-09-26-codebase-review.md) и планы по каждой находке:
011 IP уникален в VRF и хранится в самом узком префиксе (addresses.vrf_id, составной FK
    с каскадом при переносе VRF, миграция 0007 с остановкой на дублях).
012 Ограничение попыток входа (login_attempts, 429 + Retry-After), выравнивание времени
    ответа, журнал без вытеснения анонимными событиями (миграция 0006).
013 Границы пагинации: отрицательные/чрезмерные limit/offset дают 422 вместо 500.
014 Экран адресов: страница свободных адресов арифметикой, пагинация в SQL.
015 Запрет адреса сети/broadcast, загрузка не выше 100 %.
016 Роль по умолчанию — viewer.
017 Проверка JWT_SECRET/ADMIN_PASSWORD при старте.
018 null в PATCH очищает текстовые поля; нейтральный текст конфликта БД.
019 Пакетная загрузка в списках вместо N+1.
020 Автоназначение адреса вне вложенных префиксов, с блокировкой префикса.
021 Advisory-lock при снятии прав администратора, уникальный lower(username) (миграция 0008).
022 Контейнер не от root, healthcheck, блокировка миграций, requirements.lock.
023 Экранирование LIKE, журнал отказов очистки, заголовки безопасности, учёт force-удаления,
    отзыв токенов при смене пароля (claim pv, миграция 0005).
024 Исправление находок ревью 011-023 (docs/reviews/2026-09-26-changes-011-023-review.md):
    сериализация попыток входа, запрет переноса адресов в адрес сети/broadcast, журнал входов,
    запрет смены своего пароля через PATCH, валидация PATCH устройства, обновлён тест токенов.

Тесты: 14 passed. Документация: README.md, docs/changes/011-024, docs/reviews.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-26 21:33:50 +03:00

174 lines
8.1 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 MAX_OFFSET, audit, commit, like_escape
router = APIRouter(dependencies=[Depends(current_user)], tags=["journal"])
MAX_ATTEMPTS = 5
LOCK_MINUTES = 10
_escape_like = like_escape
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, ge=1, le=500), offset: int = Query(0, ge=0, le=MAX_OFFSET), 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:
audit(db, user, "journal", None, "clear_locked", "clear", {"retry_after_seconds": retry}, message="Очистка журнала: попытка во время блокировки")
db.commit()
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:
audit(db, user, "journal", None, "clear_locked", "clear", {"retry_after_seconds": retry}, message="Очистка журнала: неверный пароль, доступ заблокирован")
db.commit()
raise HTTPException(429, {"message": "Слишком много неверных попыток", "retry_after_seconds": retry})
audit(db, user, "journal", None, "clear_failed", "clear", {"attempts_left": MAX_ATTEMPTS - used}, message="Очистка журнала: неверный пароль")
db.commit()
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}