Files
ayurishchevandClaude Opus 5.5 744a025960 Задачи 032-033: ролевая модель с привязкой к организации, исправления по ревью
Пентест (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>
2026-09-27 11:26:57 +03:00

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}