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
+65
View File
@@ -0,0 +1,65 @@
"""
Router Audit Log consultazione degli eventi di sistema.
Endpoint:
GET /api/v1/audit-log lista paginata con filtri (solo admin/super_admin)
Permessi:
- admin: vede solo gli eventi del proprio tenant
- super_admin: vede tutti i tenant (filtrabile per tenant_id)
"""
import uuid
from datetime import datetime
from typing import Optional
from fastapi import APIRouter, Query
from app.dependencies import AdminUser, DB
from app.schemas.audit_log import AuditLogListResponse
from app.services.audit_service import AuditService
router = APIRouter(prefix="/audit-log", tags=["Audit Log"])
@router.get("", response_model=AuditLogListResponse)
async def list_audit_log(
current_user: AdminUser,
db: DB,
page: int = Query(1, ge=1, description="Numero di pagina"),
page_size: int = Query(25, ge=1, le=100, description="Elementi per pagina"),
action: Optional[str] = Query(None, description="Filtra per azione (es. auth.login, user.*)"),
user_id: Optional[uuid.UUID] = Query(None, description="Filtra per utente"),
outcome: Optional[str] = Query(None, pattern="^(success|failure)$", description="Esito: success o failure"),
date_from: Optional[datetime] = Query(None, description="Data inizio (ISO 8601)"),
date_to: Optional[datetime] = Query(None, description="Data fine (ISO 8601)"),
resource_type: Optional[str] = Query(None, description="Tipo risorsa (user, mailbox, message, ...)"),
tenant_id: Optional[uuid.UUID] = Query(None, description="Filtra per tenant (solo super_admin)"),
) -> AuditLogListResponse:
"""
Restituisce la lista paginata degli eventi di audit.
- Admin: vede solo gli eventi del proprio tenant (tenant_id ignorato).
- Super Admin: vede tutti i tenant, filtrabile per tenant_id.
"""
svc = AuditService(db)
# Determina il tenant_id effettivo da applicare al filtro
if current_user.is_super_admin:
# Super admin: usa il tenant_id passato come filtro (None = tutti)
effective_tenant_id = tenant_id
else:
# Admin normale: sempre vincolato al proprio tenant
effective_tenant_id = current_user.tenant_id
return await svc.list(
tenant_id=effective_tenant_id,
page=page,
page_size=page_size,
action=action,
user_id=user_id,
outcome=outcome,
date_from=date_from,
date_to=date_to,
resource_type=resource_type,
)
+21
View File
@@ -161,13 +161,34 @@ async def totp_disable(
summary="Cambio password utente corrente",
)
async def change_password(
request: Request,
body: PasswordChangeRequest,
current_user: CurrentUser,
db: DB,
) -> None:
from app.core.security import verify_password, hash_password
from app.services.audit_service import log_audit
if not verify_password(body.current_password, current_user.password_hash):
from app.services.audit_service import log_audit as _la
await _la(
db,
"auth.password_changed",
tenant_id=current_user.tenant_id,
user_id=current_user.id,
outcome="failure",
ip_address=request.client.host if request.client else None,
user_agent=request.headers.get("user-agent"),
payload={"reason": "wrong_current_password"},
)
raise InvalidCredentialsError()
current_user.password_hash = hash_password(body.new_password)
await log_audit(
db,
"auth.password_changed",
tenant_id=current_user.tenant_id,
user_id=current_user.id,
ip_address=request.client.host if request.client else None,
user_agent=request.headers.get("user-agent"),
)
+2 -1
View File
@@ -13,7 +13,7 @@ from slowapi.errors import RateLimitExceeded
from slowapi.middleware import SlowAPIMiddleware
from slowapi.util import get_remote_address
from app.api.v1 import auth, labels, mailboxes, messages, notifications, permissions, reports, send, tenants, users, virtual_boxes, ws
from app.api.v1 import audit_log, auth, labels, mailboxes, messages, notifications, permissions, reports, send, tenants, users, virtual_boxes, ws
from app.api.v1 import settings as settings_router
from app.config import get_settings
from app.core.logging import get_logger, setup_logging
@@ -97,6 +97,7 @@ app.include_router(notifications.router, prefix=API_PREFIX)
app.include_router(labels.router, prefix=API_PREFIX)
app.include_router(settings_router.router, prefix=API_PREFIX)
app.include_router(reports.router, prefix=API_PREFIX)
app.include_router(audit_log.router, prefix=API_PREFIX)
# ─── Health check ─────────────────────────────────────────────────────────────
+41
View File
@@ -0,0 +1,41 @@
"""
Schemi Pydantic per Audit Log.
"""
import uuid
from datetime import datetime
from typing import Any, Optional
from pydantic import BaseModel, field_validator
from app.core.pagination import PaginatedResponse
class AuditLogResponse(BaseModel):
"""Risposta singolo evento audit."""
id: int
tenant_id: Optional[uuid.UUID] = None
user_id: Optional[uuid.UUID] = None
action: str
resource_type: Optional[str] = None
resource_id: Optional[uuid.UUID] = None
ip_address: Optional[str] = None
user_agent: Optional[str] = None
payload: Optional[dict] = None
outcome: str
occurred_at: datetime
model_config = {"from_attributes": True}
@field_validator("ip_address", mode="before")
@classmethod
def coerce_ip_address(cls, v: Any) -> Optional[str]:
"""Converte IPv4Address/IPv6Address (tipo PostgreSQL INET) in stringa."""
if v is None:
return None
return str(v)
# Lista paginata
AuditLogListResponse = PaginatedResponse[AuditLogResponse]
+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,
)
+27
View File
@@ -12,6 +12,7 @@ from app.core.exceptions import ConflictError, ForbiddenError, NotFoundError
from app.core.security import decrypt_credential, encrypt_credential
from app.models.mailbox import Mailbox
from app.models.tenant import Tenant
from app.services.audit_service import log_audit
from app.schemas.mailbox import (
ConnectionTestRequest,
ConnectionTestResult,
@@ -85,6 +86,15 @@ class MailboxService:
)
self.db.add(mailbox)
await self.db.flush()
await log_audit(
self.db,
"mailbox.created",
tenant_id=tenant_id,
user_id=created_by,
resource_type="mailbox",
resource_id=mailbox.id,
payload={"email_address": mailbox.email_address},
)
return mailbox
async def list_mailboxes(
@@ -175,6 +185,14 @@ class MailboxService:
mailbox.status = "active"
await self.db.flush()
await log_audit(
self.db,
"mailbox.updated",
tenant_id=tenant_id,
resource_type="mailbox",
resource_id=mailbox_id,
payload={"mailbox_id": str(mailbox_id)},
)
return mailbox
async def delete_mailbox(
@@ -184,8 +202,17 @@ class MailboxService:
) -> None:
"""Soft-delete: imposta status=deleted."""
mailbox = await self.get_mailbox(mailbox_id, tenant_id)
email = mailbox.email_address
mailbox.status = "deleted"
await self.db.flush()
await log_audit(
self.db,
"mailbox.deleted",
tenant_id=tenant_id,
resource_type="mailbox",
resource_id=mailbox_id,
payload={"email_address": email},
)
# ─── Decrypt helpers (usati internamente e dal worker) ───────────────────
+32
View File
@@ -13,6 +13,7 @@ from app.core.security import hash_password
from app.models.tenant import Tenant
from app.models.user import User
from app.schemas.user import UserCreateRequest, UserUpdateRequest
from app.services.audit_service import log_audit
class UserService:
@@ -61,6 +62,15 @@ class UserService:
)
self.db.add(user)
await self.db.flush() # ottieni l'ID
await log_audit(
self.db,
"user.created",
tenant_id=tenant_id,
user_id=created_by.id,
resource_type="user",
resource_id=user.id,
payload={"email": user.email, "role": user.role},
)
return user
async def get_user(self, user_id: uuid.UUID, tenant_id: uuid.UUID) -> User:
@@ -110,13 +120,26 @@ class UserService:
if user.is_super_admin and not updated_by.is_super_admin:
raise ForbiddenError("Non puoi modificare un super_admin")
changes: dict = {}
if data.full_name is not None:
changes["full_name"] = data.full_name
user.full_name = data.full_name
if data.role is not None:
changes["role"] = data.role
user.role = data.role
if data.is_active is not None:
changes["is_active"] = data.is_active
user.is_active = data.is_active
await log_audit(
self.db,
"user.updated",
tenant_id=tenant_id,
user_id=updated_by.id,
resource_type="user",
resource_id=user_id,
payload={"changes": changes},
)
return user
async def reset_password(
@@ -143,3 +166,12 @@ class UserService:
# Soft delete (disabilita invece di eliminare)
user.is_active = False
await log_audit(
self.db,
"user.deleted",
tenant_id=tenant_id,
user_id=deleted_by.id,
resource_type="user",
resource_id=user_id,
payload={"email": user.email},
)