Files
PecHub/backend/app/services/audit_service.py
T
2026-06-04 20:54:49 +02:00

378 lines
12 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""
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=get_real_ip(request),
user_agent=request.headers.get("user-agent"),
payload={"email": new_user.email},
)
"""
import csv
import io
import math
import uuid
from datetime import datetime
from typing import Optional
from fastapi import Request
from sqlalchemy import select, func, and_
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import aliased
from app.core.pagination import PaginatedResponse, PaginationParams
from app.models.audit_log import AuditLog
from app.models.user import User
from app.schemas.audit_log import AuditLogResponse
# ─── Helper IP reale (legge X-Real-IP o X-Forwarded-For da reverse proxy) ────
def get_real_ip(request: Request) -> Optional[str]:
"""
Restituisce l'IP reale del client, leggendo gli header del reverse proxy.
Priorita': X-Real-IP > primo IP di X-Forwarded-For > request.client.host
"""
x_real_ip = request.headers.get("x-real-ip")
if x_real_ip:
return x_real_ip.strip()
x_forwarded_for = request.headers.get("x-forwarded-for", "")
if x_forwarded_for:
# Prende il primo IP della catena (quello del client originale)
first_ip = x_forwarded_for.split(",")[0].strip()
if first_ip:
return first_ip
if request.client:
return request.client.host
return None
# ─── 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
def _build_query(
self,
*,
tenant_id: Optional[uuid.UUID],
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,
):
"""Costruisce la query base con JOIN utente e filtri."""
UserAlias = aliased(User)
stmt = (
select(
AuditLog,
UserAlias.email.label("user_email"),
UserAlias.full_name.label("user_full_name"),
)
.outerjoin(UserAlias, UserAlias.id == AuditLog.user_id)
)
filters = []
if tenant_id is not None:
filters.append(AuditLog.tenant_id == tenant_id)
if action:
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)
if filters:
stmt = stmt.where(and_(*filters))
return stmt
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 con dati utente.
Se tenant_id e' None (super_admin), restituisce eventi di tutti i tenant.
"""
base_stmt = self._build_query(
tenant_id=tenant_id,
action=action,
user_id=user_id,
outcome=outcome,
date_from=date_from,
date_to=date_to,
resource_type=resource_type,
)
# Count totale
count_q = select(func.count()).select_from(base_stmt.subquery())
total = (await self.db.execute(count_q)).scalar_one()
# Dati paginati
offset = (page - 1) * page_size
items_q = base_stmt.order_by(AuditLog.occurred_at.desc()).offset(offset).limit(page_size)
rows = (await self.db.execute(items_q)).all()
items = []
for row in rows:
entry: AuditLog = row[0]
email: Optional[str] = row[1]
full_name: Optional[str] = row[2]
resp = AuditLogResponse.model_validate(entry)
resp.user_email = email
resp.user_full_name = full_name
items.append(resp)
pages = math.ceil(total / page_size) if page_size > 0 else 0
return PaginatedResponse[AuditLogResponse](
items=items,
total=total,
page=page,
page_size=page_size,
pages=pages,
)
async def export_csv(
self,
*,
tenant_id: Optional[uuid.UUID],
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,
limit: int = 10000,
) -> str:
"""
Restituisce i log in formato CSV (stringa).
Massimo `limit` righe per prevenire export eccessivi.
"""
base_stmt = self._build_query(
tenant_id=tenant_id,
action=action,
user_id=user_id,
outcome=outcome,
date_from=date_from,
date_to=date_to,
resource_type=resource_type,
)
items_q = base_stmt.order_by(AuditLog.occurred_at.desc()).limit(limit)
rows = (await self.db.execute(items_q)).all()
output = io.StringIO()
writer = csv.writer(output)
writer.writerow([
"ID", "Data/Ora", "Azione", "Esito",
"Utente", "Email utente", "IP",
"Tipo risorsa", "ID risorsa",
])
for row in rows:
entry: AuditLog = row[0]
email: Optional[str] = row[1]
full_name: Optional[str] = row[2]
writer.writerow([
entry.id,
entry.occurred_at.strftime("%d/%m/%Y %H:%M:%S"),
entry.action,
entry.outcome,
full_name or "",
email or "",
str(entry.ip_address) if entry.ip_address else "",
entry.resource_type or "",
str(entry.resource_id) if entry.resource_id else "",
])
return output.getvalue()
async def export_pdf_bytes(
self,
*,
tenant_id: Optional[uuid.UUID],
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,
limit: int = 5000,
) -> bytes:
"""
Genera un PDF con i log di audit usando reportlab.
Restituisce i byte del PDF.
"""
from reportlab.lib import colors
from reportlab.lib.pagesizes import A4, landscape
from reportlab.lib.styles import getSampleStyleSheet
from reportlab.lib.units import cm
from reportlab.platypus import (
SimpleDocTemplate, Table, TableStyle, Paragraph, Spacer,
)
base_stmt = self._build_query(
tenant_id=tenant_id,
action=action,
user_id=user_id,
outcome=outcome,
date_from=date_from,
date_to=date_to,
resource_type=resource_type,
)
items_q = base_stmt.order_by(AuditLog.occurred_at.desc()).limit(limit)
rows = (await self.db.execute(items_q)).all()
buf = io.BytesIO()
doc = SimpleDocTemplate(
buf,
pagesize=landscape(A4),
rightMargin=1 * cm,
leftMargin=1 * cm,
topMargin=1.5 * cm,
bottomMargin=1.5 * cm,
)
styles = getSampleStyleSheet()
story = []
# Titolo
title_text = "Audit Log"
if date_from or date_to:
parts = []
if date_from:
parts.append(f"dal {date_from.strftime('%d/%m/%Y')}")
if date_to:
parts.append(f"al {date_to.strftime('%d/%m/%Y')}")
title_text += " " + " ".join(parts)
story.append(Paragraph(title_text, styles["Heading1"]))
story.append(Spacer(1, 0.3 * cm))
story.append(Paragraph(
f"Esportato il {datetime.now().strftime('%d/%m/%Y %H:%M')}{len(rows)} record",
styles["Normal"],
))
story.append(Spacer(1, 0.5 * cm))
# Tabella
headers = ["Data/Ora", "Azione", "Esito", "Utente", "IP", "Risorsa"]
table_data = [headers]
for row in rows:
entry: AuditLog = row[0]
email: Optional[str] = row[1]
full_name: Optional[str] = row[2]
utente = full_name or email or (str(entry.user_id)[:8] if entry.user_id else "")
risorsa = entry.resource_type or ""
table_data.append([
entry.occurred_at.strftime("%d/%m/%Y %H:%M:%S"),
entry.action,
"OK" if entry.outcome == "success" else "FAIL",
utente,
str(entry.ip_address) if entry.ip_address else "",
risorsa,
])
col_widths = [3.8 * cm, 5.0 * cm, 1.8 * cm, 4.5 * cm, 3.5 * cm, 3.0 * cm]
table = Table(table_data, colWidths=col_widths, repeatRows=1)
table.setStyle(TableStyle([
("BACKGROUND", (0, 0), (-1, 0), colors.HexColor("#1e3a5f")),
("TEXTCOLOR", (0, 0), (-1, 0), colors.white),
("FONTNAME", (0, 0), (-1, 0), "Helvetica-Bold"),
("FONTSIZE", (0, 0), (-1, 0), 8),
("FONTSIZE", (0, 1), (-1, -1), 7),
("ROWBACKGROUNDS", (0, 1), (-1, -1), [colors.white, colors.HexColor("#f5f8ff")]),
("GRID", (0, 0), (-1, -1), 0.25, colors.HexColor("#cccccc")),
("VALIGN", (0, 0), (-1, -1), "MIDDLE"),
("TOPPADDING", (0, 0), (-1, -1), 3),
("BOTTOMPADDING", (0, 0), (-1, -1), 3),
]))
story.append(table)
doc.build(story)
return buf.getvalue()