Audit Log

This commit is contained in:
2026-03-27 14:58:12 +01:00
parent d7ae840ac6
commit a3247a69b6
13 changed files with 734 additions and 9 deletions
+153
View File
@@ -0,0 +1,153 @@
"""
Servizio Audit Log registrazione e consultazione degli eventi di sistema.
Uso tipico nei router/servizi:
from app.services.audit_service import log_audit
await log_audit(
db=db,
tenant_id=current_user.tenant_id,
user_id=current_user.id,
action="user.created",
resource_type="user",
resource_id=new_user.id,
outcome="success",
ip_address=request.client.host if request.client else None,
user_agent=request.headers.get("user-agent"),
payload={"email": new_user.email},
)
"""
import math
import uuid
from datetime import datetime
from typing import Optional
from sqlalchemy import select, func, and_
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.pagination import PaginatedResponse, PaginationParams
from app.models.audit_log import AuditLog
from app.schemas.audit_log import AuditLogResponse
# ─── Helper standalone (da chiamare ovunque senza istanziare la classe) ───────
async def log_audit(
db: AsyncSession,
action: str,
*,
tenant_id: Optional[uuid.UUID] = None,
user_id: Optional[uuid.UUID] = None,
resource_type: Optional[str] = None,
resource_id: Optional[uuid.UUID] = None,
outcome: str = "success",
ip_address: Optional[str] = None,
user_agent: Optional[str] = None,
payload: Optional[dict] = None,
) -> None:
"""
Inserisce un record di audit log nella sessione corrente.
Non fa commit: il commit avviene con la transazione del chiamante.
Non solleva eccezioni: gli errori sono loggati ma non propagati
per evitare di bloccare l'operazione principale.
"""
try:
entry = AuditLog(
tenant_id=tenant_id,
user_id=user_id,
action=action,
resource_type=resource_type,
resource_id=resource_id,
ip_address=ip_address,
user_agent=user_agent,
payload=payload or {},
outcome=outcome,
)
db.add(entry)
except Exception:
# Mai bloccare l'operazione principale per un errore di audit
import logging
logging.getLogger(__name__).warning(
"Impossibile registrare evento audit: action=%s", action, exc_info=True
)
# ─── Servizio per query (usato dal router) ────────────────────────────────────
class AuditService:
def __init__(self, db: AsyncSession) -> None:
self.db = db
async def list(
self,
*,
tenant_id: Optional[uuid.UUID],
page: int = 1,
page_size: int = 25,
action: Optional[str] = None,
user_id: Optional[uuid.UUID] = None,
outcome: Optional[str] = None,
date_from: Optional[datetime] = None,
date_to: Optional[datetime] = None,
resource_type: Optional[str] = None,
) -> PaginatedResponse[AuditLogResponse]:
"""
Restituisce la lista paginata degli eventi audit.
Se tenant_id e' None (super_admin), restituisce eventi di tutti i tenant.
"""
filters = []
if tenant_id is not None:
filters.append(AuditLog.tenant_id == tenant_id)
if action:
# Supporta prefisso: "auth." corrisponde a tutti gli eventi auth.*
if action.endswith("*"):
filters.append(AuditLog.action.like(action[:-1] + "%"))
else:
filters.append(AuditLog.action == action)
if user_id:
filters.append(AuditLog.user_id == user_id)
if outcome:
filters.append(AuditLog.outcome == outcome)
if date_from:
filters.append(AuditLog.occurred_at >= date_from)
if date_to:
filters.append(AuditLog.occurred_at <= date_to)
if resource_type:
filters.append(AuditLog.resource_type == resource_type)
where_clause = and_(*filters) if filters else True # type: ignore[arg-type]
# Count totale
count_q = select(func.count()).select_from(AuditLog).where(where_clause)
total = (await self.db.execute(count_q)).scalar_one()
# Dati paginati
offset = (page - 1) * page_size
items_q = (
select(AuditLog)
.where(where_clause)
.order_by(AuditLog.occurred_at.desc())
.offset(offset)
.limit(page_size)
)
result = await self.db.execute(items_q)
items = list(result.scalars().all())
pages = math.ceil(total / page_size) if page_size > 0 else 0
return PaginatedResponse[AuditLogResponse](
items=[AuditLogResponse.model_validate(item) for item in items],
total=total,
page=page,
page_size=page_size,
pages=pages,
)