823 lines
29 KiB
Python
823 lines
29 KiB
Python
from __future__ import annotations
|
||
|
||
import json
|
||
import uuid
|
||
|
||
from dataclasses import dataclass
|
||
from datetime import datetime, timedelta, timezone
|
||
from types import SimpleNamespace
|
||
from typing import Any, Iterable
|
||
|
||
from aiogram.types import CallbackQuery, InlineQuery, Message, TelegramObject, User
|
||
from fastapi import Request
|
||
from sqlalchemy import and_, delete, desc, or_, select
|
||
from sqlalchemy.ext.asyncio import AsyncSession
|
||
|
||
from database.models import AuditEvent
|
||
from logger import logger
|
||
|
||
try:
|
||
from core.cache_config import (
|
||
AUDIT_REDIS_BUFFER_ENABLED,
|
||
AUDIT_REDIS_DRAIN_BATCH,
|
||
AUDIT_REDIS_FLUSH_KEY,
|
||
AUDIT_REDIS_IDENTITY_PREFIX,
|
||
AUDIT_REDIS_USER_PREFIX,
|
||
AUDIT_REDIS_USER_TTL_SEC,
|
||
)
|
||
except ImportError:
|
||
AUDIT_REDIS_BUFFER_ENABLED = False
|
||
AUDIT_REDIS_FLUSH_KEY = "audit:flush"
|
||
AUDIT_REDIS_USER_PREFIX = "audit:user:tg:"
|
||
AUDIT_REDIS_IDENTITY_PREFIX = "audit:user:identity:"
|
||
AUDIT_REDIS_USER_TTL_SEC = 25 * 3600
|
||
AUDIT_REDIS_DRAIN_BATCH = 1000
|
||
|
||
_MAX_TEXT_LEN = 160
|
||
_AUDIT_TABLE_READY = False
|
||
|
||
|
||
@dataclass
|
||
class AuditContext:
|
||
request_id: str
|
||
channel: str
|
||
path_or_handler: str
|
||
actor_identity_id: str | None = None
|
||
actor_tg_id: int | None = None
|
||
|
||
|
||
def new_request_id() -> str:
|
||
return uuid.uuid4().hex
|
||
|
||
|
||
def _trim(value: Any, limit: int = _MAX_TEXT_LEN) -> str | None:
|
||
if value is None:
|
||
return None
|
||
text = str(value).strip()
|
||
if not text:
|
||
return None
|
||
if len(text) <= limit:
|
||
return text
|
||
return text[: limit - 3] + "..."
|
||
|
||
|
||
def _jsonable(value: Any) -> Any:
|
||
if value is None or isinstance(value, (str, int, float, bool)):
|
||
return value
|
||
if isinstance(value, datetime):
|
||
return value.isoformat()
|
||
if isinstance(value, dict):
|
||
return {str(k): _jsonable(v) for k, v in value.items()}
|
||
if isinstance(value, (list, tuple, set)):
|
||
return [_jsonable(item) for item in value]
|
||
return _trim(value, 500)
|
||
|
||
|
||
def _serialize(payload: dict[str, Any]) -> str:
|
||
return json.dumps(_jsonable(payload), ensure_ascii=False, sort_keys=True)
|
||
|
||
|
||
def _message_text(event: TelegramObject) -> str | None:
|
||
if isinstance(event, Message):
|
||
return _trim(event.text or event.caption)
|
||
if isinstance(event, CallbackQuery):
|
||
return _trim(event.data)
|
||
if isinstance(event, InlineQuery):
|
||
return _trim(event.query)
|
||
return None
|
||
|
||
|
||
def _event_user(event: TelegramObject) -> User | None:
|
||
if hasattr(event, "from_user") and isinstance(event.from_user, User):
|
||
return event.from_user
|
||
return None
|
||
|
||
|
||
def describe_telegram_event(event: TelegramObject) -> str:
|
||
if isinstance(event, Message):
|
||
return f"message:{_message_text(event) or '-'}"
|
||
if isinstance(event, CallbackQuery):
|
||
return f"callback:{_message_text(event) or '-'}"
|
||
if isinstance(event, InlineQuery):
|
||
return f"inline:{_message_text(event) or '-'}"
|
||
return type(event).__name__
|
||
|
||
|
||
def ensure_api_context(request: Request) -> AuditContext:
|
||
context = getattr(request.state, "audit_context", None)
|
||
if isinstance(context, AuditContext):
|
||
return context
|
||
|
||
path_or_handler = request.url.path
|
||
if request.url.query:
|
||
path_or_handler = f"{path_or_handler}?{request.url.query}"
|
||
|
||
context = AuditContext(
|
||
request_id=new_request_id(),
|
||
channel="api",
|
||
path_or_handler=path_or_handler,
|
||
)
|
||
request.state.audit_context = context
|
||
request.state.audit_request_id = context.request_id
|
||
return context
|
||
|
||
|
||
def get_api_context(request: Request | None) -> AuditContext | None:
|
||
if request is None:
|
||
return None
|
||
context = getattr(request.state, "audit_context", None)
|
||
if isinstance(context, AuditContext):
|
||
return context
|
||
return None
|
||
|
||
|
||
def set_api_actor(
|
||
request: Request,
|
||
*,
|
||
identity_id: str | None = None,
|
||
tg_id: int | None = None,
|
||
) -> AuditContext:
|
||
context = ensure_api_context(request)
|
||
if identity_id is not None:
|
||
context.actor_identity_id = identity_id
|
||
if tg_id is not None:
|
||
context.actor_tg_id = tg_id
|
||
return context
|
||
|
||
|
||
def ensure_telegram_context(
|
||
data: dict[str, Any] | None,
|
||
event: TelegramObject,
|
||
) -> AuditContext:
|
||
if data is not None:
|
||
existing = data.get("audit_context")
|
||
if isinstance(existing, AuditContext):
|
||
return existing
|
||
|
||
user = _event_user(event)
|
||
context = AuditContext(
|
||
request_id=new_request_id(),
|
||
channel="telegram",
|
||
path_or_handler=describe_telegram_event(event),
|
||
actor_tg_id=user.id if user else None,
|
||
)
|
||
if data is not None:
|
||
data["audit_context"] = context
|
||
data["audit_request_id"] = context.request_id
|
||
return context
|
||
|
||
|
||
def set_telegram_actor(
|
||
audit_context: AuditContext | dict[str, Any] | None,
|
||
*,
|
||
identity_id: str | None = None,
|
||
tg_id: int | None = None,
|
||
) -> AuditContext | None:
|
||
context: AuditContext | None
|
||
if isinstance(audit_context, AuditContext):
|
||
context = audit_context
|
||
elif isinstance(audit_context, dict):
|
||
context = audit_context.get("audit_context")
|
||
else:
|
||
context = None
|
||
|
||
if not isinstance(context, AuditContext):
|
||
return None
|
||
if identity_id is not None:
|
||
context.actor_identity_id = identity_id
|
||
if tg_id is not None:
|
||
context.actor_tg_id = tg_id
|
||
return context
|
||
|
||
|
||
def get_telegram_context(audit_context: AuditContext | dict[str, Any] | None) -> AuditContext | None:
|
||
if isinstance(audit_context, AuditContext):
|
||
return audit_context
|
||
if isinstance(audit_context, dict):
|
||
context = audit_context.get("audit_context")
|
||
if isinstance(context, AuditContext):
|
||
return context
|
||
return None
|
||
|
||
|
||
def log_api_access(
|
||
request: Request,
|
||
*,
|
||
status_code: int,
|
||
duration_ms: int,
|
||
result: str,
|
||
reason: str | None = None,
|
||
) -> None:
|
||
context = ensure_api_context(request)
|
||
client_ip = request.client.host if request.client else "-"
|
||
logger.debug(
|
||
f"[AUDIT_ACCESS] {_serialize({
|
||
'request_id': context.request_id,
|
||
'channel': 'api',
|
||
'method': request.method,
|
||
'path': context.path_or_handler,
|
||
'status_code': status_code,
|
||
'duration_ms': duration_ms,
|
||
'result': result,
|
||
'reason': reason,
|
||
'client_ip': client_ip,
|
||
'actor_identity_id': context.actor_identity_id,
|
||
'actor_tg_id': context.actor_tg_id,
|
||
})}"
|
||
)
|
||
|
||
|
||
def log_telegram_access(
|
||
event: TelegramObject,
|
||
*,
|
||
audit_context: AuditContext | None,
|
||
result: str,
|
||
reason: str | None = None,
|
||
) -> None:
|
||
context = audit_context or AuditContext(
|
||
request_id=new_request_id(),
|
||
channel="telegram",
|
||
path_or_handler=describe_telegram_event(event),
|
||
)
|
||
user = _event_user(event)
|
||
logger.debug(
|
||
f"[AUDIT_ACCESS] {_serialize({
|
||
'request_id': context.request_id,
|
||
'channel': 'telegram',
|
||
'path_or_handler': context.path_or_handler,
|
||
'event_type': type(event).__name__,
|
||
'message': _message_text(event),
|
||
'result': result,
|
||
'reason': reason,
|
||
'actor_identity_id': context.actor_identity_id,
|
||
'actor_tg_id': context.actor_tg_id or (user.id if user else None),
|
||
'username': getattr(user, 'username', None) if user else None,
|
||
})}"
|
||
)
|
||
|
||
|
||
async def record_audit_event(
|
||
session: AsyncSession,
|
||
*,
|
||
event_type: str,
|
||
channel: str,
|
||
path_or_handler: str,
|
||
actor_identity_id: str | None = None,
|
||
actor_tg_id: int | None = None,
|
||
entity_type: str | None = None,
|
||
entity_id: str | int | None = None,
|
||
result: str = "success",
|
||
reason: str | None = None,
|
||
metadata: dict[str, Any] | None = None,
|
||
request_id: str | None = None,
|
||
) -> AuditEvent:
|
||
await ensure_audit_table(session)
|
||
event = AuditEvent(
|
||
event_type=event_type,
|
||
channel=channel,
|
||
actor_identity_id=actor_identity_id,
|
||
actor_tg_id=actor_tg_id,
|
||
path_or_handler=_trim(path_or_handler, 255) or channel,
|
||
entity_type=_trim(entity_type, 64),
|
||
entity_id=_trim(entity_id, 255),
|
||
result=_trim(result, 32) or "success",
|
||
reason=_trim(reason, 1000),
|
||
metadata_=_jsonable(metadata) if metadata else None,
|
||
request_id=_trim(request_id, 64),
|
||
)
|
||
session.add(event)
|
||
await session.flush()
|
||
logger.debug(
|
||
f"[AUDIT_EVENT] {_serialize({
|
||
'id': event.id,
|
||
'request_id': event.request_id,
|
||
'channel': event.channel,
|
||
'event_type': event.event_type,
|
||
'actor_identity_id': event.actor_identity_id,
|
||
'actor_tg_id': event.actor_tg_id,
|
||
'path_or_handler': event.path_or_handler,
|
||
'entity_type': event.entity_type,
|
||
'entity_id': event.entity_id,
|
||
'result': event.result,
|
||
'reason': event.reason,
|
||
'metadata': event.metadata_,
|
||
})}"
|
||
)
|
||
return event
|
||
|
||
|
||
async def safe_record_audit_event(session: AsyncSession, **kwargs: Any) -> AuditEvent | None:
|
||
try:
|
||
return await record_audit_event(session, **kwargs)
|
||
except Exception as exc:
|
||
logger.warning(f"[Audit] Не удалось записать событие {kwargs.get('event_type')}: {exc}")
|
||
return None
|
||
|
||
|
||
def _telegram_access_payload(
|
||
audit_context: AuditContext | None,
|
||
event: TelegramObject,
|
||
*,
|
||
result: str = "success",
|
||
reason: str | None = None,
|
||
) -> dict[str, Any]:
|
||
"""Собирает payload для записи telegram_access (для фоновой задачи или синхронной)."""
|
||
ctx = get_telegram_context(audit_context)
|
||
user = _event_user(event)
|
||
path = describe_telegram_event(event)
|
||
if ctx is None:
|
||
ctx = AuditContext(
|
||
request_id=new_request_id(),
|
||
channel="telegram",
|
||
path_or_handler=path,
|
||
actor_tg_id=user.id if user else None,
|
||
)
|
||
return {
|
||
"request_id": ctx.request_id,
|
||
"path_or_handler": path,
|
||
"actor_identity_id": ctx.actor_identity_id,
|
||
"actor_tg_id": ctx.actor_tg_id or (user.id if user else None),
|
||
"result": result,
|
||
"reason": reason,
|
||
}
|
||
|
||
|
||
async def record_telegram_access_event(
|
||
session: AsyncSession,
|
||
audit_context: AuditContext | None,
|
||
event: TelegramObject,
|
||
*,
|
||
result: str = "success",
|
||
reason: str | None = None,
|
||
) -> AuditEvent | None:
|
||
"""Пишет в БД одно событие «обработчик Telegram вызван» для полного следа пользователя."""
|
||
payload = _telegram_access_payload(audit_context, event, result=result, reason=reason)
|
||
return await safe_record_audit_event(
|
||
session,
|
||
event_type="telegram_access",
|
||
channel="telegram",
|
||
path_or_handler=payload["path_or_handler"],
|
||
actor_identity_id=payload["actor_identity_id"],
|
||
actor_tg_id=payload["actor_tg_id"],
|
||
result=payload["result"],
|
||
reason=payload["reason"],
|
||
request_id=payload["request_id"],
|
||
)
|
||
|
||
|
||
def _audit_record_for_redis(
|
||
*,
|
||
event_type: str = "telegram_access",
|
||
channel: str = "telegram",
|
||
path_or_handler: str,
|
||
actor_identity_id: str | None = None,
|
||
actor_tg_id: int | None = None,
|
||
entity_type: str | None = None,
|
||
entity_id: str | int | None = None,
|
||
result: str = "success",
|
||
reason: str | None = None,
|
||
request_id: str | None = None,
|
||
metadata_: dict | None = None,
|
||
) -> dict[str, Any]:
|
||
"""Формирует запись для буфера Redis (с created_at в ISO)."""
|
||
return {
|
||
"event_type": event_type,
|
||
"channel": channel,
|
||
"path_or_handler": _trim(path_or_handler, 255) or channel,
|
||
"actor_identity_id": actor_identity_id,
|
||
"actor_tg_id": actor_tg_id,
|
||
"entity_type": _trim(entity_type, 64) if entity_type else None,
|
||
"entity_id": _trim(str(entity_id), 255) if entity_id is not None else None,
|
||
"result": _trim(result, 32) or "success",
|
||
"reason": _trim(reason, 1000) if reason else None,
|
||
"request_id": _trim(request_id, 64) if request_id else None,
|
||
"metadata_": _jsonable(metadata_) if metadata_ else None,
|
||
"created_at": datetime.now(timezone.utc).isoformat(),
|
||
}
|
||
|
||
|
||
async def record_audit_event_to_redis(
|
||
*,
|
||
request_id: str | None = None,
|
||
path_or_handler: str = "",
|
||
actor_identity_id: str | None = None,
|
||
actor_tg_id: int | None = None,
|
||
result: str = "success",
|
||
reason: str | None = None,
|
||
) -> None:
|
||
"""Пишет событие telegram_access в буфер Redis (списки для выгрузки в БД и для чтения по пользователю)."""
|
||
from core.redis_cache import cache_expire, cache_rpush
|
||
|
||
record = _audit_record_for_redis(
|
||
path_or_handler=path_or_handler,
|
||
actor_identity_id=actor_identity_id,
|
||
actor_tg_id=actor_tg_id,
|
||
result=result,
|
||
reason=reason,
|
||
request_id=request_id,
|
||
)
|
||
await cache_rpush(AUDIT_REDIS_FLUSH_KEY, record)
|
||
if actor_tg_id is not None:
|
||
user_key = f"{AUDIT_REDIS_USER_PREFIX}{actor_tg_id}"
|
||
await cache_rpush(user_key, record)
|
||
await cache_expire(user_key, AUDIT_REDIS_USER_TTL_SEC)
|
||
if actor_identity_id:
|
||
identity_key = f"{AUDIT_REDIS_IDENTITY_PREFIX}{actor_identity_id}"
|
||
await cache_rpush(identity_key, record)
|
||
await cache_expire(identity_key, AUDIT_REDIS_USER_TTL_SEC)
|
||
|
||
|
||
async def record_api_access_event_to_redis(
|
||
*,
|
||
request_id: str | None = None,
|
||
path_or_handler: str = "",
|
||
actor_identity_id: str | None = None,
|
||
actor_tg_id: int | None = None,
|
||
result: str = "success",
|
||
reason: str | None = None,
|
||
) -> None:
|
||
"""Пишет событие api_access в буфер Redis (как telegram_access)."""
|
||
from core.redis_cache import cache_expire, cache_rpush
|
||
|
||
record = _audit_record_for_redis(
|
||
event_type="api_access",
|
||
channel="api",
|
||
path_or_handler=path_or_handler,
|
||
actor_identity_id=actor_identity_id,
|
||
actor_tg_id=actor_tg_id,
|
||
result=result,
|
||
reason=reason,
|
||
request_id=request_id,
|
||
)
|
||
await cache_rpush(AUDIT_REDIS_FLUSH_KEY, record)
|
||
if actor_tg_id is not None:
|
||
user_key = f"{AUDIT_REDIS_USER_PREFIX}{actor_tg_id}"
|
||
await cache_rpush(user_key, record)
|
||
await cache_expire(user_key, AUDIT_REDIS_USER_TTL_SEC)
|
||
if actor_identity_id:
|
||
identity_key = f"{AUDIT_REDIS_IDENTITY_PREFIX}{actor_identity_id}"
|
||
await cache_rpush(identity_key, record)
|
||
await cache_expire(identity_key, AUDIT_REDIS_USER_TTL_SEC)
|
||
|
||
|
||
async def record_api_access_event_background(
|
||
session_factory: Any,
|
||
request: Request,
|
||
*,
|
||
result: str = "success",
|
||
reason: str | None = None,
|
||
status_code: int = 200,
|
||
) -> None:
|
||
"""Пишет одно событие api_access в Redis-буфер или в БД в фоне (после обработки запроса).
|
||
Вызывается из middleware; actor берётся из request.state (set_api_actor в эндпоинтах)."""
|
||
context = ensure_api_context(request)
|
||
path_or_handler = f"{request.method} {request.url.path}"
|
||
if request.url.query:
|
||
path_or_handler = f"{path_or_handler}?{request.url.query}"
|
||
path_or_handler = _trim(path_or_handler, 255) or "api"
|
||
if AUDIT_REDIS_BUFFER_ENABLED:
|
||
try:
|
||
await record_api_access_event_to_redis(
|
||
request_id=context.request_id,
|
||
path_or_handler=path_or_handler,
|
||
actor_identity_id=context.actor_identity_id,
|
||
actor_tg_id=context.actor_tg_id,
|
||
result=result,
|
||
reason=reason,
|
||
)
|
||
except Exception as exc:
|
||
logger.warning("[Audit] Запись api_access в Redis-буфер не удалась: %s", exc)
|
||
return
|
||
try:
|
||
async with session_factory() as session:
|
||
await ensure_audit_table(session)
|
||
await record_audit_event(
|
||
session,
|
||
event_type="api_access",
|
||
channel="api",
|
||
path_or_handler=path_or_handler,
|
||
actor_identity_id=context.actor_identity_id,
|
||
actor_tg_id=context.actor_tg_id,
|
||
result=result,
|
||
reason=reason,
|
||
request_id=_trim(context.request_id, 64),
|
||
)
|
||
await session.commit()
|
||
except Exception as exc:
|
||
logger.warning(
|
||
"[Audit] Фоновая запись api_access не удалась: %s",
|
||
exc,
|
||
extra={"path_or_handler": path_or_handler[:80] if path_or_handler else None},
|
||
)
|
||
|
||
|
||
async def record_telegram_access_event_background(
|
||
session_factory: Any,
|
||
*,
|
||
request_id: str | None,
|
||
path_or_handler: str,
|
||
actor_identity_id: str | None = None,
|
||
actor_tg_id: int | None = None,
|
||
result: str = "success",
|
||
reason: str | None = None,
|
||
) -> None:
|
||
"""Пишет событие telegram_access: в Redis-буфер (если включён) или в БД в отдельной сессии.
|
||
При ошибке логирует и не пробрасывает исключение."""
|
||
if AUDIT_REDIS_BUFFER_ENABLED:
|
||
try:
|
||
await record_audit_event_to_redis(
|
||
request_id=request_id,
|
||
path_or_handler=path_or_handler,
|
||
actor_identity_id=actor_identity_id,
|
||
actor_tg_id=actor_tg_id,
|
||
result=result,
|
||
reason=reason,
|
||
)
|
||
except Exception as exc:
|
||
logger.warning("[Audit] Запись в Redis-буфер не удалась: %s", exc)
|
||
return
|
||
try:
|
||
async with session_factory() as session:
|
||
await ensure_audit_table(session)
|
||
await record_audit_event(
|
||
session,
|
||
event_type="telegram_access",
|
||
channel="telegram",
|
||
path_or_handler=_trim(path_or_handler, 255) or "telegram",
|
||
actor_identity_id=actor_identity_id,
|
||
actor_tg_id=actor_tg_id,
|
||
result=result,
|
||
reason=reason,
|
||
request_id=_trim(request_id, 64),
|
||
)
|
||
await session.commit()
|
||
except Exception as exc:
|
||
logger.warning(
|
||
"[Audit] Фоновая запись telegram_access не удалась: %s",
|
||
exc,
|
||
extra={"path_or_handler": path_or_handler[:80] if path_or_handler else None},
|
||
)
|
||
|
||
|
||
async def safe_record_api_event(
|
||
session: AsyncSession,
|
||
request: Request,
|
||
*,
|
||
event_type: str,
|
||
entity_type: str | None = None,
|
||
entity_id: str | int | None = None,
|
||
result: str = "success",
|
||
reason: str | None = None,
|
||
metadata: dict[str, Any] | None = None,
|
||
actor_identity_id: str | None = None,
|
||
actor_tg_id: int | None = None,
|
||
path_or_handler: str | None = None,
|
||
) -> AuditEvent | None:
|
||
context = ensure_api_context(request)
|
||
return await safe_record_audit_event(
|
||
session,
|
||
event_type=event_type,
|
||
channel="api",
|
||
path_or_handler=path_or_handler or context.path_or_handler,
|
||
actor_identity_id=actor_identity_id if actor_identity_id is not None else context.actor_identity_id,
|
||
actor_tg_id=actor_tg_id if actor_tg_id is not None else context.actor_tg_id,
|
||
entity_type=entity_type,
|
||
entity_id=entity_id,
|
||
result=result,
|
||
reason=reason,
|
||
metadata=metadata,
|
||
request_id=context.request_id,
|
||
)
|
||
|
||
|
||
async def safe_record_telegram_event(
|
||
session: AsyncSession,
|
||
audit_context: AuditContext | dict[str, Any] | None,
|
||
*,
|
||
event_type: str,
|
||
entity_type: str | None = None,
|
||
entity_id: str | int | None = None,
|
||
result: str = "success",
|
||
reason: str | None = None,
|
||
metadata: dict[str, Any] | None = None,
|
||
actor_identity_id: str | None = None,
|
||
actor_tg_id: int | None = None,
|
||
path_or_handler: str | None = None,
|
||
) -> AuditEvent | None:
|
||
context = get_telegram_context(audit_context)
|
||
return await safe_record_audit_event(
|
||
session,
|
||
event_type=event_type,
|
||
channel="telegram",
|
||
path_or_handler=path_or_handler or (context.path_or_handler if context else "telegram"),
|
||
actor_identity_id=actor_identity_id if actor_identity_id is not None else (context.actor_identity_id if context else None),
|
||
actor_tg_id=actor_tg_id if actor_tg_id is not None else (context.actor_tg_id if context else None),
|
||
entity_type=entity_type,
|
||
entity_id=entity_id,
|
||
result=result,
|
||
reason=reason,
|
||
metadata=metadata,
|
||
request_id=context.request_id if context else None,
|
||
)
|
||
|
||
|
||
def _redis_record_to_event_like(rec: dict[str, Any]) -> SimpleNamespace:
|
||
"""Превращает запись из Redis в объект с теми же атрибутами, что и AuditEvent."""
|
||
created = rec.get("created_at")
|
||
if isinstance(created, str):
|
||
try:
|
||
created = datetime.fromisoformat(created.replace("Z", "+00:00"))
|
||
except Exception:
|
||
created = datetime.now(timezone.utc)
|
||
elif created is None:
|
||
created = datetime.now(timezone.utc)
|
||
return SimpleNamespace(
|
||
id=None,
|
||
event_type=rec.get("event_type", "telegram_access"),
|
||
channel=rec.get("channel", "telegram"),
|
||
path_or_handler=rec.get("path_or_handler") or "",
|
||
actor_identity_id=rec.get("actor_identity_id"),
|
||
actor_tg_id=rec.get("actor_tg_id"),
|
||
entity_type=rec.get("entity_type"),
|
||
entity_id=rec.get("entity_id"),
|
||
result=rec.get("result", "success"),
|
||
reason=rec.get("reason"),
|
||
metadata_=rec.get("metadata_"),
|
||
request_id=rec.get("request_id"),
|
||
created_at=created,
|
||
)
|
||
|
||
|
||
async def _list_audit_events_from_redis(
|
||
tg_id: int | None,
|
||
identity_id: str | None,
|
||
channel: str | None,
|
||
event_types: list[str] | None,
|
||
max_events: int = 3000,
|
||
) -> list[SimpleNamespace]:
|
||
"""Читает события пользователя из Redis-буфера (для слияния с БД)."""
|
||
if not AUDIT_REDIS_BUFFER_ENABLED:
|
||
return []
|
||
from core.redis_cache import cache_lrange
|
||
|
||
out: list[SimpleNamespace] = []
|
||
seen: set[tuple[str, str]] = set()
|
||
keys_to_read = []
|
||
if tg_id is not None:
|
||
keys_to_read.append(f"{AUDIT_REDIS_USER_PREFIX}{tg_id}")
|
||
if identity_id:
|
||
keys_to_read.append(f"{AUDIT_REDIS_IDENTITY_PREFIX}{identity_id}")
|
||
for key in keys_to_read:
|
||
raw = await cache_lrange(key, -max_events, -1)
|
||
for rec in reversed(raw):
|
||
if not isinstance(rec, dict):
|
||
continue
|
||
created = rec.get("created_at")
|
||
rid = rec.get("request_id") or ""
|
||
if (created, rid) in seen:
|
||
continue
|
||
if channel and rec.get("channel") != channel:
|
||
continue
|
||
if event_types and rec.get("event_type") not in event_types:
|
||
continue
|
||
seen.add((str(created), rid))
|
||
out.append(_redis_record_to_event_like(rec))
|
||
out.sort(key=lambda e: e.created_at, reverse=True)
|
||
return out[:max_events]
|
||
|
||
|
||
async def list_audit_events(
|
||
session: AsyncSession,
|
||
*,
|
||
identity_id: str | None = None,
|
||
tg_id: int | None = None,
|
||
channel: str | None = None,
|
||
event_type: str | None = None,
|
||
event_types: Iterable[str] | None = None,
|
||
limit: int = 100,
|
||
offset: int = 0,
|
||
) -> list[AuditEvent | SimpleNamespace]:
|
||
"""Список событий аудита. При включённом Redis-буфере объединяет данные из БД и Redis."""
|
||
await ensure_audit_table(session)
|
||
event_types_list = sorted(event_types) if event_types else None
|
||
|
||
if not AUDIT_REDIS_BUFFER_ENABLED:
|
||
stmt = select(AuditEvent)
|
||
actor_filters = []
|
||
if identity_id:
|
||
actor_filters.append(AuditEvent.actor_identity_id == identity_id)
|
||
actor_filters.append(and_(AuditEvent.entity_type == "identity", AuditEvent.entity_id == identity_id))
|
||
if tg_id is not None:
|
||
tg_id_str = str(tg_id)
|
||
actor_filters.append(AuditEvent.actor_tg_id == tg_id)
|
||
actor_filters.append(and_(AuditEvent.entity_type == "user", AuditEvent.entity_id == tg_id_str))
|
||
actor_filters.append(and_(AuditEvent.entity_type == "telegram_user", AuditEvent.entity_id == tg_id_str))
|
||
if actor_filters:
|
||
stmt = stmt.where(or_(*actor_filters))
|
||
if channel:
|
||
stmt = stmt.where(AuditEvent.channel == channel)
|
||
if event_type:
|
||
stmt = stmt.where(AuditEvent.event_type == event_type)
|
||
if event_types_list:
|
||
stmt = stmt.where(AuditEvent.event_type.in_(event_types_list))
|
||
stmt = stmt.order_by(desc(AuditEvent.created_at), desc(AuditEvent.id)).limit(limit).offset(offset)
|
||
result = await session.execute(stmt)
|
||
return list(result.scalars().all())
|
||
|
||
redis_events = await _list_audit_events_from_redis(
|
||
tg_id, identity_id, channel, event_types_list, max_events=3000
|
||
)
|
||
need = offset + limit + len(redis_events)
|
||
stmt = select(AuditEvent)
|
||
actor_filters = []
|
||
if identity_id:
|
||
actor_filters.append(AuditEvent.actor_identity_id == identity_id)
|
||
actor_filters.append(and_(AuditEvent.entity_type == "identity", AuditEvent.entity_id == identity_id))
|
||
if tg_id is not None:
|
||
tg_id_str = str(tg_id)
|
||
actor_filters.append(AuditEvent.actor_tg_id == tg_id)
|
||
actor_filters.append(and_(AuditEvent.entity_type == "user", AuditEvent.entity_id == tg_id_str))
|
||
actor_filters.append(and_(AuditEvent.entity_type == "telegram_user", AuditEvent.entity_id == tg_id_str))
|
||
if actor_filters:
|
||
stmt = stmt.where(or_(*actor_filters))
|
||
if channel:
|
||
stmt = stmt.where(AuditEvent.channel == channel)
|
||
if event_type:
|
||
stmt = stmt.where(AuditEvent.event_type == event_type)
|
||
if event_types_list:
|
||
stmt = stmt.where(AuditEvent.event_type.in_(event_types_list))
|
||
stmt = stmt.order_by(desc(AuditEvent.created_at), desc(AuditEvent.id)).limit(min(5000, need)).offset(0)
|
||
result = await session.execute(stmt)
|
||
db_events = list(result.scalars().all())
|
||
merged = redis_events + db_events
|
||
merged.sort(key=lambda e: (e.created_at, getattr(e, "id", 0)), reverse=True)
|
||
return merged[offset : offset + limit]
|
||
|
||
|
||
async def ensure_audit_table(session: AsyncSession) -> None:
|
||
global _AUDIT_TABLE_READY
|
||
if _AUDIT_TABLE_READY:
|
||
return
|
||
connection = await session.connection()
|
||
await connection.run_sync(AuditEvent.__table__.create, checkfirst=True)
|
||
_AUDIT_TABLE_READY = True
|
||
|
||
|
||
async def delete_old_audit_events(
|
||
session: AsyncSession,
|
||
*,
|
||
older_than_days: int = 90,
|
||
) -> int:
|
||
"""Удаляет события старше N дней. Вызывать по крону/периодике при больших наплывах.
|
||
Возвращает количество удалённых строк."""
|
||
await ensure_audit_table(session)
|
||
threshold = datetime.now(timezone.utc) - timedelta(days=older_than_days)
|
||
stmt = delete(AuditEvent).where(AuditEvent.created_at < threshold)
|
||
result = await session.execute(stmt)
|
||
return result.rowcount or 0
|
||
|
||
|
||
async def drain_audit_redis_to_db(session_factory: Any) -> int:
|
||
"""Выгружает буфер аудита из Redis в БД батчами. Вызывать по крону (например в 00:00).
|
||
Возвращает количество записанных событий."""
|
||
from core.redis_cache import cache_lpop_batch
|
||
|
||
total = 0
|
||
while True:
|
||
batch = await cache_lpop_batch(AUDIT_REDIS_FLUSH_KEY, AUDIT_REDIS_DRAIN_BATCH)
|
||
if not batch:
|
||
break
|
||
try:
|
||
async with session_factory() as session:
|
||
await ensure_audit_table(session)
|
||
for rec in batch:
|
||
created = rec.get("created_at")
|
||
if isinstance(created, str):
|
||
try:
|
||
created = datetime.fromisoformat(created.replace("Z", "+00:00"))
|
||
except Exception:
|
||
created = datetime.now(timezone.utc)
|
||
elif created is None:
|
||
created = datetime.now(timezone.utc)
|
||
event = AuditEvent(
|
||
event_type=rec.get("event_type", "telegram_access"),
|
||
channel=rec.get("channel", "telegram"),
|
||
path_or_handler=rec.get("path_or_handler") or "telegram",
|
||
actor_identity_id=rec.get("actor_identity_id"),
|
||
actor_tg_id=rec.get("actor_tg_id"),
|
||
entity_type=rec.get("entity_type"),
|
||
entity_id=rec.get("entity_id"),
|
||
result=rec.get("result", "success"),
|
||
reason=rec.get("reason"),
|
||
metadata_=rec.get("metadata_"),
|
||
request_id=rec.get("request_id"),
|
||
created_at=created,
|
||
)
|
||
session.add(event)
|
||
await session.commit()
|
||
total += len(batch)
|
||
except Exception as exc:
|
||
logger.warning("[Audit] drain_audit_redis_to_db батч не записан: %s", exc)
|
||
break
|
||
return total
|