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
+75 -18
View File
@@ -1,8 +1,9 @@
"""
Router Audit Log consultazione degli eventi di sistema.
Router Audit Log consultazione ed esportazione degli eventi di sistema.
Endpoint:
GET /api/v1/audit-log lista paginata con filtri (solo admin/super_admin)
GET /api/v1/audit-log lista paginata con filtri (solo admin/super_admin)
GET /api/v1/audit-log/export esportazione CSV o PDF (solo admin/super_admin)
Permessi:
- admin: vede solo gli eventi del proprio tenant
@@ -14,6 +15,7 @@ from datetime import datetime
from typing import Optional
from fastapi import APIRouter, Query
from fastapi.responses import Response, StreamingResponse
from app.dependencies import AdminUser, DB
from app.schemas.audit_log import AuditLogListResponse
@@ -22,6 +24,13 @@ from app.services.audit_service import AuditService
router = APIRouter(prefix="/audit-log", tags=["Audit Log"])
def _effective_tenant_id(current_user, tenant_id: Optional[uuid.UUID]) -> Optional[uuid.UUID]:
"""Determina il tenant_id effettivo in base al ruolo dell'utente."""
if current_user.is_super_admin:
return tenant_id # None = tutti i tenant
return current_user.tenant_id # sempre vincolato al proprio tenant
@router.get("", response_model=AuditLogListResponse)
async def list_audit_log(
current_user: AdminUser,
@@ -36,24 +45,10 @@ async def list_audit_log(
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.
"""
"""Restituisce la lista paginata degli eventi di audit."""
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,
tenant_id=_effective_tenant_id(current_user, tenant_id),
page=page,
page_size=page_size,
action=action,
@@ -63,3 +58,65 @@ async def list_audit_log(
date_to=date_to,
resource_type=resource_type,
)
@router.get("/export")
async def export_audit_log(
current_user: AdminUser,
db: DB,
format: str = Query("csv", pattern="^(csv|pdf)$", description="Formato: csv o pdf"),
action: Optional[str] = Query(None),
user_id: Optional[uuid.UUID] = Query(None),
outcome: Optional[str] = Query(None, pattern="^(success|failure)$"),
date_from: Optional[datetime] = Query(None),
date_to: Optional[datetime] = Query(None),
resource_type: Optional[str] = Query(None),
tenant_id: Optional[uuid.UUID] = Query(None),
) -> Response:
"""
Esporta i log di audit in formato CSV o PDF.
Applica gli stessi filtri dell'endpoint lista.
"""
svc = AuditService(db)
effective_tid = _effective_tenant_id(current_user, tenant_id)
# Nome file con periodo
suffix = ""
if date_from:
suffix += f"_dal_{date_from.strftime('%Y%m%d')}"
if date_to:
suffix += f"_al_{date_to.strftime('%Y%m%d')}"
if format == "csv":
csv_content = await svc.export_csv(
tenant_id=effective_tid,
action=action,
user_id=user_id,
outcome=outcome,
date_from=date_from,
date_to=date_to,
resource_type=resource_type,
)
filename = f"audit_log{suffix}.csv"
return Response(
content=csv_content.encode("utf-8-sig"), # BOM per compatibilita' Excel
media_type="text/csv; charset=utf-8",
headers={"Content-Disposition": f'attachment; filename="{filename}"'},
)
else: # pdf
pdf_bytes = await svc.export_pdf_bytes(
tenant_id=effective_tid,
action=action,
user_id=user_id,
outcome=outcome,
date_from=date_from,
date_to=date_to,
resource_type=resource_type,
)
filename = f"audit_log{suffix}.pdf"
return Response(
content=pdf_bytes,
media_type="application/pdf",
headers={"Content-Disposition": f'attachment; filename="{filename}"'},
)
+4 -3
View File
@@ -33,6 +33,7 @@ from app.schemas.auth import (
TokenResponse,
)
from app.schemas.user import UserResponse
from app.services.audit_service import get_real_ip
from app.services.auth_service import AuthService
settings = get_settings()
@@ -51,7 +52,7 @@ async def login(
body: LoginRequest,
db: DB,
) -> TokenResponse:
ip = request.client.host if request.client else None
ip = get_real_ip(request)
ua = request.headers.get("user-agent")
service = AuthService(db)
@@ -177,7 +178,7 @@ async def change_password(
tenant_id=current_user.tenant_id,
user_id=current_user.id,
outcome="failure",
ip_address=request.client.host if request.client else None,
ip_address=get_real_ip(request),
user_agent=request.headers.get("user-agent"),
payload={"reason": "wrong_current_password"},
)
@@ -189,6 +190,6 @@ async def change_password(
"auth.password_changed",
tenant_id=current_user.tenant_id,
user_id=current_user.id,
ip_address=request.client.host if request.client else None,
ip_address=get_real_ip(request),
user_agent=request.headers.get("user-agent"),
)
+159 -2
View File
@@ -23,12 +23,13 @@ import uuid
from datetime import datetime, timezone
from typing import Optional
from fastapi import APIRouter, Query, status
from fastapi import APIRouter, Query, Request, status
from fastapi.responses import StreamingResponse
from sqlalchemy import func, or_, select
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import selectinload
from app.services.audit_service import get_real_ip
from app.services.search_service import SearchService
from app.config import get_settings
@@ -38,12 +39,14 @@ from app.dependencies import CurrentUser, DB
from app.models.label import Label
from app.models.message import Attachment, Message
from app.schemas.message import (
AttachmentMatchInfo,
AttachmentResponse,
MessageBulkUpdateRequest,
MessageBulkUpdateResponse,
MessageListResponse,
MessageResponse,
MessageUpdateRequest,
SearchMatchInfo,
)
router = APIRouter(prefix="/messages", tags=["Messages"])
@@ -330,8 +333,45 @@ async def list_messages(
result = await db.execute(q)
items = list(result.scalars().all())
# ── Popola search_match per i risultati di ricerca ────────────────────────
if search and items:
term_lower = search.lower()
msg_ids = [m.id for m in items]
term_like = f"%{search}%"
# Query batch: allegati con extracted_text che matcha il termine
att_result = await db.execute(
select(Attachment.id, Attachment.message_id, Attachment.filename)
.where(
Attachment.message_id.in_(msg_ids),
Attachment.extracted_text.ilike(term_like),
)
)
# Mappa message_id → lista di AttachmentMatchInfo che matchano
att_matches: dict[uuid.UUID, list[AttachmentMatchInfo]] = {}
for row in att_result.fetchall():
att_id, msg_id, filename = row
att_matches.setdefault(msg_id, []).append(
AttachmentMatchInfo(id=att_id, filename=filename)
)
message_responses: list[MessageResponse] = []
for m in items:
resp = MessageResponse.model_validate(m)
in_subject = bool(m.subject and term_lower in m.subject.lower())
in_body = bool(m.body_text and term_lower in m.body_text.lower())
in_attachments = att_matches.get(m.id, [])
resp.search_match = SearchMatchInfo(
in_subject=in_subject,
in_body=in_body,
in_attachments=in_attachments,
)
message_responses.append(resp)
else:
message_responses = [MessageResponse.model_validate(m) for m in items]
return MessageListResponse(
items=[MessageResponse.model_validate(m) for m in items],
items=message_responses,
total=total,
page=page,
page_size=page_size,
@@ -340,6 +380,7 @@ async def list_messages(
@router.patch("/bulk", response_model=MessageBulkUpdateResponse)
async def bulk_update_messages(
request: Request,
data: MessageBulkUpdateRequest,
current_user: CurrentUser,
db: DB,
@@ -349,6 +390,8 @@ async def bulk_update_messages(
Per is_pending_conservation=True o is_conserved=True richiede can_conserve.
"""
from app.services.audit_service import log_audit
if not data.ids:
return MessageBulkUpdateResponse(updated=0, items=[])
@@ -415,6 +458,29 @@ async def bulk_update_messages(
elif not data.is_conserved:
message.conserved_at = None
# Registra evento audit bulk
if messages:
changes: dict = {}
for field in ("is_read", "is_starred", "is_archived", "is_trashed",
"is_pending_conservation", "is_conserved"):
v = getattr(data, field, None)
if v is not None:
changes[field] = v
await log_audit(
db,
"message.bulk_updated",
tenant_id=current_user.tenant_id,
user_id=current_user.id,
resource_type="message",
ip_address=get_real_ip(request),
user_agent=request.headers.get("user-agent"),
payload={
"count": len(messages),
"message_ids": [str(m.id) for m in messages],
"changes": changes,
},
)
await db.commit()
if messages:
@@ -434,17 +500,37 @@ async def bulk_update_messages(
@router.get("/{message_id}", response_model=MessageResponse)
async def get_message(
request: Request,
message_id: uuid.UUID,
current_user: CurrentUser,
db: DB,
) -> MessageResponse:
"""Carica un messaggio per ID."""
from app.services.audit_service import log_audit
message = await _resolve_message(message_id, current_user, db)
await log_audit(
db,
"message.opened",
tenant_id=current_user.tenant_id,
user_id=current_user.id,
resource_type="message",
resource_id=message.id,
ip_address=get_real_ip(request),
user_agent=request.headers.get("user-agent"),
payload={
"subject": message.subject,
"from_address": message.from_address,
"direction": message.direction,
"mailbox_id": str(message.mailbox_id),
},
)
await db.commit()
return MessageResponse.model_validate(message)
@router.patch("/{message_id}", response_model=MessageResponse)
async def update_message(
request: Request,
message_id: uuid.UUID,
data: MessageUpdateRequest,
current_user: CurrentUser,
@@ -455,6 +541,8 @@ async def update_message(
Per is_pending_conservation=True o is_conserved=True richiede can_conserve.
"""
from app.services.audit_service import log_audit
message = await _resolve_message(message_id, current_user, db)
# Verifica permesso conservazione se necessario
@@ -464,6 +552,19 @@ async def update_message(
await perm_svc.require_can_conserve(current_user, message.mailbox_id)
now = datetime.now(timezone.utc)
ip = get_real_ip(request)
ua = request.headers.get("user-agent")
base_payload = {"subject": message.subject, "mailbox_id": str(message.mailbox_id)}
# Mappa flag → coppia (action_true, action_false)
_FLAG_ACTIONS: dict[str, tuple[str, str]] = {
"is_read": ("message.read", "message.unread"),
"is_starred": ("message.starred", "message.unstarred"),
"is_archived": ("message.archived", "message.unarchived"),
"is_trashed": ("message.trashed", "message.restored"),
"is_pending_conservation": ("message.pending_conservation","message.conservation_cancelled"),
"is_conserved": ("message.conserved", "message.conservation_removed"),
}
if data.is_read is not None:
message.is_read = data.is_read
@@ -494,6 +595,23 @@ async def update_message(
elif not data.is_conserved:
message.conserved_at = None
# Registra un evento di audit per ogni flag modificato
for field, (action_true, action_false) in _FLAG_ACTIONS.items():
value = getattr(data, field, None)
if value is not None:
action = action_true if value else action_false
await log_audit(
db,
action,
tenant_id=current_user.tenant_id,
user_id=current_user.id,
resource_type="message",
resource_id=message_id,
ip_address=ip,
user_agent=ua,
payload=base_payload,
)
await db.commit()
refreshed = await db.execute(
select(Message)
@@ -524,12 +642,15 @@ async def list_attachments(
@router.get("/{message_id}/attachments/{attachment_id}/download")
async def download_attachment(
request: Request,
message_id: uuid.UUID,
attachment_id: uuid.UUID,
current_user: CurrentUser,
db: DB,
) -> StreamingResponse:
"""Scarica un allegato direttamente da MinIO."""
from app.services.audit_service import log_audit
await _resolve_message(message_id, current_user, db)
result = await db.execute(
@@ -542,6 +663,23 @@ async def download_attachment(
if not attachment:
raise NotFoundError(f"Allegato {attachment_id} non trovato")
await log_audit(
db,
"message.attachment_downloaded",
tenant_id=current_user.tenant_id,
user_id=current_user.id,
resource_type="attachment",
resource_id=attachment.id,
ip_address=get_real_ip(request),
user_agent=request.headers.get("user-agent"),
payload={
"filename": attachment.filename,
"message_id": str(message_id),
"size_bytes": attachment.size_bytes,
},
)
await db.commit()
try:
from miniopy_async import Minio
@@ -580,6 +718,7 @@ async def download_attachment(
@router.get("/{message_id}/download-package")
async def download_package(
request: Request,
message_id: uuid.UUID,
current_user: CurrentUser,
db: DB,
@@ -588,6 +727,7 @@ async def download_package(
import io
import zipfile as _zipfile
from app.services.audit_service import log_audit
from miniopy_async import Minio
message = await _resolve_message(message_id, current_user, db)
@@ -682,6 +822,23 @@ async def download_package(
safe_subject = (message.subject or "pec").replace("/", "_").replace("\\", "_")[:50]
zip_filename = f"pec_{safe_subject}.zip"
await log_audit(
db,
"message.package_downloaded",
tenant_id=current_user.tenant_id,
user_id=current_user.id,
resource_type="message",
resource_id=message.id,
ip_address=get_real_ip(request),
user_agent=request.headers.get("user-agent"),
payload={
"subject": message.subject,
"zip_filename": zip_filename,
"zip_size_bytes": len(zip_bytes),
},
)
await db.commit()
return StreamingResponse(
iter([zip_bytes]),
media_type="application/zip",
+65 -1
View File
@@ -13,11 +13,12 @@ import json
import uuid
from typing import Annotated
from fastapi import APIRouter, File, Form, HTTPException, Query, UploadFile, status
from fastapi import APIRouter, File, Form, HTTPException, Query, Request, UploadFile, status
from app.core.exceptions import ForbiddenError
from app.dependencies import CurrentUser, DB
from app.schemas.send import SendJobListResponse, SendJobResponse, SendPecRequest
from app.services.audit_service import get_real_ip
from app.services.permission_service import PermissionService
from app.services.send_service import SendService
@@ -47,12 +48,33 @@ def _job_response(job) -> SendJobResponse:
),
)
async def create_send_job(
request: Request,
data: SendPecRequest,
current_user: CurrentUser,
db: DB,
) -> SendJobResponse:
from app.services.audit_service import log_audit
svc = _svc(db)
job = await svc.create_send_job(current_user=current_user, data=data)
await log_audit(
db,
"pec.sent",
tenant_id=current_user.tenant_id,
user_id=current_user.id,
resource_type="send_job",
resource_id=job.id,
ip_address=get_real_ip(request),
user_agent=request.headers.get("user-agent"),
payload={
"mailbox_id": str(job.mailbox_id),
"to_addresses": data.to_addresses,
"subject": data.subject,
"has_attachments": False,
},
)
await db.commit()
await db.refresh(job)
return _job_response(job)
@@ -74,6 +96,7 @@ async def create_send_job(
),
)
async def create_send_job_multipart(
request: Request,
current_user: CurrentUser,
db: DB,
data: str = Form(
@@ -85,6 +108,8 @@ async def create_send_job_multipart(
description="File allegati (0 o più, max 20 MB ciascuno)",
),
) -> SendJobResponse:
from app.services.audit_service import log_audit
# ── Parse del JSON ────────────────────────────────────────────────────────
try:
raw = json.loads(data)
@@ -128,6 +153,25 @@ async def create_send_job_multipart(
data=pec_data,
attachments=files_data if files_data else None,
)
await log_audit(
db,
"pec.sent",
tenant_id=current_user.tenant_id,
user_id=current_user.id,
resource_type="send_job",
resource_id=job.id,
ip_address=get_real_ip(request),
user_agent=request.headers.get("user-agent"),
payload={
"mailbox_id": str(job.mailbox_id),
"to_addresses": pec_data.to_addresses,
"subject": pec_data.subject,
"has_attachments": bool(files_data),
"attachment_count": len(files_data),
},
)
await db.commit()
await db.refresh(job)
return _job_response(job)
@@ -205,10 +249,13 @@ async def get_send_job(
summary="Annulla job di invio",
)
async def cancel_send_job(
request: Request,
job_id: uuid.UUID,
current_user: CurrentUser,
db: DB,
) -> None:
from app.services.audit_service import log_audit
svc = _svc(db)
job = await svc.get_send_job(job_id, current_user.tenant_id)
@@ -218,4 +265,21 @@ async def cancel_send_job(
raise ForbiddenError("Autorizzazione insufficiente per annullare questo invio")
await svc.cancel_send_job(job_id, current_user.tenant_id)
await log_audit(
db,
"pec.send_cancelled",
tenant_id=current_user.tenant_id,
user_id=current_user.id,
resource_type="send_job",
resource_id=job_id,
ip_address=get_real_ip(request),
user_agent=request.headers.get("user-agent"),
payload={
"job_id": str(job_id),
"mailbox_id": str(job.mailbox_id),
"previous_status": job.status,
},
)
await db.commit()
+174
View File
@@ -0,0 +1,174 @@
"""
Router firme automatiche.
Endpoint:
GET /signatures lista firme del tenant
POST /signatures crea firma (admin)
GET /signatures/{id} dettaglio firma
PUT /signatures/{id} aggiorna firma (admin)
DELETE /signatures/{id} elimina firma (admin)
GET /signatures/assignments lista assegnazioni (con filtri opzionali)
POST /signatures/assignments crea/sostituisce assegnazione (admin)
DELETE /signatures/assignments/{id} rimuovi assegnazione (admin)
"""
import uuid
from fastapi import APIRouter, Query, status
from app.dependencies import AdminUser, CurrentUser, DB
from app.schemas.signature import (
SignatureAssignmentCreate,
SignatureAssignmentListResponse,
SignatureAssignmentResponse,
SignatureCreate,
SignatureListResponse,
SignatureResponse,
SignatureUpdate,
)
from app.services.signature_service import SignatureService
router = APIRouter(tags=["Signatures"])
# ─── Firme ────────────────────────────────────────────────────────────────────
@router.get("/signatures", response_model=SignatureListResponse)
async def list_signatures(
current_user: CurrentUser,
db: DB,
q: str | None = Query(None, description="Filtro per nome"),
) -> SignatureListResponse:
"""Elenca le firme del tenant corrente."""
svc = SignatureService(db)
items, total = await svc.list_signatures(current_user.tenant_id, q=q)
return SignatureListResponse(
items=[SignatureResponse.model_validate(s) for s in items],
total=total,
)
@router.post("/signatures", response_model=SignatureResponse, status_code=status.HTTP_201_CREATED)
async def create_signature(
data: SignatureCreate,
current_user: AdminUser,
db: DB,
) -> SignatureResponse:
"""Crea una nuova firma (solo admin)."""
svc = SignatureService(db)
sig = await svc.create_signature(current_user.tenant_id, data, created_by=current_user.id)
return SignatureResponse.model_validate(sig)
@router.get("/signatures/assignments", response_model=SignatureAssignmentListResponse)
async def list_assignments(
current_user: AdminUser,
db: DB,
mailbox_id: uuid.UUID | None = Query(None),
virtual_box_id: uuid.UUID | None = Query(None),
) -> SignatureAssignmentListResponse:
"""Elenca le assegnazioni firma del tenant, con filtri opzionali."""
svc = SignatureService(db)
items, total = await svc.list_assignments(
current_user.tenant_id,
mailbox_id=mailbox_id,
virtual_box_id=virtual_box_id,
)
return SignatureAssignmentListResponse(
items=[SignatureAssignmentResponse.model_validate(i) for i in items],
total=total,
)
@router.post(
"/signatures/assignments",
response_model=SignatureAssignmentResponse,
status_code=status.HTTP_201_CREATED,
)
async def create_assignment(
data: SignatureAssignmentCreate,
current_user: AdminUser,
db: DB,
) -> SignatureAssignmentResponse:
"""
Crea (o sostituisce) l'assegnazione firma per una casella/vbox+contesto.
Se esiste gia' un'assegnazione per la stessa coppia, viene sovrascritta.
"""
svc = SignatureService(db)
result = await svc.create_assignment(current_user.tenant_id, data)
return SignatureAssignmentResponse.model_validate(result)
@router.delete(
"/signatures/assignments/{assignment_id}",
status_code=status.HTTP_204_NO_CONTENT,
)
async def delete_assignment(
assignment_id: uuid.UUID,
current_user: AdminUser,
db: DB,
) -> None:
"""Rimuove un'assegnazione firma (solo admin)."""
svc = SignatureService(db)
await svc.delete_assignment(current_user.tenant_id, assignment_id)
@router.get("/signatures/resolve", response_model=SignatureResponse | None)
async def resolve_signature(
current_user: CurrentUser,
db: DB,
context: str = Query("compose", description="Contesto: reply | compose"),
mailbox_id: uuid.UUID | None = Query(None),
virtual_box_id: uuid.UUID | None = Query(None),
) -> SignatureResponse | None:
"""
Restituisce la firma assegnata per una casella o virtual box nel contesto dato.
Usato dal ComposeModal per caricare automaticamente la firma.
"""
svc = SignatureService(db)
sig = await svc.resolve_signature(
current_user.tenant_id,
context=context,
mailbox_id=mailbox_id,
virtual_box_id=virtual_box_id,
)
if sig is None:
return None
return SignatureResponse.model_validate(sig)
@router.get("/signatures/{signature_id}", response_model=SignatureResponse)
async def get_signature(
signature_id: uuid.UUID,
current_user: CurrentUser,
db: DB,
) -> SignatureResponse:
"""Restituisce il dettaglio di una firma."""
svc = SignatureService(db)
sig = await svc.get_signature(current_user.tenant_id, signature_id)
return SignatureResponse.model_validate(sig)
@router.put("/signatures/{signature_id}", response_model=SignatureResponse)
async def update_signature(
signature_id: uuid.UUID,
data: SignatureUpdate,
current_user: AdminUser,
db: DB,
) -> SignatureResponse:
"""Aggiorna una firma esistente (solo admin)."""
svc = SignatureService(db)
sig = await svc.update_signature(current_user.tenant_id, signature_id, data)
return SignatureResponse.model_validate(sig)
@router.delete("/signatures/{signature_id}", status_code=status.HTTP_204_NO_CONTENT)
async def delete_signature(
signature_id: uuid.UUID,
current_user: AdminUser,
db: DB,
) -> None:
"""Elimina una firma (solo admin)."""
svc = SignatureService(db)
await svc.delete_signature(current_user.tenant_id, signature_id)