mirror of
https://github.com/idrainformatica/PecFlow.git
synced 2026-06-16 12:45:42 +02:00
Audit Log
This commit is contained in:
@@ -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,
|
||||
)
|
||||
@@ -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) ───────────────────
|
||||
|
||||
|
||||
@@ -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},
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user