From a3247a69b63be5a4c45629752ff5f3dad0069d77 Mon Sep 17 00:00:00 2001 From: idrainformatica Date: Fri, 27 Mar 2026 14:58:12 +0100 Subject: [PATCH] Audit Log --- GapAnalysis.md | 8 - backend/app/api/v1/audit_log.py | 65 ++++ backend/app/api/v1/auth.py | 21 ++ backend/app/main.py | 3 +- backend/app/schemas/audit_log.py | 41 +++ backend/app/services/audit_service.py | 153 +++++++++ backend/app/services/mailbox_service.py | 27 ++ backend/app/services/user_service.py | 32 ++ frontend/src/App.tsx | 4 + frontend/src/api/audit_log.api.ts | 41 +++ frontend/src/components/Layout/Sidebar.tsx | 2 + frontend/src/pages/AuditLog/AuditLogPage.tsx | 338 +++++++++++++++++++ frontend/src/types/api.types.ts | 8 + 13 files changed, 734 insertions(+), 9 deletions(-) create mode 100644 backend/app/api/v1/audit_log.py create mode 100644 backend/app/schemas/audit_log.py create mode 100644 backend/app/services/audit_service.py create mode 100644 frontend/src/api/audit_log.api.ts create mode 100644 frontend/src/pages/AuditLog/AuditLogPage.tsx diff --git a/GapAnalysis.md b/GapAnalysis.md index ec4f1a6..02268bc 100644 --- a/GapAnalysis.md +++ b/GapAnalysis.md @@ -65,13 +65,7 @@ frontend/src/pages/Archival/ (pagina log versamenti, download RdV, richiesta DIP Il modello archival.py esiste ma la tabella archival_batches non e' nella migrazione corrente La configurazione conservatore nelle impostazioni tenant e' pronta, ma il "pulsante" che avvia il versamento non esiste -5. Audit Log – modello esistente, tutto il resto mancante -Il modello audit_log.py e la tabella esistono -Non c'e' nessun endpoint API GET /audit-log per leggerlo -Non c'e' nessuna pagina frontend per la visualizzazione -Non e' chiaro se il backend registra effettivamente gli eventi (nessuna chiamata a AuditLog trovata nei servizi) -COSA MANCA – PRIORITA' MEDIA 6. Worker – job mancanti dispatch_notification.py – notifiche automatiche @@ -85,9 +79,7 @@ Il CI/CD GitHub Actions e' disabilitato (ci.yml.bak): non c'e' lint automatico, Non c'e' docker-compose.prod.yml (override produzione con configurazioni rafforzate) Docs /docs, /redoc sono disabilitate in produzione ma non c'e' un meccanismo di secret scan -9. Ruolo Supervisor -Il ruolo supervisor e' definito nell'enum DB e nella documentazione ma non ha logica differenziata dal operator nel codice: is_admin controlla solo admin/super_admin, tutto il resto e' trattato uguale 10. Gestione quote casella L'evento mailbox.quota_warning e' definito negli enum delle notifiche ma non e' mai generato dal worker (nessuna stima della quota IMAP) diff --git a/backend/app/api/v1/audit_log.py b/backend/app/api/v1/audit_log.py new file mode 100644 index 0000000..c3d9244 --- /dev/null +++ b/backend/app/api/v1/audit_log.py @@ -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, + ) diff --git a/backend/app/api/v1/auth.py b/backend/app/api/v1/auth.py index 89815c1..1ac2146 100644 --- a/backend/app/api/v1/auth.py +++ b/backend/app/api/v1/auth.py @@ -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"), + ) diff --git a/backend/app/main.py b/backend/app/main.py index 2c22708..5c1b51e 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -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 ───────────────────────────────────────────────────────────── diff --git a/backend/app/schemas/audit_log.py b/backend/app/schemas/audit_log.py new file mode 100644 index 0000000..875f732 --- /dev/null +++ b/backend/app/schemas/audit_log.py @@ -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] diff --git a/backend/app/services/audit_service.py b/backend/app/services/audit_service.py new file mode 100644 index 0000000..7cf7bde --- /dev/null +++ b/backend/app/services/audit_service.py @@ -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, + ) diff --git a/backend/app/services/mailbox_service.py b/backend/app/services/mailbox_service.py index 17c6cc3..94d2601 100644 --- a/backend/app/services/mailbox_service.py +++ b/backend/app/services/mailbox_service.py @@ -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) ─────────────────── diff --git a/backend/app/services/user_service.py b/backend/app/services/user_service.py index 9b82426..eadd2f9 100644 --- a/backend/app/services/user_service.py +++ b/backend/app/services/user_service.py @@ -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}, + ) diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 1c76a43..b6d67d0 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -13,6 +13,7 @@ import { NotificationsPage } from '@/pages/Notifications/NotificationsPage' import { MultiTenantPage } from '@/pages/MultiTenant/MultiTenantPage' import { SearchPage } from '@/pages/Search/SearchPage' import { ReportsPage } from '@/pages/Reports/ReportsPage' +import { AuditLogPage } from '@/pages/AuditLog/AuditLogPage' /** * Routing principale dell'applicazione PEChub. @@ -84,6 +85,9 @@ export default function App() { {/* Dashboard e Reportistica */} } /> + {/* Audit Log */} + } /> + {/* Profilo utente */} } /> diff --git a/frontend/src/api/audit_log.api.ts b/frontend/src/api/audit_log.api.ts new file mode 100644 index 0000000..01e66ee --- /dev/null +++ b/frontend/src/api/audit_log.api.ts @@ -0,0 +1,41 @@ +/** + * Client API per Audit Log. + */ + +import apiClient from './client' +import type { PaginatedResponse } from '@/types/api.types' + +export interface AuditLogEntry { + id: number + tenant_id: string | null + user_id: string | null + action: string + resource_type: string | null + resource_id: string | null + ip_address: string | null + user_agent: string | null + payload: Record | null + outcome: 'success' | 'failure' + occurred_at: string +} + +export type AuditLogListResponse = PaginatedResponse + +export interface AuditLogParams { + page?: number + page_size?: number + action?: string + user_id?: string + outcome?: 'success' | 'failure' + date_from?: string + date_to?: string + resource_type?: string + tenant_id?: string +} + +export const auditLogApi = { + list: (params: AuditLogParams = {}): Promise => + apiClient + .get('/audit-log', { params }) + .then((r) => r.data), +} diff --git a/frontend/src/components/Layout/Sidebar.tsx b/frontend/src/components/Layout/Sidebar.tsx index 4a726e5..0ce4e2a 100644 --- a/frontend/src/components/Layout/Sidebar.tsx +++ b/frontend/src/components/Layout/Sidebar.tsx @@ -52,6 +52,7 @@ import { Trash2, Search, BarChart2, + ClipboardList, } from 'lucide-react' import { cn } from '@/lib/utils' import { useAuth } from '@/hooks/useAuth' @@ -470,6 +471,7 @@ export function Sidebar() { { to: '/permissions', label: 'Permessi', icon: Shield }, { to: '/virtual-boxes', label: 'Virtual Box', icon: Filter }, { to: '/notifications', label: 'Notifiche', icon: Bell }, + { to: '/audit-log', label: 'Audit Log', icon: ClipboardList }, ] as const).map((item) => ( + {isSuccess ? ( + + ) : ( + + )} + {isSuccess ? 'Successo' : 'Fallito'} + + ) +} + +// ─── Etichetta azione leggibile ─────────────────────────────────────────────── + +function actionLabel(action: string): string { + const map: Record = { + 'auth.login': 'Login', + 'auth.password_changed': 'Cambio password', + 'user.created': 'Utente creato', + 'user.updated': 'Utente modificato', + 'user.deleted': 'Utente eliminato', + 'mailbox.created': 'Casella creata', + 'mailbox.updated': 'Casella modificata', + 'mailbox.deleted': 'Casella eliminata', + 'message.sent': 'PEC inviata', + } + return map[action] ?? action +} + +// ─── Componente principale ──────────────────────────────────────────────────── + +export function AuditLogPage() { + const [page, setPage] = useState(1) + const PAGE_SIZE = 25 + + // Filtri + const [filterAction, setFilterAction] = useState('') + const [filterOutcome, setFilterOutcome] = useState<'' | 'success' | 'failure'>('') + const [filterDateFrom, setFilterDateFrom] = useState('') + const [filterDateTo, setFilterDateTo] = useState('') + + // Parametri query attivi (applicati al click su "Cerca") + const [activeParams, setActiveParams] = useState({}) + + const { data, isLoading, isError } = useQuery({ + queryKey: ['audit-log', page, activeParams], + queryFn: () => + auditLogApi.list({ + page, + page_size: PAGE_SIZE, + ...activeParams, + }), + staleTime: 30_000, + }) + + const handleSearch = () => { + setPage(1) + const params: AuditLogParams = {} + if (filterAction) params.action = filterAction + if (filterOutcome) params.outcome = filterOutcome + if (filterDateFrom) params.date_from = new Date(filterDateFrom).toISOString() + if (filterDateTo) params.date_to = new Date(filterDateTo + 'T23:59:59').toISOString() + setActiveParams(params) + } + + const handleReset = () => { + setFilterAction('') + setFilterOutcome('') + setFilterDateFrom('') + setFilterDateTo('') + setActiveParams({}) + setPage(1) + } + + const items = data?.items ?? [] + const total = data?.total ?? 0 + const pages = data?.pages ?? 1 + + return ( +
+ {/* Intestazione */} +
+

