Versamento su API AgID

This commit is contained in:
2026-03-19 14:43:36 +01:00
parent 06dfbfcbc4
commit 4e19090f0f
12 changed files with 1319 additions and 3 deletions
+59
View File
@@ -0,0 +1,59 @@
"""
Router Impostazioni Tenant (Fase 6).
Endpoint:
GET /settings → legge le impostazioni del tenant corrente (admin)
PUT /settings → aggiorna le impostazioni del tenant corrente (admin)
Solo gli admin e super_admin possono accedere.
La sezione "archiviazione" gestisce il toggle mock/produzione per il
conservatore AgID (Fase 6 Archiviazione Sostitutiva).
"""
from fastapi import APIRouter
from app.dependencies import AdminUser, DB
from app.schemas.tenant_settings import TenantSettingsResponse, TenantSettingsUpdate
from app.services.tenant_settings_service import TenantSettingsService
router = APIRouter(prefix="/settings", tags=["Impostazioni"])
@router.get(
"",
response_model=TenantSettingsResponse,
summary="Legge le impostazioni del tenant",
description=(
"Restituisce la configurazione operativa del tenant: "
"modalità archiviazione (mock/produzione), endpoint e stato credenziali conservatore."
),
)
async def get_settings(
current_user: AdminUser,
db: DB,
) -> TenantSettingsResponse:
service = TenantSettingsService(db)
settings = await service.get_or_create(current_user.tenant_id)
return TenantSettingsService.to_response(settings)
@router.put(
"",
response_model=TenantSettingsResponse,
summary="Aggiorna le impostazioni del tenant",
description=(
"Aggiorna la configurazione operativa del tenant. "
"Tutti i campi sono opzionali (semantica PATCH). "
"Il passaggio a modalità 'production' richiede un endpoint conservatore configurato."
),
)
async def update_settings(
body: TenantSettingsUpdate,
current_user: AdminUser,
db: DB,
) -> TenantSettingsResponse:
service = TenantSettingsService(db)
settings = await service.update(current_user.tenant_id, body)
await db.commit()
await db.refresh(settings)
return TenantSettingsService.to_response(settings)
+8
View File
@@ -121,3 +121,11 @@ class ValidationError(HTTPException):
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
detail=detail,
)
class BadRequestError(HTTPException):
def __init__(self, detail: str = "Richiesta non valida") -> None:
super().__init__(
status_code=status.HTTP_400_BAD_REQUEST,
detail=detail,
)
+2
View File
@@ -14,6 +14,7 @@ from slowapi.middleware import SlowAPIMiddleware
from slowapi.util import get_remote_address
from app.api.v1 import auth, labels, mailboxes, messages, notifications, permissions, 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
from app.database import engine
@@ -94,6 +95,7 @@ app.include_router(ws.router, prefix=API_PREFIX)
app.include_router(virtual_boxes.router, prefix=API_PREFIX)
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)
# ─── Health check ─────────────────────────────────────────────────────────────
+1
View File
@@ -9,3 +9,4 @@ from app.models.label import Label, MessageLabel # noqa: F401
from app.models.permission import MailboxPermission # noqa: F401
from app.models.virtual_box import VirtualBox, VirtualBoxRule, VirtualBoxAssignment # noqa: F401
from app.models.notification import NotificationChannel, NotificationRule, NotificationLog # noqa: F401
from app.models.tenant_settings import TenantSettings # noqa: F401
+70
View File
@@ -0,0 +1,70 @@
"""
Modello TenantSettings configurazione per-tenant (archiviazione sostitutiva, ecc.).
"""
import uuid
from datetime import datetime
from sqlalchemy import DateTime, Index, String, Text, UniqueConstraint, func
from sqlalchemy.dialects.postgresql import UUID
from sqlalchemy.orm import Mapped, mapped_column
from app.database import Base
class TenantSettings(Base):
"""
Configurazione operativa per tenant.
Attualmente gestisce:
- Modalità archiviazione sostitutiva (mock / production)
- Credenziali conservatore AgID (cifrate AES-256-GCM)
Una riga per tenant, creata al primo accesso (upsert).
"""
__tablename__ = "tenant_settings"
id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True), primary_key=True, default=uuid.uuid4
)
tenant_id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True), nullable=False, index=True
)
# ── Archiviazione sostitutiva ─────────────────────────────────────────────
# 'mock' → conservatore simulato (risposte false, default per dev)
# 'production' → endpoint reale AgID conservatore
archival_mode: Mapped[str] = mapped_column(
String(20), nullable=False, default="mock"
)
# Identificativo conservatore (es. 'aruba-cons', 'docuvision', 'mock')
conservatore_id: Mapped[str] = mapped_column(
String(100), nullable=False, default="mock"
)
# URL endpoint API conservatore (None in modalità mock)
conservatore_endpoint: Mapped[str | None] = mapped_column(Text, nullable=True)
# Credenziali cifrate AES-256-GCM (None in modalità mock)
conservatore_username_enc: Mapped[str | None] = mapped_column(Text, nullable=True)
conservatore_password_enc: Mapped[str | None] = mapped_column(Text, nullable=True)
# Note operative opzionali
archival_notes: Mapped[str | None] = mapped_column(Text, nullable=True)
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), nullable=False, server_default=func.now()
)
updated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), nullable=False, server_default=func.now(), onupdate=func.now()
)
__table_args__ = (
UniqueConstraint("tenant_id", name="uq_tenant_settings_tenant"),
Index("idx_tenant_settings_tenant", "tenant_id"),
)
def __repr__(self) -> str:
return f"<TenantSettings tenant={self.tenant_id} mode={self.archival_mode!r}>"
+70
View File
@@ -0,0 +1,70 @@
"""
Schema Pydantic per TenantSettings lettura e aggiornamento impostazioni tenant.
"""
import uuid
from datetime import datetime
from typing import Literal
from pydantic import BaseModel, Field, HttpUrl, field_validator
ArchivalMode = Literal["mock", "production"]
class TenantSettingsResponse(BaseModel):
"""
Risposta GET /settings.
Le credenziali del conservatore non vengono mai esposte in chiaro:
vengono restituite come flag booleani (is_configured).
"""
id: uuid.UUID
tenant_id: uuid.UUID
# Archiviazione
archival_mode: ArchivalMode
conservatore_id: str
conservatore_endpoint: str | None
conservatore_username_configured: bool # TRUE se la username è già salvata
conservatore_password_configured: bool # TRUE se la password è già salvata
archival_notes: str | None
created_at: datetime
updated_at: datetime
model_config = {"from_attributes": True}
class TenantSettingsUpdate(BaseModel):
"""
Body PUT /settings.
Tutti i campi sono opzionali (PATCH semantics).
Le credenziali vengono aggiornate solo se fornite.
Un valore esplicitamente None può essere usato per cancellare le credenziali.
"""
archival_mode: ArchivalMode | None = None
conservatore_id: str | None = Field(
default=None, min_length=1, max_length=100
)
# URL endpoint del conservatore (obbligatorio in produzione, ignorato in mock)
conservatore_endpoint: str | None = None
# Credenziali in chiaro: vengono cifrate prima del salvataggio.
# Valore stringa vuota ("") = cancella la credenziale.
conservatore_username: str | None = None
conservatore_password: str | None = None
archival_notes: str | None = None
@field_validator("archival_mode")
@classmethod
def validate_archival_mode(cls, v: str | None) -> str | None:
if v is not None and v not in ("mock", "production"):
raise ValueError("archival_mode deve essere 'mock' o 'production'")
return v
@@ -0,0 +1,152 @@
"""
Servizio TenantSettings lettura e aggiornamento configurazione per-tenant.
Responsabilità:
- Upsert della riga settings (crea se non esiste, aggiorna altrimenti)
- Cifratura/decifratura AES-256-GCM delle credenziali conservatore
- Validazione di consistenza (produzione richiede endpoint + credenziali)
"""
import uuid
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.exceptions import BadRequestError
from app.core.security import decrypt_credential, encrypt_credential
from app.models.tenant_settings import TenantSettings
from app.schemas.tenant_settings import TenantSettingsResponse, TenantSettingsUpdate
class TenantSettingsService:
def __init__(self, db: AsyncSession) -> None:
self.db = db
# ─── Lettura (o creazione defaults) ──────────────────────────────────────
async def get_or_create(self, tenant_id: uuid.UUID) -> TenantSettings:
"""
Restituisce le impostazioni del tenant.
Se non esistono, crea una riga con i valori di default (mock).
"""
result = await self.db.execute(
select(TenantSettings).where(TenantSettings.tenant_id == tenant_id)
)
settings = result.scalar_one_or_none()
if settings is None:
settings = TenantSettings(
tenant_id=tenant_id,
archival_mode="mock",
conservatore_id="mock",
)
self.db.add(settings)
await self.db.flush()
return settings
# ─── Aggiornamento ────────────────────────────────────────────────────────
async def update(
self,
tenant_id: uuid.UUID,
data: TenantSettingsUpdate,
) -> TenantSettings:
"""
Aggiornamento parziale delle impostazioni.
Regole:
- Il passaggio a 'production' è consentito solo se conservatore_endpoint
è già configurato oppure viene fornito nel body corrente.
- Le credenziali vengono cifrate prima del salvataggio.
- Una stringa vuota "" su username/password cancella la credenziale.
"""
settings = await self.get_or_create(tenant_id)
# Applica aggiornamenti campi non-credenziali
if data.archival_mode is not None:
settings.archival_mode = data.archival_mode
if data.conservatore_id is not None:
settings.conservatore_id = data.conservatore_id
if data.conservatore_endpoint is not None:
settings.conservatore_endpoint = data.conservatore_endpoint or None
if data.archival_notes is not None:
settings.archival_notes = data.archival_notes or None
# Aggiorna credenziali (cifra se fornite, cancella se stringa vuota)
if data.conservatore_username is not None:
if data.conservatore_username == "":
settings.conservatore_username_enc = None
else:
settings.conservatore_username_enc = encrypt_credential(
data.conservatore_username
)
if data.conservatore_password is not None:
if data.conservatore_password == "":
settings.conservatore_password_enc = None
else:
settings.conservatore_password_enc = encrypt_credential(
data.conservatore_password
)
# Validazione: produzione richiede endpoint configurato
if settings.archival_mode == "production":
if not settings.conservatore_endpoint:
raise BadRequestError(
"La modalità produzione richiede un endpoint conservatore valido. "
"Inserisci l'URL prima di attivare la modalità produzione."
)
await self.db.flush()
return settings
# ─── Costruzione response ─────────────────────────────────────────────────
@staticmethod
def to_response(settings: TenantSettings) -> TenantSettingsResponse:
"""
Converte il modello in DTO di risposta.
Le credenziali NON vengono mai esposte in chiaro.
"""
return TenantSettingsResponse(
id=settings.id,
tenant_id=settings.tenant_id,
archival_mode=settings.archival_mode, # type: ignore[arg-type]
conservatore_id=settings.conservatore_id,
conservatore_endpoint=settings.conservatore_endpoint,
conservatore_username_configured=settings.conservatore_username_enc is not None,
conservatore_password_configured=settings.conservatore_password_enc is not None,
archival_notes=settings.archival_notes,
created_at=settings.created_at,
updated_at=settings.updated_at,
)
# ─── Helper per il worker (recupera credenziali decifrate) ───────────────
async def get_conservatore_credentials(
self, tenant_id: uuid.UUID
) -> dict:
"""
Restituisce le credenziali del conservatore decifrate.
Usato dal worker per effettuare versamenti.
"""
settings = await self.get_or_create(tenant_id)
return {
"mode": settings.archival_mode,
"conservatore_id": settings.conservatore_id,
"endpoint": settings.conservatore_endpoint,
"username": (
decrypt_credential(settings.conservatore_username_enc)
if settings.conservatore_username_enc
else None
),
"password": (
decrypt_credential(settings.conservatore_password_enc)
if settings.conservatore_password_enc
else None
),
}