"""Журнал: поиск и фильтры, запись, настройки ротации, очистка с подтверждением пароля.""" 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}