Fix notification System

This commit is contained in:
2026-03-27 15:13:14 +01:00
parent a3247a69b6
commit e390d344ff
13 changed files with 1692 additions and 46 deletions
+182 -44
View File
@@ -1,20 +1,24 @@
"""
Servizio Notifiche Multi-canale CRUD canali, regole, log.
Servizio Notifiche Multi-canale CRUD canali, regole, log + dispatch.
Nota: la cifratura AES-256-GCM di config_enc avviene qui usando
la NOTIFICATION_SECRET_KEY dalla config. Per semplicità in questo
stub usiamo Fernet (libreria cryptography), facilmente sostituibile
con una implementazione GCM dedicata.
Cifratura: AES-256-GCM via libreria cryptography.
Formato config_enc: base64( nonce(12) || ciphertext+tag )
Chiave: ENCRYPTION_KEY (hex 64 char = 32 byte) dalla config.
Backward compatibility: se il valore non decrittografa come GCM, viene
tentato il fallback a base64 grezzo (configurazioni precedenti al fix).
"""
import base64
import json
import logging
import os
import uuid
from datetime import datetime, timezone
from cryptography.hazmat.primitives.ciphers.aead import AESGCM
from sqlalchemy import func, select
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import selectinload
from app.config import get_settings
from app.core.exceptions import NotFoundError
@@ -27,20 +31,53 @@ from app.schemas.notification import (
NotificationRuleUpdate,
)
logger = logging.getLogger(__name__)
settings = get_settings()
def _encrypt(data: dict) -> str:
"""Cifra un dict JSON → base64. Usa la SECRET_KEY come seed."""
# In produzione: usa AES-256-GCM. Qui: semplice base64 con marker.
raw = json.dumps(data).encode()
return base64.b64encode(raw).decode()
# ─── Cifratura AES-256-GCM ────────────────────────────────────────────────────
def _encrypt(data: dict, key: bytes | None = None) -> str:
"""
Cifra un dict JSON con AES-256-GCM.
Formato output: base64( nonce(12 byte) || ciphertext+tag(16 byte) )
"""
if key is None:
key = settings.encryption_key_bytes
nonce = os.urandom(12)
aesgcm = AESGCM(key)
plaintext = json.dumps(data, ensure_ascii=False).encode("utf-8")
ciphertext = aesgcm.encrypt(nonce, plaintext, None) # include tag
return base64.b64encode(nonce + ciphertext).decode("ascii")
def _decrypt(enc: str) -> dict:
"""Decifra il valore restituito da _encrypt."""
raw = base64.b64decode(enc.encode())
return json.loads(raw.decode())
def _decrypt(enc: str, key: bytes | None = None) -> dict:
"""
Decifra il valore prodotto da _encrypt.
Backward compatible: se il dato non e' GCM valido, prova il
vecchio base64 grezzo (usato prima del fix di sicurezza).
"""
if key is None:
key = settings.encryption_key_bytes
try:
raw = base64.b64decode(enc.encode("ascii"))
if len(raw) > 28: # 12 nonce + 16 tag minimo
nonce = raw[:12]
ciphertext = raw[12:]
aesgcm = AESGCM(key)
plaintext = aesgcm.decrypt(nonce, ciphertext, None)
return json.loads(plaintext.decode("utf-8"))
except Exception:
pass
# Fallback: base64 grezzo (configurazioni create prima del fix GCM)
try:
raw = base64.b64decode(enc.encode("ascii"))
return json.loads(raw.decode("utf-8"))
except Exception:
return {}
class NotificationService:
@@ -113,6 +150,7 @@ class NotificationService:
if data.config is not None:
channel.config = data.config
if data.config_secret is not None:
# Re-cifra sempre con AES-256-GCM (aggiorna anche i vecchi base64)
channel.config_enc = _encrypt(data.config_secret)
await self.db.flush()
@@ -128,18 +166,16 @@ class NotificationService:
self, channel_id: uuid.UUID, tenant_id: uuid.UUID
) -> ChannelTestResult:
"""
Invia un messaggio di test al canale configurato.
Invia un messaggio di test reale al canale configurato.
Questa implementazione stub restituisce sempre successo se il canale
è attivo e configurato. Una implementazione completa fa una chiamata
reale al canale (HTTP/SMTP/Telegram/WhatsApp).
Esegue invio effettivo per Telegram, Webhook, Email SMTP e WhatsApp.
"""
channel = await self.get_channel(channel_id, tenant_id)
if not channel.is_active:
return ChannelTestResult(
success=False,
message="Il canale è disabilitato",
message="Il canale e' disabilitato",
)
if channel.circuit_open_until and channel.circuit_open_until > datetime.now(timezone.utc):
@@ -148,21 +184,23 @@ class NotificationService:
message=f"Circuit breaker aperto fino a {channel.circuit_open_until.isoformat()}",
)
# Validazione configurazione minima per tipo canale
config = channel.config or {}
secret = {}
if channel.config_enc:
try:
secret = _decrypt(channel.config_enc)
except Exception as e:
return ChannelTestResult(
success=False,
message=f"Errore decifratura configurazione sensibile: {e}",
)
channel_type = channel.channel_type
if channel_type == "webhook":
if not config.get("url"):
return ChannelTestResult(success=False, message="URL webhook non configurato")
elif channel_type == "email":
if not config.get("to_email"):
return ChannelTestResult(success=False, message="Email destinatario non configurata")
elif channel_type == "telegram":
# ── Telegram ──────────────────────────────────────────────────────────
if channel_type == "telegram":
if not config.get("chat_id"):
return ChannelTestResult(success=False, message="Chat ID Telegram non configurato")
# Invio reale via Bot API
secret = _decrypt(channel.config_enc) if channel.config_enc else {}
bot_token = secret.get("bot_token")
if not bot_token:
return ChannelTestResult(success=False, message="Bot token Telegram non configurato")
@@ -176,28 +214,128 @@ class NotificationService:
msg_id = result.get("message_id")
return ChannelTestResult(
success=True,
message=f"Messaggio Telegram inviato con successo (message_id={msg_id}).",
message=f"Messaggio Telegram inviato (message_id={msg_id}).",
http_status=200,
)
except TelegramError as exc:
return ChannelTestResult(
success=False,
message=f"Errore Telegram: {exc}",
http_status=exc.http_status,
)
except Exception as exc:
return ChannelTestResult(
success=False,
message=f"Errore imprevisto durante il test Telegram: {exc}",
message=f"Errore Telegram: {exc}",
)
elif channel_type == "whatsapp":
if not config.get("phone_number"):
return ChannelTestResult(success=False, message="Numero WhatsApp non configurato")
# ── Webhook ───────────────────────────────────────────────────────────
elif channel_type == "webhook":
url = config.get("url")
if not url:
return ChannelTestResult(success=False, message="URL webhook non configurato")
webhook_secret = secret.get("webhook_secret")
try:
from app.notifications.webhook import WebhookError, send_test_webhook
result = await send_test_webhook(
url=url,
webhook_secret=webhook_secret,
channel_name=channel.name,
)
return ChannelTestResult(
success=True,
message=(
f"Webhook raggiunto con successo "
f"(HTTP {result['http_status']}, delivery={result['delivery_id']})."
),
http_status=result["http_status"],
)
except Exception as exc:
http_status = getattr(exc, "http_status", None)
return ChannelTestResult(
success=False,
message=f"Errore webhook: {exc}",
http_status=http_status,
)
# ── Email SMTP ────────────────────────────────────────────────────────
elif channel_type == "email":
smtp_host = config.get("smtp_host")
smtp_port = config.get("smtp_port", 465)
from_email = config.get("from_email")
to_email = config.get("to_email")
smtp_user = config.get("smtp_user") or from_email
use_tls = config.get("smtp_use_tls", True)
use_starttls = config.get("smtp_use_starttls", False)
from_name = config.get("from_name", "PEChub Notifiche")
smtp_password = secret.get("smtp_password", "")
if not smtp_host:
return ChannelTestResult(success=False, message="Host SMTP non configurato")
if not from_email:
return ChannelTestResult(success=False, message="Email mittente non configurata")
if not to_email:
return ChannelTestResult(success=False, message="Email destinatario non configurata")
if not smtp_password:
return ChannelTestResult(success=False, message="Password SMTP non configurata")
try:
from app.notifications.email_smtp import EmailSMTPError, send_test_email
await send_test_email(
smtp_host=smtp_host,
smtp_port=int(smtp_port),
smtp_user=smtp_user,
smtp_password=smtp_password,
from_email=from_email,
to_email=to_email,
channel_name=channel.name,
from_name=from_name,
use_tls=use_tls,
use_starttls=use_starttls,
)
return ChannelTestResult(
success=True,
message=f"Email di test inviata con successo a {to_email}.",
http_status=200,
)
except Exception as exc:
return ChannelTestResult(
success=False,
message=f"Errore email: {exc}",
)
# ── WhatsApp ──────────────────────────────────────────────────────────
elif channel_type == "whatsapp":
phone_number_id = config.get("phone_number_id")
to_phone = config.get("to_phone")
access_token = secret.get("access_token")
if not phone_number_id:
return ChannelTestResult(success=False, message="phone_number_id non configurato")
if not to_phone:
return ChannelTestResult(success=False, message="Numero WhatsApp destinatario non configurato")
if not access_token:
return ChannelTestResult(success=False, message="Access token Meta non configurato")
try:
from app.notifications.whatsapp import WhatsAppError, send_test_whatsapp
result = await send_test_whatsapp(
phone_number_id=phone_number_id,
to_phone=to_phone,
access_token=access_token,
channel_name=channel.name,
)
return ChannelTestResult(
success=True,
message=f"Messaggio WhatsApp inviato (message_id={result.get('message_id')}).",
http_status=200,
)
except Exception as exc:
http_status = getattr(exc, "http_status", None)
return ChannelTestResult(
success=False,
message=f"Errore WhatsApp: {exc}",
http_status=http_status,
)
# ── Tipo sconosciuto ──────────────────────────────────────────────────
return ChannelTestResult(
success=True,
message=f"Canale {channel_type} configurato correttamente. Test simulato con successo.",
http_status=200,
success=False,
message=f"Tipo canale '{channel_type}' non supportato",
)
# ─── Rules ───────────────────────────────────────────────────────────────