Law/app/services/security_audit.py
2026-02-23 17:54:19 +03:00

129 lines
4.1 KiB
Python

from __future__ import annotations
import logging
import uuid
from datetime import timedelta
from typing import Any
from sqlalchemy import func, inspect
from sqlalchemy.exc import SQLAlchemyError
from sqlalchemy.orm import Session
from app.models.security_audit_log import SecurityAuditLog
from app.models.common import utcnow
logger = logging.getLogger(__name__)
SUSPICIOUS_DENY_WINDOW_MINUTES = 10
SUSPICIOUS_DENY_THRESHOLD = 5
def _uuid_or_none(raw: str | uuid.UUID | None) -> uuid.UUID | None:
if raw is None:
return None
if isinstance(raw, uuid.UUID):
return raw
try:
return uuid.UUID(str(raw))
except (TypeError, ValueError):
return None
def _safe_details(details: dict[str, Any] | None) -> dict[str, Any]:
if not isinstance(details, dict):
return {}
safe: dict[str, Any] = {}
for key, value in details.items():
if value is None or isinstance(value, (str, int, float, bool)):
safe[str(key)] = value
else:
safe[str(key)] = str(value)
return safe
def _emit_suspicious_denied_download_alert(
db: Session,
*,
actor_role: str,
actor_subject: str,
actor_ip: str | None,
) -> None:
if not actor_subject and not actor_ip:
return
since = utcnow() - timedelta(minutes=SUSPICIOUS_DENY_WINDOW_MINUTES)
query = db.query(func.count(SecurityAuditLog.id)).filter(
SecurityAuditLog.created_at >= since,
SecurityAuditLog.action == "DOWNLOAD_OBJECT",
SecurityAuditLog.allowed.is_(False),
)
if actor_subject:
query = query.filter(SecurityAuditLog.actor_subject == actor_subject)
elif actor_ip:
query = query.filter(SecurityAuditLog.actor_ip == actor_ip)
denied_count = int(query.scalar() or 0)
if denied_count >= SUSPICIOUS_DENY_THRESHOLD:
logger.warning(
"SECURITY_ALERT repeated denied download attempts role=%s subject=%s ip=%s count=%s window_min=%s",
actor_role,
actor_subject or "-",
actor_ip or "-",
denied_count,
SUSPICIOUS_DENY_WINDOW_MINUTES,
)
def record_file_security_event(
db: Session,
*,
actor_role: str,
actor_subject: str,
actor_ip: str | None,
action: str,
scope: str,
allowed: bool,
reason: str | None = None,
object_key: str | None = None,
request_id: str | uuid.UUID | None = None,
attachment_id: str | uuid.UUID | None = None,
details: dict[str, Any] | None = None,
responsible: str | None = None,
persist_now: bool = False,
) -> None:
# Security telemetry must not block business flow if DB log write fails.
try:
bind = db.get_bind()
if bind is None or not inspect(bind).has_table("security_audit_log"):
return
row = SecurityAuditLog(
actor_role=str(actor_role or "UNKNOWN").upper(),
actor_subject=str(actor_subject or "").strip(),
actor_ip=str(actor_ip or "").strip() or None,
action=str(action or "").strip().upper() or "UNKNOWN",
scope=str(scope or "").strip().upper() or "UNKNOWN",
object_key=str(object_key or "").strip() or None,
request_id=_uuid_or_none(request_id),
attachment_id=_uuid_or_none(attachment_id),
allowed=bool(allowed),
reason=(str(reason)[:400] if reason is not None else None),
details=_safe_details(details),
responsible=str(responsible or "Администратор системы").strip() or "Администратор системы",
)
db.add(row)
db.flush()
if not bool(allowed) and str(action or "").upper() == "DOWNLOAD_OBJECT":
_emit_suspicious_denied_download_alert(
db,
actor_role=row.actor_role,
actor_subject=row.actor_subject,
actor_ip=row.actor_ip,
)
if persist_now:
db.commit()
except SQLAlchemyError:
if persist_now:
try:
db.rollback()
except Exception:
pass