Audit Log

+

+ Registro cronologico degli eventi di sistema e delle operazioni degli utenti. +

+
+ + {/* Filtri */} +
+
+ {/* Azione */} +
+ + +
+ + {/* Esito */} +
+ + +
+ + {/* Data da */} +
+ + setFilterDateFrom(e.target.value)} + className="w-full rounded-md border border-gray-300 px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500" + /> +
+ + {/* Data a */} +
+ + setFilterDateTo(e.target.value)} + className="w-full rounded-md border border-gray-300 px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500" + /> +
+
+ + {/* Bottoni */} +
+ + +
+
+ + {/* Tabella */} +
+ {/* Header tabella con count */} +
+ + {isLoading ? 'Caricamento...' : `${total.toLocaleString('it-IT')} eventi trovati`} + + {total > 0 && ( + + Pagina {page} di {pages} + + )} +
+ + {/* Stato errore */} + {isError && ( +
+ +

Errore nel caricamento dei dati.

+
+ )} + + {/* Stato vuoto */} + {!isLoading && !isError && items.length === 0 && ( +
+ +

Nessun evento trovato.

+
+ )} + + {/* Dati */} + {items.length > 0 && ( +
+ + + + + + + + + + + + + {items.map((entry) => ( + + {/* Data/ora */} + + + {/* Azione */} + + + {/* Esito */} + + + {/* Risorsa */} + + + {/* IP */} + + + {/* Utente (UUID abbreviato) */} + + + ))} + +
+ Data / Ora + + Azione + + Esito + + Risorsa + + IP + + Utente +
+ {format(new Date(entry.occurred_at), 'dd/MM/yyyy HH:mm:ss', { locale: it })} + + + {actionLabel(entry.action)} + + + ({entry.action}) + + + + + {entry.resource_type ? ( + + {entry.resource_type} + {entry.resource_id && ( + + {entry.resource_id.split('-')[0]}... + + )} + + ) : ( + + )} + + {entry.ip_address ?? } + + {entry.user_id ? ( + + {entry.user_id.split('-')[0]}... + + ) : ( + + )} +
+
+ )} + + {/* Paginazione */} + {pages > 1 && ( +
+ + + {page} / {pages} + + +
+ )} +
+
+ ) +} diff --git a/frontend/src/types/api.types.ts b/frontend/src/types/api.types.ts index 69e8f72..3114757 100644 --- a/frontend/src/types/api.types.ts +++ b/frontend/src/types/api.types.ts @@ -578,6 +578,14 @@ export interface WsEvent { // ─── API Pagination ────────────────────────────────────────────────────────── +export interface PaginatedResponse { + items: T[] + total: number + page: number + page_size: number + pages: number +} + export interface PaginationParams { page?: number page_size?: number