mirror of
https://github.com/idrainformatica/PecFlow.git
synced 2026-06-16 12:45:42 +02:00
378 lines
12 KiB
Python
378 lines
12 KiB
Python
"""
|
||
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()
|