Пентест (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>
182 lines
9.2 KiB
Python
182 lines
9.2 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, superadmin_user, verify_password
|
|
from app.services import MAX_OFFSET, audit, commit, like_escape, require_org, scope_org # изменение 032
|
|
|
|
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), user: User = Depends(current_user), # изменение 032
|
|
):
|
|
stmt = _filtered(select(AuditLog), event_type, entity_type, actor, date_from, date_to, q, client_ip)
|
|
stmt = scope_org(stmt, AuditLog.organization_id, user) # изменение 032: фильтр по организации
|
|
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), user: User = Depends(current_user)): # изменение 032
|
|
cfg = get_settings(db)
|
|
stmt = select(func.count(), func.min(AuditLog.ts)).select_from(AuditLog)
|
|
stmt = scope_org(stmt, AuditLog.organization_id, user) # изменение 032: фильтр по организации
|
|
total, oldest = db.execute(stmt).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), user: User = Depends(current_user)): # изменение 032
|
|
stmt = select(AuditLog.entity_type, AuditLog.action).distinct()
|
|
stmt = scope_org(stmt, AuditLog.organization_id, user) # изменение 032: фильтр по организации
|
|
pairs = db.execute(stmt.order_by(AuditLog.entity_type, AuditLog.action)).all()
|
|
stmt2 = select(AuditLog.username).distinct()
|
|
stmt2 = scope_org(stmt2, AuditLog.organization_id, user) # изменение 032: фильтр по организации
|
|
users = db.scalars(stmt2.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), user: User = Depends(current_user)): # изменение 032
|
|
row = db.scalar(select(AuditLog).where(AuditLog.uid == uid))
|
|
if row is None:
|
|
raise HTTPException(404, "Запись не найдена")
|
|
require_org(user, row.organization_id, "Запись журнала") # изменение 032
|
|
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(superadmin_user)): # изменение 032: только superadmin
|
|
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(superadmin_user)): # изменение 032: только superadmin
|
|
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}
|