Modifiche varie

This commit is contained in:
2026-06-04 20:54:49 +02:00
parent ccc4167e28
commit e31676d22e
31 changed files with 3058 additions and 153 deletions
+246 -22
View File
@@ -12,25 +12,54 @@ Uso tipico nei router/servizi:
resource_type="user",
resource_id=new_user.id,
outcome="success",
ip_address=request.client.host if request.client else None,
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(
@@ -79,31 +108,35 @@ class AuditService:
def __init__(self, db: AsyncSession) -> None:
self.db = db
async def list(
def _build_query(
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.
):
"""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)
)
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:
@@ -124,30 +157,221 @@ class AuditService:
if resource_type:
filters.append(AuditLog.resource_type == resource_type)
where_clause = and_(*filters) if filters else True # type: ignore[arg-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(AuditLog).where(where_clause)
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 = (
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())
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=[AuditLogResponse.model_validate(item) for item in items],
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()