feat: Fase 1 – Fondamenta complete (backend FastAPI + auth + permessi)

- docker-compose.yml: PostgreSQL 16, Redis 7, MinIO, Nginx
- backend FastAPI: struttura monorepo, config pydantic-settings
- modelli SQLAlchemy: tutti i modelli (tenants, users, mailboxes, messages, archival, permissions, labels, audit_log)
- migrazione Alembic 0001: schema completo in pure SQL
- auth API: login JWT, refresh token rotation, logout, 2FA TOTP (setup/verify/disable)
- CRUD utenti: lista, crea, modifica, reset password, soft delete
- permessi granulari (Fase 1-A): mailbox_permissions, assegna/revoca/lista
- CRUD tenant: gestione super-admin
- sicurezza: AES-256-GCM cifratura credenziali IMAP/SMTP, bcrypt password
- RLS PostgreSQL: isolamento multi-tenant per request
- seed sviluppo: tenant demo + admin + operator
- test unit: security (bcrypt, JWT, AES), auth_service
- test integration: auth endpoints, users endpoints
- CI GitHub Actions: lint (ruff), test (pytest), build Docker, security scan
- infra: nginx.conf, redis.conf
- Makefile con comandi make dev/test/migrate/seed

Definition of Done:
 Login, refresh token e TOTP funzionanti
 make dev porta in piedi tutto lo stack locale
 CI configurata
This commit is contained in:
2026-03-18 16:42:01 +01:00
parent 0251c2bbb0
commit 58a233236c
60 changed files with 6942 additions and 0 deletions
+1
View File
@@ -0,0 +1 @@
# PecFlow Backend
+1
View File
@@ -0,0 +1 @@
# API routers
+1
View File
@@ -0,0 +1 @@
# API v1 routers
+173
View File
@@ -0,0 +1,173 @@
"""
Router autenticazione login, refresh, logout, 2FA TOTP.
Endpoint:
POST /api/v1/auth/login → access + refresh token
POST /api/v1/auth/refresh → rinnova token
POST /api/v1/auth/logout → revoca refresh token
GET /api/v1/auth/me → utente corrente
POST /api/v1/auth/totp/setup → genera segreto TOTP + QR
POST /api/v1/auth/totp/verify → verifica e attiva TOTP
POST /api/v1/auth/totp/disable → disabilita TOTP
POST /api/v1/auth/change-password → cambio password
"""
from typing import Annotated
from fastapi import APIRouter, Depends, Request
from slowapi import Limiter
from slowapi.util import get_remote_address
from sqlalchemy.ext.asyncio import AsyncSession
from app.config import get_settings
from app.core.exceptions import InvalidCredentialsError
from app.database import get_db
from app.dependencies import CurrentUser, DB
from app.schemas.auth import (
LoginRequest,
PasswordChangeRequest,
RefreshRequest,
TOTPSetupResponse,
TOTPStatusResponse,
TOTPVerifyRequest,
TokenResponse,
)
from app.schemas.user import UserResponse
from app.services.auth_service import AuthService
settings = get_settings()
router = APIRouter(prefix="/auth", tags=["Autenticazione"])
limiter = Limiter(key_func=get_remote_address)
@router.post(
"/login",
response_model=TokenResponse,
summary="Login con email e password",
description="Autentica l'utente. Se 2FA è attivo, richiede anche il codice TOTP.",
)
async def login(
request: Request,
body: LoginRequest,
db: DB,
) -> TokenResponse:
ip = request.client.host if request.client else None
ua = request.headers.get("user-agent")
service = AuthService(db)
access_token, refresh_token = await service.login(
email=body.email,
password=body.password,
totp_code=body.totp_code,
ip_address=ip,
user_agent=ua,
)
return TokenResponse(
access_token=access_token,
refresh_token=refresh_token,
expires_in=settings.access_token_expire_minutes * 60,
)
@router.post(
"/refresh",
response_model=TokenResponse,
summary="Rinnova access token",
)
async def refresh_tokens(
body: RefreshRequest,
db: DB,
) -> TokenResponse:
service = AuthService(db)
access_token, refresh_token = await service.refresh_tokens(body.refresh_token)
return TokenResponse(
access_token=access_token,
refresh_token=refresh_token,
expires_in=settings.access_token_expire_minutes * 60,
)
@router.post(
"/logout",
status_code=204,
summary="Logout revoca refresh token",
)
async def logout(
body: RefreshRequest,
db: DB,
) -> None:
service = AuthService(db)
await service.logout(body.refresh_token)
@router.get(
"/me",
response_model=UserResponse,
summary="Utente corrente autenticato",
)
async def me(current_user: CurrentUser) -> UserResponse:
return UserResponse.model_validate(current_user)
@router.post(
"/totp/setup",
response_model=TOTPSetupResponse,
summary="Avvia setup 2FA TOTP",
description="Genera segreto TOTP e QR code. Il 2FA viene attivato solo dopo la verifica.",
)
async def totp_setup(
current_user: CurrentUser,
db: DB,
) -> TOTPSetupResponse:
service = AuthService(db)
data = await service.setup_totp(current_user)
return TOTPSetupResponse(**data)
@router.post(
"/totp/verify",
response_model=TOTPStatusResponse,
summary="Verifica codice TOTP e attiva 2FA",
)
async def totp_verify(
body: TOTPVerifyRequest,
current_user: CurrentUser,
db: DB,
) -> TOTPStatusResponse:
service = AuthService(db)
await service.verify_and_enable_totp(current_user, body.totp_code)
return TOTPStatusResponse(totp_enabled=True)
@router.post(
"/totp/disable",
response_model=TOTPStatusResponse,
summary="Disabilita 2FA TOTP",
)
async def totp_disable(
current_user: CurrentUser,
db: DB,
) -> TOTPStatusResponse:
service = AuthService(db)
await service.disable_totp(current_user)
return TOTPStatusResponse(totp_enabled=False)
@router.post(
"/change-password",
status_code=204,
summary="Cambio password utente corrente",
)
async def change_password(
body: PasswordChangeRequest,
current_user: CurrentUser,
db: DB,
) -> None:
from app.core.security import verify_password, hash_password
if not verify_password(body.current_password, current_user.password_hash):
raise InvalidCredentialsError()
current_user.password_hash = hash_password(body.new_password)
+112
View File
@@ -0,0 +1,112 @@
"""
Router permessi granulari casella (Fase 1-A).
Endpoint:
POST /api/v1/permissions/mailboxes/{mailbox_id}/users/{user_id} → assegna permesso
DELETE /api/v1/permissions/mailboxes/{mailbox_id}/users/{user_id} → revoca permesso
GET /api/v1/permissions/mailboxes/{mailbox_id}/users → utenti con accesso
GET /api/v1/permissions/users/{user_id}/mailboxes → caselle accessibili
"""
import uuid
from fastapi import APIRouter
from app.dependencies import AdminUser, CurrentUser, DB
from app.schemas.permission import (
MailboxUserPermissionResponse,
PermissionGrantRequest,
PermissionResponse,
UserMailboxPermissionResponse,
)
from app.services.permission_service import PermissionService
router = APIRouter(prefix="/permissions", tags=["Permessi casella"])
@router.post(
"/mailboxes/{mailbox_id}/users/{user_id}",
response_model=PermissionResponse,
status_code=201,
summary="Assegna permesso utente su casella",
description="Crea o aggiorna i permessi di un utente su una specifica casella PEC.",
)
async def grant_permission(
mailbox_id: uuid.UUID,
user_id: uuid.UUID,
body: PermissionGrantRequest,
current_user: AdminUser,
db: DB,
) -> PermissionResponse:
service = PermissionService(db)
perm = await service.grant_permission(
tenant_id=current_user.tenant_id,
mailbox_id=mailbox_id,
user_id=user_id,
data=body,
granted_by=current_user,
)
return PermissionResponse.model_validate(perm)
@router.delete(
"/mailboxes/{mailbox_id}/users/{user_id}",
status_code=204,
summary="Revoca permesso utente su casella",
)
async def revoke_permission(
mailbox_id: uuid.UUID,
user_id: uuid.UUID,
current_user: AdminUser,
db: DB,
) -> None:
service = PermissionService(db)
await service.revoke_permission(
mailbox_id=mailbox_id,
user_id=user_id,
revoked_by=current_user,
)
@router.get(
"/mailboxes/{mailbox_id}/users",
response_model=list[MailboxUserPermissionResponse],
summary="Utenti con accesso a una casella",
)
async def list_mailbox_users(
mailbox_id: uuid.UUID,
current_user: AdminUser,
db: DB,
) -> list[MailboxUserPermissionResponse]:
service = PermissionService(db)
rows = await service.list_mailbox_users(mailbox_id, current_user.tenant_id)
return [MailboxUserPermissionResponse(**row) for row in rows]
@router.get(
"/users/{user_id}/mailboxes",
response_model=list[UserMailboxPermissionResponse],
summary="Caselle accessibili a un utente",
)
async def list_user_mailboxes(
user_id: uuid.UUID,
current_user: AdminUser,
db: DB,
) -> list[UserMailboxPermissionResponse]:
service = PermissionService(db)
rows = await service.list_user_mailboxes(user_id, current_user.tenant_id)
return [UserMailboxPermissionResponse(**row) for row in rows]
@router.get(
"/my/mailboxes",
response_model=list[UserMailboxPermissionResponse],
summary="Caselle accessibili all'utente corrente",
)
async def my_mailboxes(
current_user: CurrentUser,
db: DB,
) -> list[UserMailboxPermissionResponse]:
service = PermissionService(db)
rows = await service.list_user_mailboxes(current_user.id, current_user.tenant_id)
return [UserMailboxPermissionResponse(**row) for row in rows]
+80
View File
@@ -0,0 +1,80 @@
"""
Router tenant gestione organizzazioni (solo super_admin).
Endpoint:
GET /api/v1/tenants → lista tenant
POST /api/v1/tenants → crea tenant + admin
GET /api/v1/tenants/{id} → dettaglio tenant
PATCH /api/v1/tenants/{id} → modifica tenant
"""
import uuid
from fastapi import APIRouter
from app.dependencies import SuperAdminUser, DB
from app.schemas.tenant import TenantCreateRequest, TenantResponse, TenantUpdateRequest
from app.services.tenant_service import TenantService
router = APIRouter(prefix="/tenants", tags=["Tenant (super-admin)"])
@router.get(
"",
response_model=list[TenantResponse],
summary="Lista tutti i tenant",
)
async def list_tenants(
_: SuperAdminUser,
db: DB,
) -> list[TenantResponse]:
service = TenantService(db)
tenants = await service.list_tenants()
return [TenantResponse.model_validate(t) for t in tenants]
@router.post(
"",
response_model=TenantResponse,
status_code=201,
summary="Crea nuovo tenant con admin iniziale",
)
async def create_tenant(
body: TenantCreateRequest,
_: SuperAdminUser,
db: DB,
) -> TenantResponse:
service = TenantService(db)
tenant, _ = await service.create_tenant(body)
return TenantResponse.model_validate(tenant)
@router.get(
"/{tenant_id}",
response_model=TenantResponse,
summary="Dettaglio tenant",
)
async def get_tenant(
tenant_id: uuid.UUID,
_: SuperAdminUser,
db: DB,
) -> TenantResponse:
service = TenantService(db)
tenant = await service.get_tenant(tenant_id)
return TenantResponse.model_validate(tenant)
@router.patch(
"/{tenant_id}",
response_model=TenantResponse,
summary="Modifica tenant",
)
async def update_tenant(
tenant_id: uuid.UUID,
body: TenantUpdateRequest,
_: SuperAdminUser,
db: DB,
) -> TenantResponse:
service = TenantService(db)
tenant = await service.update_tenant(tenant_id, body)
return TenantResponse.model_validate(tenant)
+147
View File
@@ -0,0 +1,147 @@
"""
Router utenti CRUD per admin del tenant.
Endpoint:
GET /api/v1/users → lista utenti (admin)
POST /api/v1/users → crea utente (admin)
GET /api/v1/users/{id} → dettaglio utente (admin)
PATCH /api/v1/users/{id} → modifica utente (admin)
DELETE /api/v1/users/{id} → disabilita utente (admin)
POST /api/v1/users/{id}/reset-password → reset password (admin)
"""
import uuid
from fastapi import APIRouter, Query
from app.dependencies import AdminUser, DB
from app.schemas.user import (
UserCreateRequest,
UserListResponse,
UserPasswordResetRequest,
UserResponse,
UserUpdateRequest,
)
from app.services.user_service import UserService
router = APIRouter(prefix="/users", tags=["Utenti"])
@router.get(
"",
response_model=UserListResponse,
summary="Lista utenti del tenant",
)
async def list_users(
current_user: AdminUser,
db: DB,
page: int = Query(default=1, ge=1),
page_size: int = Query(default=25, ge=1, le=100),
) -> UserListResponse:
service = UserService(db)
users, total = await service.list_users(
tenant_id=current_user.tenant_id,
page=page,
page_size=page_size,
)
import math
return UserListResponse(
items=[UserResponse.model_validate(u) for u in users],
total=total,
page=page,
page_size=page_size,
pages=math.ceil(total / page_size) if page_size else 0,
)
@router.post(
"",
response_model=UserResponse,
status_code=201,
summary="Crea nuovo utente nel tenant",
)
async def create_user(
body: UserCreateRequest,
current_user: AdminUser,
db: DB,
) -> UserResponse:
service = UserService(db)
user = await service.create_user(
tenant_id=current_user.tenant_id,
data=body,
created_by=current_user,
)
return UserResponse.model_validate(user)
@router.get(
"/{user_id}",
response_model=UserResponse,
summary="Dettaglio utente",
)
async def get_user(
user_id: uuid.UUID,
current_user: AdminUser,
db: DB,
) -> UserResponse:
service = UserService(db)
user = await service.get_user(user_id, current_user.tenant_id)
return UserResponse.model_validate(user)
@router.patch(
"/{user_id}",
response_model=UserResponse,
summary="Modifica utente",
)
async def update_user(
user_id: uuid.UUID,
body: UserUpdateRequest,
current_user: AdminUser,
db: DB,
) -> UserResponse:
service = UserService(db)
user = await service.update_user(
user_id=user_id,
tenant_id=current_user.tenant_id,
data=body,
updated_by=current_user,
)
return UserResponse.model_validate(user)
@router.delete(
"/{user_id}",
status_code=204,
summary="Disabilita utente (soft delete)",
)
async def delete_user(
user_id: uuid.UUID,
current_user: AdminUser,
db: DB,
) -> None:
service = UserService(db)
await service.delete_user(
user_id=user_id,
tenant_id=current_user.tenant_id,
deleted_by=current_user,
)
@router.post(
"/{user_id}/reset-password",
status_code=204,
summary="Reset password utente (admin)",
)
async def reset_password(
user_id: uuid.UUID,
body: UserPasswordResetRequest,
current_user: AdminUser,
db: DB,
) -> None:
service = UserService(db)
await service.reset_password(
user_id=user_id,
tenant_id=current_user.tenant_id,
new_password=body.new_password,
)
+90
View File
@@ -0,0 +1,90 @@
"""
Configurazione applicazione legge variabili d'ambiente tramite pydantic-settings.
"""
from functools import lru_cache
from typing import Literal
from pydantic import field_validator
from pydantic_settings import BaseSettings, SettingsConfigDict
class Settings(BaseSettings):
model_config = SettingsConfigDict(
env_file=".env",
env_file_encoding="utf-8",
case_sensitive=False,
extra="ignore",
)
# ── Applicazione ──────────────────────────────────────────────────────────
app_env: Literal["development", "staging", "production"] = "development"
app_debug: bool = True
app_host: str = "0.0.0.0"
app_port: int = 8000
app_base_url: str = "http://localhost:8000"
# ── Sicurezza / JWT ───────────────────────────────────────────────────────
secret_key: str = "change-me-in-production"
algorithm: str = "HS256"
access_token_expire_minutes: int = 15
refresh_token_expire_days: int = 30
# Chiave AES-256-GCM per cifratura credenziali IMAP/SMTP (hex 64 chars = 32 bytes)
encryption_key: str = "0" * 64
# ── Database ──────────────────────────────────────────────────────────────
database_url: str = "postgresql+asyncpg://pecflow:pecflow_dev_password@db:5432/pecflow"
database_url_sync: str = "postgresql://pecflow:pecflow_dev_password@db:5432/pecflow"
# ── Redis ─────────────────────────────────────────────────────────────────
redis_url: str = "redis://redis:6379/0"
# ── MinIO ─────────────────────────────────────────────────────────────────
minio_endpoint: str = "minio:9000"
minio_access_key: str = "minioadmin"
minio_secret_key: str = "minioadmin"
minio_bucket: str = "pecflow"
minio_use_ssl: bool = False
# ── CORS ──────────────────────────────────────────────────────────────────
cors_origins: str = "http://localhost:3000,http://localhost:5173"
# ── Rate Limiting ─────────────────────────────────────────────────────────
rate_limit_auth: str = "10/minute"
rate_limit_default: str = "100/minute"
# ── Logging ───────────────────────────────────────────────────────────────
log_level: str = "INFO"
log_json: bool = False
@field_validator("encryption_key")
@classmethod
def validate_encryption_key(cls, v: str) -> str:
if len(v) != 64:
raise ValueError(
"ENCRYPTION_KEY deve essere una stringa hex di 64 caratteri (32 bytes)"
)
try:
bytes.fromhex(v)
except ValueError:
raise ValueError("ENCRYPTION_KEY deve essere una stringa esadecimale valida")
return v
@property
def cors_origins_list(self) -> list[str]:
return [origin.strip() for origin in self.cors_origins.split(",")]
@property
def is_production(self) -> bool:
return self.app_env == "production"
@property
def encryption_key_bytes(self) -> bytes:
return bytes.fromhex(self.encryption_key)
@lru_cache
def get_settings() -> Settings:
"""Restituisce istanza singleton delle impostazioni (cachata)."""
return Settings()
+1
View File
@@ -0,0 +1 @@
# Core utilities
+123
View File
@@ -0,0 +1,123 @@
"""
Eccezioni applicative custom per PecFlow.
"""
from fastapi import HTTPException, status
# ─── Autenticazione ───────────────────────────────────────────────────────────
class InvalidCredentialsError(HTTPException):
def __init__(self) -> None:
super().__init__(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Credenziali non valide",
headers={"WWW-Authenticate": "Bearer"},
)
class TokenExpiredError(HTTPException):
def __init__(self) -> None:
super().__init__(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Token scaduto",
headers={"WWW-Authenticate": "Bearer"},
)
class TokenInvalidError(HTTPException):
def __init__(self) -> None:
super().__init__(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Token non valido",
headers={"WWW-Authenticate": "Bearer"},
)
class AccountLockedError(HTTPException):
def __init__(self, locked_until: str = "") -> None:
detail = "Account temporaneamente bloccato per troppi tentativi falliti"
if locked_until:
detail += f" fino a {locked_until}"
super().__init__(
status_code=status.HTTP_423_LOCKED,
detail=detail,
)
class AccountDisabledError(HTTPException):
def __init__(self) -> None:
super().__init__(
status_code=status.HTTP_403_FORBIDDEN,
detail="Account disabilitato",
)
class TOTPRequiredError(HTTPException):
def __init__(self) -> None:
super().__init__(
status_code=status.HTTP_403_FORBIDDEN,
detail="Autenticazione a due fattori richiesta",
)
class TOTPInvalidError(HTTPException):
def __init__(self) -> None:
super().__init__(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Codice TOTP non valido o scaduto",
)
# ─── Autorizzazione ───────────────────────────────────────────────────────────
class ForbiddenError(HTTPException):
def __init__(self, detail: str = "Accesso non autorizzato") -> None:
super().__init__(
status_code=status.HTTP_403_FORBIDDEN,
detail=detail,
)
class PermissionDeniedError(HTTPException):
def __init__(self, resource: str = "risorsa") -> None:
super().__init__(
status_code=status.HTTP_403_FORBIDDEN,
detail=f"Permessi insufficienti per accedere a questa {resource}",
)
# ─── Risorse ──────────────────────────────────────────────────────────────────
class NotFoundError(HTTPException):
def __init__(self, resource: str = "risorsa") -> None:
super().__init__(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"{resource.capitalize()} non trovata",
)
class ConflictError(HTTPException):
def __init__(self, detail: str = "Conflitto: risorsa già esistente") -> None:
super().__init__(
status_code=status.HTTP_409_CONFLICT,
detail=detail,
)
class TenantLimitExceededError(HTTPException):
def __init__(self, resource: str, limit: int) -> None:
super().__init__(
status_code=status.HTTP_402_PAYMENT_REQUIRED,
detail=f"Limite del piano raggiunto: massimo {limit} {resource} per questo tenant",
)
# ─── Validazione ──────────────────────────────────────────────────────────────
class ValidationError(HTTPException):
def __init__(self, detail: str) -> None:
super().__init__(
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
detail=detail,
)
+65
View File
@@ -0,0 +1,65 @@
"""
Structured logging per PecFlow.
In produzione (LOG_JSON=true) emette log JSON per aggregatori (Loki, ELK).
In sviluppo emette log leggibili colorati.
"""
import logging
import sys
from typing import Any
from app.config import get_settings
settings = get_settings()
def _build_handler() -> logging.Handler:
handler = logging.StreamHandler(sys.stdout)
if settings.log_json:
try:
import json
class JsonFormatter(logging.Formatter):
def format(self, record: logging.LogRecord) -> str:
log_entry: dict[str, Any] = {
"timestamp": self.formatTime(record, "%Y-%m-%dT%H:%M:%S"),
"level": record.levelname,
"logger": record.name,
"message": record.getMessage(),
}
if record.exc_info:
log_entry["exception"] = self.formatException(record.exc_info)
return json.dumps(log_entry, ensure_ascii=False)
handler.setFormatter(JsonFormatter())
except Exception:
pass
else:
fmt = "%(asctime)s %(levelname)-8s %(name)s %(message)s"
handler.setFormatter(logging.Formatter(fmt, datefmt="%H:%M:%S"))
return handler
def setup_logging() -> None:
"""Configura il logging applicativo. Da chiamare all'avvio dell'app."""
level = getattr(logging, settings.log_level.upper(), logging.INFO)
root_logger = logging.getLogger()
root_logger.setLevel(level)
# Rimuovi handler esistenti per evitare duplicati
root_logger.handlers.clear()
root_logger.addHandler(_build_handler())
# Riduci verbosità librerie rumorose
logging.getLogger("uvicorn.access").setLevel(logging.WARNING)
logging.getLogger("sqlalchemy.engine").setLevel(
logging.INFO if settings.app_debug else logging.WARNING
)
def get_logger(name: str) -> logging.Logger:
"""Restituisce un logger con il nome specificato."""
return logging.getLogger(name)
+56
View File
@@ -0,0 +1,56 @@
"""
Utility per paginazione standardizzata nelle API.
"""
from typing import Generic, TypeVar
from pydantic import BaseModel, Field
T = TypeVar("T")
DEFAULT_PAGE_SIZE = 25
MAX_PAGE_SIZE = 100
class PaginationParams(BaseModel):
page: int = Field(default=1, ge=1, description="Numero di pagina (1-based)")
page_size: int = Field(
default=DEFAULT_PAGE_SIZE,
ge=1,
le=MAX_PAGE_SIZE,
description=f"Elementi per pagina (max {MAX_PAGE_SIZE})",
)
@property
def offset(self) -> int:
return (self.page - 1) * self.page_size
@property
def limit(self) -> int:
return self.page_size
class PaginatedResponse(BaseModel, Generic[T]):
"""Risposta paginata generica."""
items: list[T]
total: int
page: int
page_size: int
pages: int
@classmethod
def create(
cls,
items: list[T],
total: int,
params: PaginationParams,
) -> "PaginatedResponse[T]":
import math
pages = math.ceil(total / params.page_size) if params.page_size > 0 else 0
return cls(
items=items,
total=total,
page=params.page,
page_size=params.page_size,
pages=pages,
)
+158
View File
@@ -0,0 +1,158 @@
"""
Modulo sicurezza cifratura AES-256-GCM, hashing password, JWT utilities.
ADR-002: Le credenziali IMAP/SMTP vengono cifrate con AES-256-GCM prima di
essere scritte in DB. La chiave è in variabile d'ambiente (ENCRYPTION_KEY).
Formato storage: base64(nonce_12byte || ciphertext || tag_16byte)
"""
import base64
import os
from datetime import UTC, datetime, timedelta
from typing import Any
from uuid import UUID
import bcrypt as _bcrypt
from cryptography.hazmat.primitives.ciphers.aead import AESGCM
from jose import JWTError, jwt
from app.config import get_settings
settings = get_settings()
# ─── Password hashing (bcrypt diretto, compatibile con bcrypt 4.x/5.x) ───────
_BCRYPT_ROUNDS = 12
def hash_password(password: str) -> str:
"""Genera hash bcrypt della password (work factor 12)."""
pwd_bytes = password.encode("utf-8")
salt = _bcrypt.gensalt(rounds=_BCRYPT_ROUNDS)
return _bcrypt.hashpw(pwd_bytes, salt).decode("utf-8")
def verify_password(plain_password: str, hashed_password: str) -> bool:
"""Verifica password contro il suo hash."""
try:
return _bcrypt.checkpw(
plain_password.encode("utf-8"),
hashed_password.encode("utf-8"),
)
except Exception:
return False
# ─── JWT ──────────────────────────────────────────────────────────────────────
def create_access_token(
subject: str | UUID,
tenant_id: str | UUID,
role: str,
extra_claims: dict[str, Any] | None = None,
) -> str:
"""
Crea un JWT access token con scadenza configurabile.
Claims standard:
- sub: user_id (string)
- tid: tenant_id
- role: ruolo utente
- exp: scadenza
- iat: emesso a
"""
now = datetime.now(UTC)
expire = now + timedelta(minutes=settings.access_token_expire_minutes)
payload: dict[str, Any] = {
"sub": str(subject),
"tid": str(tenant_id),
"role": role,
"iat": now,
"exp": expire,
"type": "access",
}
if extra_claims:
payload.update(extra_claims)
return jwt.encode(payload, settings.secret_key, algorithm=settings.algorithm)
def create_refresh_token(subject: str | UUID, tenant_id: str | UUID) -> str:
"""
Crea un JWT refresh token con scadenza lunga (30 giorni default).
Non contiene il ruolo viene rivalutato a ogni refresh.
"""
now = datetime.now(UTC)
expire = now + timedelta(days=settings.refresh_token_expire_days)
payload: dict[str, Any] = {
"sub": str(subject),
"tid": str(tenant_id),
"iat": now,
"exp": expire,
"type": "refresh",
}
return jwt.encode(payload, settings.secret_key, algorithm=settings.algorithm)
def decode_token(token: str) -> dict[str, Any]:
"""
Decodifica e valida un JWT token.
Solleva JWTError se il token è invalido o scaduto.
"""
return jwt.decode(token, settings.secret_key, algorithms=[settings.algorithm])
def is_token_valid(token: str, expected_type: str = "access") -> bool:
"""Verifica rapidamente la validità del token senza sollevare eccezioni."""
try:
payload = decode_token(token)
return payload.get("type") == expected_type
except JWTError:
return False
# ─── AES-256-GCM cifratura credenziali (ADR-002) ─────────────────────────────
def encrypt_credential(plaintext: str) -> str:
"""
Cifra una stringa con AES-256-GCM usando la chiave applicativa.
Formato output: base64(nonce_12byte || ciphertext || tag_16byte)
Il tag GCM (16 byte) è automaticamente concatenato al ciphertext da AESGCM.
"""
key = settings.encryption_key_bytes
aesgcm = AESGCM(key)
nonce = os.urandom(12) # 12 byte nonce raccomandato per GCM
# AESGCM.encrypt() restituisce ciphertext + tag concatenati
ciphertext_with_tag = aesgcm.encrypt(nonce, plaintext.encode("utf-8"), None)
# Concatena nonce + ciphertext_with_tag e codifica in base64
raw = nonce + ciphertext_with_tag
return base64.b64encode(raw).decode("ascii")
def decrypt_credential(encrypted: str) -> str:
"""
Decifra una stringa cifrata con encrypt_credential().
Solleva ValueError se la decifratura fallisce (chiave errata o dati corrotti).
"""
key = settings.encryption_key_bytes
aesgcm = AESGCM(key)
try:
raw = base64.b64decode(encrypted.encode("ascii"))
nonce = raw[:12]
ciphertext_with_tag = raw[12:]
plaintext_bytes = aesgcm.decrypt(nonce, ciphertext_with_tag, None)
return plaintext_bytes.decode("utf-8")
except Exception as e:
raise ValueError(f"Decifratura fallita: {e}") from e
# ─── Hash sicuro per refresh token storage ────────────────────────────────────
import hashlib
def hash_token(token: str) -> str:
"""SHA-256 del token raw per storage sicuro in DB."""
return hashlib.sha256(token.encode("utf-8")).hexdigest()
+52
View File
@@ -0,0 +1,52 @@
"""
Configurazione database engine SQLAlchemy async e session factory.
"""
from collections.abc import AsyncGenerator
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
from sqlalchemy.orm import DeclarativeBase
from app.config import get_settings
settings = get_settings()
# Engine async con pool connection
engine = create_async_engine(
settings.database_url,
echo=settings.app_debug, # log SQL in development
pool_size=10,
max_overflow=20,
pool_pre_ping=True, # verifica connessione prima di usarla
pool_recycle=3600, # ricicla connessioni ogni ora
)
# Session factory
AsyncSessionLocal = async_sessionmaker(
bind=engine,
class_=AsyncSession,
expire_on_commit=False,
autocommit=False,
autoflush=False,
)
class Base(DeclarativeBase):
"""Base class per tutti i modelli SQLAlchemy."""
pass
async def get_db() -> AsyncGenerator[AsyncSession, None]:
"""
Dependency FastAPI: restituisce una sessione DB per ogni request.
La sessione viene chiusa automaticamente al termine del request.
"""
async with AsyncSessionLocal() as session:
try:
yield session
await session.commit()
except Exception:
await session.rollback()
raise
finally:
await session.close()
+117
View File
@@ -0,0 +1,117 @@
"""
Dependency FastAPI get_db, get_current_user, require_admin, RLS middleware.
"""
import uuid
from typing import Annotated
from fastapi import Depends, Request
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
from jose import JWTError
from sqlalchemy import text
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.exceptions import ForbiddenError, TokenInvalidError
from app.core.security import decode_token
from app.database import get_db
from app.models.user import User
from sqlalchemy import select
security = HTTPBearer()
# ─── Database con RLS ─────────────────────────────────────────────────────────
async def get_db_with_rls(
tenant_id: uuid.UUID,
db: AsyncSession = Depends(get_db),
) -> AsyncSession:
"""
Imposta la variabile di sessione PostgreSQL per RLS.
Da usare dopo aver estratto il tenant_id dall'utente autenticato.
"""
await db.execute(
text("SET LOCAL app.current_tenant_id = :tenant_id"),
{"tenant_id": str(tenant_id)},
)
return db
# ─── Utente corrente ──────────────────────────────────────────────────────────
async def get_current_user(
credentials: Annotated[HTTPAuthorizationCredentials, Depends(security)],
db: AsyncSession = Depends(get_db),
) -> User:
"""
Estrae e valida il JWT dall'header Authorization: Bearer <token>.
Carica l'utente dal DB e imposta RLS.
"""
token = credentials.credentials
try:
payload = decode_token(token)
except JWTError:
raise TokenInvalidError()
if payload.get("type") != "access":
raise TokenInvalidError()
user_id_str = payload.get("sub")
tenant_id_str = payload.get("tid")
if not user_id_str or not tenant_id_str:
raise TokenInvalidError()
try:
user_id = uuid.UUID(user_id_str)
tenant_id = uuid.UUID(tenant_id_str)
except ValueError:
raise TokenInvalidError()
# Imposta RLS per questo tenant
# SET LOCAL non supporta parametri $1, usiamo text() con valore inline
await db.execute(
text(f"SET LOCAL app.current_tenant_id = '{tenant_id!s}'")
)
# Carica utente
result = await db.execute(
select(User).where(User.id == user_id, User.tenant_id == tenant_id)
)
user = result.scalar_one_or_none()
if not user:
raise TokenInvalidError()
if not user.is_active:
from app.core.exceptions import AccountDisabledError
raise AccountDisabledError()
return user
# ─── Role guards ──────────────────────────────────────────────────────────────
async def require_admin(
current_user: Annotated[User, Depends(get_current_user)],
) -> User:
"""Richiede ruolo admin o super_admin."""
if not current_user.is_admin:
raise ForbiddenError("Richiesto ruolo amministratore")
return current_user
async def require_super_admin(
current_user: Annotated[User, Depends(get_current_user)],
) -> User:
"""Richiede ruolo super_admin."""
if not current_user.is_super_admin:
raise ForbiddenError("Richiesto ruolo super_admin")
return current_user
# ─── Tipo annotato per ridurre boilerplate negli endpoint ─────────────────────
CurrentUser = Annotated[User, Depends(get_current_user)]
AdminUser = Annotated[User, Depends(require_admin)]
SuperAdminUser = Annotated[User, Depends(require_super_admin)]
DB = Annotated[AsyncSession, Depends(get_db)]
+108
View File
@@ -0,0 +1,108 @@
"""
Entrypoint FastAPI registra router, middleware, startup/shutdown.
"""
from contextlib import asynccontextmanager
from typing import AsyncGenerator
from fastapi import FastAPI, Request, status
from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import JSONResponse
from slowapi import Limiter, _rate_limit_exceeded_handler
from slowapi.errors import RateLimitExceeded
from slowapi.middleware import SlowAPIMiddleware
from slowapi.util import get_remote_address
from app.api.v1 import auth, permissions, tenants, users
from app.config import get_settings
from app.core.logging import get_logger, setup_logging
from app.database import engine
settings = get_settings()
logger = get_logger(__name__)
@asynccontextmanager
async def lifespan(app: FastAPI) -> AsyncGenerator[None, None]:
"""Gestione ciclo di vita dell'applicazione."""
setup_logging()
logger.info(
"🚀 PecFlow Backend avviato",
extra={"env": settings.app_env, "debug": settings.app_debug},
)
yield
# Cleanup: chiudi connessioni DB
await engine.dispose()
logger.info("🛑 PecFlow Backend fermato")
# ─── Applicazione FastAPI ─────────────────────────────────────────────────────
limiter = Limiter(key_func=get_remote_address, default_limits=["200/minute"])
app = FastAPI(
title="PecFlow API",
description="API per la gestione PEC SaaS multi-tenant",
version="1.0.0",
docs_url="/docs" if not settings.is_production else None,
redoc_url="/redoc" if not settings.is_production else None,
lifespan=lifespan,
)
# ─── Middleware ───────────────────────────────────────────────────────────────
app.state.limiter = limiter
app.add_exception_handler(RateLimitExceeded, _rate_limit_exceeded_handler)
app.add_middleware(SlowAPIMiddleware)
app.add_middleware(
CORSMiddleware,
allow_origins=settings.cors_origins_list,
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
# ─── Router ───────────────────────────────────────────────────────────────────
API_PREFIX = "/api/v1"
app.include_router(auth.router, prefix=API_PREFIX)
app.include_router(users.router, prefix=API_PREFIX)
app.include_router(tenants.router, prefix=API_PREFIX)
app.include_router(permissions.router, prefix=API_PREFIX)
# ─── Health check ─────────────────────────────────────────────────────────────
@app.get("/health", tags=["Health"], include_in_schema=False)
async def health_check() -> dict:
"""Endpoint di health check per Docker/Kubernetes."""
return {
"status": "ok",
"version": "1.0.0",
"env": settings.app_env,
}
@app.get("/health/db", tags=["Health"], include_in_schema=False)
async def health_db() -> dict:
"""Verifica connessione al database."""
from sqlalchemy import text
from app.database import AsyncSessionLocal
try:
async with AsyncSessionLocal() as session:
await session.execute(text("SELECT 1"))
return {"status": "ok", "database": "connected"}
except Exception as e:
return JSONResponse(
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
content={"status": "error", "database": str(e)},
)
# ─── Error handler globale ────────────────────────────────────────────────────
@app.exception_handler(Exception)
async def global_exception_handler(request: Request, exc: Exception) -> JSONResponse:
logger.exception(f"Errore non gestito: {exc}")
return JSONResponse(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
content={"detail": "Errore interno del server"},
)
+9
View File
@@ -0,0 +1,9 @@
# Importa tutti i modelli per permettere ad Alembic di rilevarli
from app.models.tenant import Tenant # noqa: F401
from app.models.user import User, RefreshToken # noqa: F401
from app.models.mailbox import Mailbox # noqa: F401
from app.models.message import Message, Attachment, SendJob # noqa: F401
from app.models.archival import ArchivalBatch, ArchivalBatchMessage, ArchivalDip # noqa: F401
from app.models.audit_log import AuditLog # noqa: F401
from app.models.label import Label, MessageLabel # noqa: F401
from app.models.permission import MailboxPermission # noqa: F401
+108
View File
@@ -0,0 +1,108 @@
"""
Modelli Archival versamenti verso conservatore AgID.
"""
import uuid
from datetime import date, datetime
from sqlalchemy import (
CHAR,
DateTime,
Enum,
ForeignKey,
Index,
Integer,
String,
Text,
func,
)
from sqlalchemy.dialects.postgresql import UUID
from sqlalchemy.orm import Mapped, mapped_column
from app.database import Base
ArchivalStatus = Enum(
"pending", "building_sip", "uploading", "uploaded",
"confirmed", "rejected", "failed",
name="archival_status",
create_type=False,
)
class ArchivalBatch(Base):
__tablename__ = "archival_batches"
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), ForeignKey("tenants.id", ondelete="CASCADE"), nullable=False
)
conservatore_id: Mapped[str] = mapped_column(String(100), nullable=False)
status: Mapped[str] = mapped_column(ArchivalStatus, nullable=False, default="pending")
sip_path: Mapped[str | None] = mapped_column(Text, nullable=True)
sip_checksum: Mapped[str | None] = mapped_column(CHAR(64), nullable=True)
versamento_id: Mapped[str | None] = mapped_column(Text, nullable=True)
rdv_received_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
rdv_path: Mapped[str | None] = mapped_column(Text, nullable=True)
rdv_checksum: Mapped[str | None] = mapped_column(CHAR(64), nullable=True)
attempt_count: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
max_attempts: Mapped[int] = mapped_column(Integer, nullable=False, default=3)
next_retry_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
last_error: Mapped[str | None] = mapped_column(Text, nullable=True)
period_from: Mapped[date] = mapped_column(nullable=False)
period_to: Mapped[date] = mapped_column(nullable=False)
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__ = (
Index("idx_archival_tenant", "tenant_id"),
Index("idx_archival_status", "status", "next_retry_at"),
)
class ArchivalBatchMessage(Base):
__tablename__ = "archival_batch_messages"
batch_id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True),
ForeignKey("archival_batches.id", ondelete="CASCADE"),
primary_key=True,
)
message_id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True),
ForeignKey("messages.id", ondelete="CASCADE"),
primary_key=True,
)
class ArchivalDip(Base):
__tablename__ = "archival_dips"
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), ForeignKey("tenants.id", ondelete="CASCADE"), nullable=False
)
batch_id: Mapped[uuid.UUID | None] = mapped_column(
UUID(as_uuid=True), ForeignKey("archival_batches.id"), nullable=True
)
requested_by: Mapped[uuid.UUID | None] = mapped_column(
UUID(as_uuid=True), ForeignKey("users.id"), nullable=True
)
dip_path: Mapped[str | None] = mapped_column(Text, nullable=True)
status: Mapped[str] = mapped_column(String(50), nullable=False, default="requested")
requested_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), nullable=False, server_default=func.now()
)
received_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
+51
View File
@@ -0,0 +1,51 @@
"""
Modello AuditLog immutabile per compliance e tracciabilità.
"""
import uuid
from datetime import datetime
from sqlalchemy import (
BigInteger,
DateTime,
ForeignKey,
Index,
String,
Text,
func,
)
from sqlalchemy.dialects.postgresql import INET, JSONB, UUID
from sqlalchemy.orm import Mapped, mapped_column
from app.database import Base
class AuditLog(Base):
__tablename__ = "audit_log"
id: Mapped[int] = mapped_column(BigInteger, primary_key=True, autoincrement=True)
tenant_id: Mapped[uuid.UUID | None] = mapped_column(
UUID(as_uuid=True), ForeignKey("tenants.id"), nullable=True
)
user_id: Mapped[uuid.UUID | None] = mapped_column(
UUID(as_uuid=True), ForeignKey("users.id"), nullable=True
)
action: Mapped[str] = mapped_column(String(100), nullable=False)
resource_type: Mapped[str | None] = mapped_column(String(100), nullable=True)
resource_id: Mapped[uuid.UUID | None] = mapped_column(UUID(as_uuid=True), nullable=True)
ip_address: Mapped[str | None] = mapped_column(INET, nullable=True)
user_agent: Mapped[str | None] = mapped_column(Text, nullable=True)
payload: Mapped[dict | None] = mapped_column(JSONB, nullable=True)
outcome: Mapped[str] = mapped_column(String(20), nullable=False, default="success")
occurred_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), nullable=False, server_default=func.now()
)
__table_args__ = (
Index("idx_audit_tenant_date", "tenant_id", "occurred_at"),
Index("idx_audit_user", "user_id", "occurred_at"),
Index("idx_audit_action", "action"),
)
def __repr__(self) -> str:
return f"<AuditLog {self.action!r} user={self.user_id} outcome={self.outcome!r}>"
+46
View File
@@ -0,0 +1,46 @@
"""
Modelli Label e MessageLabel tagging messaggi.
"""
import uuid
from sqlalchemy import CHAR, ForeignKey, Index, String, UniqueConstraint
from sqlalchemy.dialects.postgresql import UUID
from sqlalchemy.orm import Mapped, mapped_column
from app.database import Base
class Label(Base):
__tablename__ = "labels"
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), ForeignKey("tenants.id", ondelete="CASCADE"), nullable=False
)
name: Mapped[str] = mapped_column(String(100), nullable=False)
color: Mapped[str | None] = mapped_column(CHAR(7), nullable=True) # hex #RRGGBB
__table_args__ = (
UniqueConstraint("tenant_id", "name", name="uq_label_name_tenant"),
)
def __repr__(self) -> str:
return f"<Label {self.name!r}>"
class MessageLabel(Base):
__tablename__ = "message_labels"
message_id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True),
ForeignKey("messages.id", ondelete="CASCADE"),
primary_key=True,
)
label_id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True),
ForeignKey("labels.id", ondelete="CASCADE"),
primary_key=True,
)
+94
View File
@@ -0,0 +1,94 @@
"""
Modello Mailbox casella PEC con credenziali IMAP/SMTP cifrate.
"""
import uuid
from datetime import datetime
from sqlalchemy import (
BigInteger,
Boolean,
DateTime,
Enum,
ForeignKey,
Index,
Integer,
String,
Text,
UniqueConstraint,
func,
)
from sqlalchemy.dialects.postgresql import UUID
from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.database import Base
MailboxStatus = Enum(
"active", "paused", "error", "deleted",
name="mailbox_status",
create_type=False,
)
class Mailbox(Base):
__tablename__ = "mailboxes"
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), ForeignKey("tenants.id", ondelete="CASCADE"), nullable=False
)
email_address: Mapped[str] = mapped_column(String(255), nullable=False)
display_name: Mapped[str | None] = mapped_column(String(255), nullable=True)
provider: Mapped[str | None] = mapped_column(String(100), nullable=True)
# Credenziali IMAP cifrate (AES-256-GCM)
imap_host_enc: Mapped[str] = mapped_column(Text, nullable=False)
imap_port_enc: Mapped[str] = mapped_column(Text, nullable=False)
imap_user_enc: Mapped[str] = mapped_column(Text, nullable=False)
imap_pass_enc: Mapped[str] = mapped_column(Text, nullable=False)
imap_use_ssl: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True)
# Credenziali SMTP cifrate (AES-256-GCM)
smtp_host_enc: Mapped[str] = mapped_column(Text, nullable=False)
smtp_port_enc: Mapped[str] = mapped_column(Text, nullable=False)
smtp_user_enc: Mapped[str] = mapped_column(Text, nullable=False)
smtp_pass_enc: Mapped[str] = mapped_column(Text, nullable=False)
smtp_use_tls: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True)
# Stato sincronizzazione
status: Mapped[str] = mapped_column(MailboxStatus, nullable=False, default="active")
last_sync_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
last_sync_uid: Mapped[int | None] = mapped_column(BigInteger, nullable=True)
sync_error_msg: Mapped[str | None] = mapped_column(Text, nullable=True)
sync_error_count: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
created_by: Mapped[uuid.UUID | None] = mapped_column(
UUID(as_uuid=True), ForeignKey("users.id"), 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()
)
# Relazioni
tenant: Mapped["Tenant"] = relationship("Tenant", back_populates="mailboxes") # noqa: F821
permissions: Mapped[list["MailboxPermission"]] = relationship( # noqa: F821
"MailboxPermission", back_populates="mailbox", cascade="all, delete-orphan"
)
__table_args__ = (
UniqueConstraint("tenant_id", "email_address", name="uq_mailbox_email_tenant"),
Index("idx_mailboxes_tenant", "tenant_id"),
Index(
"idx_mailboxes_status",
"status",
postgresql_where="status = 'active'",
),
)
def __repr__(self) -> str:
return f"<Mailbox {self.email_address!r} status={self.status!r}>"
+191
View File
@@ -0,0 +1,191 @@
"""
Modelli Message, Attachment, SendJob.
"""
import uuid
from datetime import datetime
from sqlalchemy import (
ARRAY,
BigInteger,
Boolean,
DateTime,
Enum,
ForeignKey,
Index,
Integer,
String,
Text,
func,
)
from sqlalchemy.dialects.postgresql import UUID
from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.database import Base
PecDirection = Enum("inbound", "outbound", name="pec_direction", create_type=False)
PecState = Enum(
"draft", "queued", "sent", "accepted", "delivered", "anomaly", "failed", "received",
name="pec_state",
create_type=False,
)
PecMsgType = Enum(
"posta_certificata", "accettazione", "non_accettazione", "presa_in_carico",
"avvenuta_consegna", "mancata_consegna", "errore_consegna",
"preavviso_mancata_consegna", "rilevazione_virus", "unknown",
name="pec_msg_type",
create_type=False,
)
SendJobStatus = Enum(
"pending", "sending", "sent", "failed", "retrying",
name="send_job_status",
create_type=False,
)
class Message(Base):
__tablename__ = "messages"
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), ForeignKey("tenants.id", ondelete="CASCADE"), nullable=False
)
mailbox_id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True), ForeignKey("mailboxes.id", ondelete="CASCADE"), nullable=False
)
# Identificatori
message_id_header: Mapped[str | None] = mapped_column(Text, nullable=True)
imap_uid: Mapped[int | None] = mapped_column(BigInteger, nullable=True)
imap_folder: Mapped[str] = mapped_column(String(255), nullable=False, default="INBOX")
direction: Mapped[str] = mapped_column(PecDirection, nullable=False)
pec_type: Mapped[str] = mapped_column(
PecMsgType, nullable=False, default="posta_certificata"
)
state: Mapped[str] = mapped_column(PecState, nullable=False)
# Busta PEC
subject: Mapped[str | None] = mapped_column(Text, nullable=True)
from_address: Mapped[str | None] = mapped_column(String(255), nullable=True)
to_addresses: Mapped[list[str] | None] = mapped_column(ARRAY(Text), nullable=True)
cc_addresses: Mapped[list[str] | None] = mapped_column(ARRAY(Text), nullable=True)
sent_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
received_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
size_bytes: Mapped[int | None] = mapped_column(BigInteger, nullable=True)
# Corpo
body_text: Mapped[str | None] = mapped_column(Text, nullable=True)
body_html: Mapped[str | None] = mapped_column(Text, nullable=True)
has_attachments: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
# Collegamento ricevute
parent_message_id: Mapped[uuid.UUID | None] = mapped_column(
UUID(as_uuid=True), ForeignKey("messages.id"), nullable=True
)
# Flag operativi
is_read: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
is_starred: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
is_archived: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
archived_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
raw_eml_path: 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()
)
# Relazioni
attachments: Mapped[list["Attachment"]] = relationship(
"Attachment", back_populates="message", cascade="all, delete-orphan"
)
children: Mapped[list["Message"]] = relationship(
"Message", foreign_keys=[parent_message_id]
)
__table_args__ = (
Index("idx_messages_tenant", "tenant_id"),
Index("idx_messages_mailbox", "mailbox_id"),
Index("idx_messages_state", "state"),
Index("idx_messages_received_at", "received_at", postgresql_ops={"received_at": "DESC"}),
Index(
"idx_messages_parent", "parent_message_id",
postgresql_where="parent_message_id IS NOT NULL",
),
Index("idx_messages_imap_uid", "mailbox_id", "imap_uid"),
)
def __repr__(self) -> str:
return f"<Message {self.id} {self.pec_type!r} {self.state!r}>"
class Attachment(Base):
__tablename__ = "attachments"
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), ForeignKey("tenants.id", ondelete="CASCADE"), nullable=False
)
message_id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True), ForeignKey("messages.id", ondelete="CASCADE"), nullable=False
)
filename: Mapped[str] = mapped_column(String(512), nullable=False)
content_type: Mapped[str | None] = mapped_column(String(255), nullable=True)
size_bytes: Mapped[int | None] = mapped_column(BigInteger, nullable=True)
storage_path: Mapped[str] = mapped_column(Text, nullable=False)
checksum_sha256: Mapped[str | None] = mapped_column(String(64), nullable=True)
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), nullable=False, server_default=func.now()
)
# Relazioni
message: Mapped["Message"] = relationship("Message", back_populates="attachments")
__table_args__ = (
Index("idx_attachments_message", "message_id"),
)
class SendJob(Base):
__tablename__ = "send_jobs"
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), ForeignKey("tenants.id", ondelete="CASCADE"), nullable=False
)
mailbox_id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True), ForeignKey("mailboxes.id"), nullable=False
)
message_id: Mapped[uuid.UUID | None] = mapped_column(
UUID(as_uuid=True), ForeignKey("messages.id"), nullable=True
)
status: Mapped[str] = mapped_column(SendJobStatus, nullable=False, default="pending")
attempt_count: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
max_attempts: Mapped[int] = mapped_column(Integer, nullable=False, default=5)
next_retry_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
last_error: Mapped[str | None] = mapped_column(Text, nullable=True)
queued_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), nullable=False, server_default=func.now()
)
sent_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
created_by: Mapped[uuid.UUID | None] = mapped_column(
UUID(as_uuid=True), ForeignKey("users.id"), nullable=True
)
__table_args__ = (
Index("idx_sendjobs_tenant", "tenant_id"),
Index(
"idx_sendjobs_status", "status", "next_retry_at",
postgresql_where="status IN ('pending', 'retrying')",
),
)
+71
View File
@@ -0,0 +1,71 @@
"""
Modello MailboxPermission matrice permessi utente × casella (Fase 1-A).
ADR permessi granulari: admin ha accesso implicito a tutto.
Gli operator/readonly/supervisor devono avere un record esplicito.
"""
import uuid
from datetime import datetime
from sqlalchemy import (
Boolean,
DateTime,
ForeignKey,
Index,
UniqueConstraint,
func,
)
from sqlalchemy.dialects.postgresql import UUID
from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.database import Base
class MailboxPermission(Base):
__tablename__ = "mailbox_permissions"
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), ForeignKey("tenants.id", ondelete="CASCADE"), nullable=False
)
user_id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True), ForeignKey("users.id", ondelete="CASCADE"), nullable=False
)
mailbox_id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True), ForeignKey("mailboxes.id", ondelete="CASCADE"), nullable=False
)
can_read: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True)
can_send: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
can_manage: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
granted_by: Mapped[uuid.UUID | None] = mapped_column(
UUID(as_uuid=True), ForeignKey("users.id"), nullable=True
)
granted_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), nullable=False, server_default=func.now()
)
# Relazioni
user: Mapped["User"] = relationship( # noqa: F821
"User", back_populates="mailbox_permissions", foreign_keys=[user_id]
)
mailbox: Mapped["Mailbox"] = relationship( # noqa: F821
"Mailbox", back_populates="permissions"
)
__table_args__ = (
UniqueConstraint("user_id", "mailbox_id", name="uq_perm_user_mailbox"),
Index("idx_mbperm_user", "user_id"),
Index("idx_mbperm_mailbox", "mailbox_id"),
Index("idx_mbperm_tenant", "tenant_id"),
)
def __repr__(self) -> str:
return (
f"<MailboxPermission user={self.user_id} mailbox={self.mailbox_id} "
f"read={self.can_read} send={self.can_send}>"
)
+47
View File
@@ -0,0 +1,47 @@
"""
Modello Tenant ogni organizzazione cliente del SaaS.
"""
import uuid
from datetime import datetime
from sqlalchemy import Boolean, DateTime, Index, Integer, String, func
from sqlalchemy.dialects.postgresql import UUID
from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.database import Base
class Tenant(Base):
__tablename__ = "tenants"
id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True), primary_key=True, default=uuid.uuid4
)
slug: Mapped[str] = mapped_column(String(63), nullable=False, unique=True)
name: Mapped[str] = mapped_column(String(255), nullable=False)
plan: Mapped[str] = mapped_column(String(50), nullable=False, default="starter")
is_active: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True)
max_mailboxes: Mapped[int] = mapped_column(Integer, nullable=False, default=5)
max_users: Mapped[int] = mapped_column(Integer, nullable=False, default=10)
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()
)
# Relazioni
users: Mapped[list["User"]] = relationship( # noqa: F821
"User", back_populates="tenant", cascade="all, delete-orphan"
)
mailboxes: Mapped[list["Mailbox"]] = relationship( # noqa: F821
"Mailbox", back_populates="tenant", cascade="all, delete-orphan"
)
__table_args__ = (
Index("idx_tenants_slug", "slug"),
)
def __repr__(self) -> str:
return f"<Tenant {self.slug!r} ({self.plan})>"
+129
View File
@@ -0,0 +1,129 @@
"""
Modelli User e RefreshToken.
"""
import uuid
from datetime import datetime
from sqlalchemy import (
Boolean,
DateTime,
Enum,
ForeignKey,
Index,
Integer,
String,
Text,
UniqueConstraint,
func,
)
from sqlalchemy.dialects.postgresql import INET, UUID
from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.database import Base
UserRole = Enum(
"super_admin", "admin", "supervisor", "operator", "readonly",
name="user_role",
create_type=False, # creato dalla migrazione Alembic
)
class User(Base):
__tablename__ = "users"
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), ForeignKey("tenants.id", ondelete="CASCADE"), nullable=False
)
email: Mapped[str] = mapped_column(String(255), nullable=False)
password_hash: Mapped[str] = mapped_column(Text, nullable=False)
full_name: Mapped[str] = mapped_column(String(255), nullable=False)
role: Mapped[str] = mapped_column(UserRole, nullable=False, default="operator")
is_active: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True)
# 2FA TOTP
totp_secret: Mapped[str | None] = mapped_column(Text, nullable=True)
totp_enabled: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
# Sicurezza accesso
last_login_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
failed_login_count: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
locked_until: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), 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()
)
# Relazioni
tenant: Mapped["Tenant"] = relationship("Tenant", back_populates="users") # noqa: F821
refresh_tokens: Mapped[list["RefreshToken"]] = relationship(
"RefreshToken", back_populates="user", cascade="all, delete-orphan"
)
mailbox_permissions: Mapped[list["MailboxPermission"]] = relationship( # noqa: F821
"MailboxPermission",
back_populates="user",
foreign_keys="[MailboxPermission.user_id]",
cascade="all, delete-orphan",
)
__table_args__ = (
UniqueConstraint("tenant_id", "email", name="uq_user_email_tenant"),
Index("idx_users_tenant", "tenant_id"),
Index("idx_users_email", "email"),
)
@property
def is_admin(self) -> bool:
return self.role in ("super_admin", "admin")
@property
def is_super_admin(self) -> bool:
return self.role == "super_admin"
def __repr__(self) -> str:
return f"<User {self.email!r} role={self.role!r}>"
class RefreshToken(Base):
__tablename__ = "refresh_tokens"
id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True), primary_key=True, default=uuid.uuid4
)
user_id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True), ForeignKey("users.id", ondelete="CASCADE"), nullable=False
)
token_hash: Mapped[str] = mapped_column(Text, nullable=False, unique=True)
issued_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), nullable=False, server_default=func.now()
)
expires_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False)
revoked_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
user_agent: Mapped[str | None] = mapped_column(Text, nullable=True)
ip_address: Mapped[str | None] = mapped_column(INET, nullable=True)
# Relazioni
user: Mapped["User"] = relationship("User", back_populates="refresh_tokens")
__table_args__ = (
Index("idx_rt_user", "user_id"),
Index(
"idx_rt_expires",
"expires_at",
postgresql_where="revoked_at IS NULL",
),
)
@property
def is_valid(self) -> bool:
from datetime import UTC
return self.revoked_at is None and self.expires_at > datetime.now(UTC)
def __repr__(self) -> str:
return f"<RefreshToken user_id={self.user_id!r}>"
+1
View File
@@ -0,0 +1 @@
# Schemas Pydantic
+68
View File
@@ -0,0 +1,68 @@
"""
Schema Pydantic per autenticazione e autorizzazione.
"""
import uuid
from datetime import datetime
from pydantic import BaseModel, EmailStr, Field, field_validator
# ─── Request ──────────────────────────────────────────────────────────────────
class LoginRequest(BaseModel):
email: EmailStr
password: str = Field(min_length=1)
totp_code: str | None = Field(default=None, description="Codice TOTP 6 cifre (se 2FA attivo)")
class RefreshRequest(BaseModel):
refresh_token: str
class TOTPVerifyRequest(BaseModel):
totp_code: str = Field(min_length=6, max_length=6, description="Codice TOTP 6 cifre")
class PasswordChangeRequest(BaseModel):
current_password: str
new_password: str = Field(min_length=8)
@field_validator("new_password")
@classmethod
def validate_password_strength(cls, v: str) -> str:
if len(v) < 8:
raise ValueError("La password deve essere almeno 8 caratteri")
if not any(c.isupper() for c in v):
raise ValueError("La password deve contenere almeno una lettera maiuscola")
if not any(c.isdigit() for c in v):
raise ValueError("La password deve contenere almeno un numero")
return v
# ─── Response ─────────────────────────────────────────────────────────────────
class TokenResponse(BaseModel):
access_token: str
refresh_token: str
token_type: str = "bearer"
expires_in: int = Field(description="Scadenza access token in secondi")
class TOTPSetupResponse(BaseModel):
"""Dati per configurare TOTP sull'authenticator app."""
secret: str = Field(description="Segreto TOTP base32 (da inserire manualmente)")
qr_uri: str = Field(description="URI otpauth:// per il QR code")
qr_image_base64: str = Field(description="QR code come immagine PNG base64")
class TOTPStatusResponse(BaseModel):
totp_enabled: bool
class UserTokenData(BaseModel):
"""Dati estratti dal JWT per l'utente corrente."""
user_id: uuid.UUID
tenant_id: uuid.UUID
role: str
email: str | None = None
+50
View File
@@ -0,0 +1,50 @@
"""
Schema Pydantic per permessi granulari casella (Fase 1-A).
"""
import uuid
from datetime import datetime
from pydantic import BaseModel
class PermissionGrantRequest(BaseModel):
can_read: bool = True
can_send: bool = False
can_manage: bool = False
class PermissionResponse(BaseModel):
id: uuid.UUID
tenant_id: uuid.UUID
user_id: uuid.UUID
mailbox_id: uuid.UUID
can_read: bool
can_send: bool
can_manage: bool
granted_by: uuid.UUID | None
granted_at: datetime
model_config = {"from_attributes": True}
class UserMailboxPermissionResponse(BaseModel):
"""Vista utente: caselle accessibili con relativi permessi."""
mailbox_id: uuid.UUID
mailbox_email: str
mailbox_display_name: str | None
can_read: bool
can_send: bool
can_manage: bool
class MailboxUserPermissionResponse(BaseModel):
"""Vista casella: utenti con accesso."""
user_id: uuid.UUID
user_email: str
user_full_name: str
user_role: str
can_read: bool
can_send: bool
can_manage: bool
granted_at: datetime
+54
View File
@@ -0,0 +1,54 @@
"""
Schema Pydantic per tenant (super-admin).
"""
import uuid
from datetime import datetime
from typing import Literal
from pydantic import BaseModel, Field, field_validator
TenantPlanType = Literal["starter", "pro", "enterprise"]
class TenantCreateRequest(BaseModel):
slug: str = Field(min_length=3, max_length=63, pattern=r"^[a-z0-9-]+$")
name: str = Field(min_length=2, max_length=255)
plan: TenantPlanType = "starter"
max_mailboxes: int = Field(default=5, ge=1, le=1000)
max_users: int = Field(default=10, ge=1, le=1000)
# Utente admin iniziale
admin_email: str
admin_password: str = Field(min_length=8)
admin_full_name: str = Field(min_length=2, max_length=255)
@field_validator("slug")
@classmethod
def validate_slug(cls, v: str) -> str:
reserved = {"api", "admin", "www", "mail", "smtp", "imap", "pecflow", "app"}
if v in reserved:
raise ValueError(f"Slug '{v}' riservato")
return v.lower()
class TenantUpdateRequest(BaseModel):
name: str | None = Field(default=None, min_length=2, max_length=255)
plan: TenantPlanType | None = None
is_active: bool | None = None
max_mailboxes: int | None = Field(default=None, ge=1, le=1000)
max_users: int | None = Field(default=None, ge=1, le=1000)
class TenantResponse(BaseModel):
id: uuid.UUID
slug: str
name: str
plan: str
is_active: bool
max_mailboxes: int
max_users: int
created_at: datetime
updated_at: datetime
model_config = {"from_attributes": True}
+89
View File
@@ -0,0 +1,89 @@
"""
Schema Pydantic per utenti.
"""
import uuid
from datetime import datetime
from typing import Literal
from pydantic import BaseModel, EmailStr, Field, field_validator
UserRoleType = Literal["super_admin", "admin", "supervisor", "operator", "readonly"]
# ─── Request ──────────────────────────────────────────────────────────────────
class UserCreateRequest(BaseModel):
email: EmailStr
password: str = Field(min_length=8)
full_name: str = Field(min_length=2, max_length=255)
role: UserRoleType = "operator"
@field_validator("password")
@classmethod
def validate_password(cls, v: str) -> str:
if len(v) < 8:
raise ValueError("La password deve essere almeno 8 caratteri")
if not any(c.isupper() for c in v):
raise ValueError("Almeno una lettera maiuscola richiesta")
if not any(c.isdigit() for c in v):
raise ValueError("Almeno un numero richiesto")
return v
@field_validator("role")
@classmethod
def validate_role_not_superadmin(cls, v: str) -> str:
if v == "super_admin":
raise ValueError("Non è possibile creare utenti super_admin via API")
return v
class UserUpdateRequest(BaseModel):
full_name: str | None = Field(default=None, min_length=2, max_length=255)
role: UserRoleType | None = None
is_active: bool | None = None
@field_validator("role")
@classmethod
def validate_role_not_superadmin(cls, v: str | None) -> str | None:
if v == "super_admin":
raise ValueError("Non è possibile assegnare il ruolo super_admin via API")
return v
class UserPasswordResetRequest(BaseModel):
new_password: str = Field(min_length=8)
@field_validator("new_password")
@classmethod
def validate_password(cls, v: str) -> str:
if not any(c.isupper() for c in v):
raise ValueError("Almeno una lettera maiuscola richiesta")
if not any(c.isdigit() for c in v):
raise ValueError("Almeno un numero richiesto")
return v
# ─── Response ─────────────────────────────────────────────────────────────────
class UserResponse(BaseModel):
id: uuid.UUID
tenant_id: uuid.UUID
email: str
full_name: str
role: str
is_active: bool
totp_enabled: bool
last_login_at: datetime | None
created_at: datetime
updated_at: datetime
model_config = {"from_attributes": True}
class UserListResponse(BaseModel):
items: list[UserResponse]
total: int
page: int
page_size: int
pages: int
+1
View File
@@ -0,0 +1 @@
# Services
+302
View File
@@ -0,0 +1,302 @@
"""
Servizio autenticazione login, JWT, TOTP 2FA, refresh token.
"""
import base64
import io
import uuid
from datetime import UTC, datetime, timedelta
import pyotp
import qrcode
from jose import JWTError
from sqlalchemy import select, update
from sqlalchemy.ext.asyncio import AsyncSession
from app.config import get_settings
from app.core.exceptions import (
AccountDisabledError,
AccountLockedError,
InvalidCredentialsError,
TOTPInvalidError,
TOTPRequiredError,
TokenInvalidError,
)
from app.core.security import (
create_access_token,
create_refresh_token,
decode_token,
encrypt_credential,
decrypt_credential,
hash_password,
hash_token,
verify_password,
)
from app.models.audit_log import AuditLog
from app.models.user import RefreshToken, User
settings = get_settings()
# Numero massimo di tentativi falliti prima del blocco
MAX_FAILED_ATTEMPTS = 5
LOCK_DURATION_MINUTES = 15
class AuthService:
def __init__(self, db: AsyncSession) -> None:
self.db = db
async def login(
self,
email: str,
password: str,
totp_code: str | None,
ip_address: str | None = None,
user_agent: str | None = None,
) -> tuple[str, str]:
"""
Autentica l'utente con email + password (+ TOTP se abilitato).
Restituisce (access_token, refresh_token).
"""
# 1. Trova utente per email (non filtrare per tenant: l'email è unica globalmente
# per ora, ma in futuro si potrebbe filtrare per subdomain)
user = await self._get_user_by_email(email)
if not user:
await self._log_audit(None, None, "auth.login", "failure", ip_address, {"reason": "user_not_found"})
raise InvalidCredentialsError()
# 2. Verifica account attivo
if not user.is_active:
raise AccountDisabledError()
# 3. Verifica blocco temporaneo
if user.locked_until and user.locked_until > datetime.now(UTC):
locked_str = user.locked_until.strftime("%H:%M")
raise AccountLockedError(locked_until=locked_str)
# 4. Verifica password
if not verify_password(password, user.password_hash):
await self._handle_failed_login(user)
await self._log_audit(user.tenant_id, user.id, "auth.login", "failure", ip_address, {"reason": "wrong_password"})
raise InvalidCredentialsError()
# 5. Verifica TOTP (se abilitato)
if user.totp_enabled:
if not totp_code:
raise TOTPRequiredError()
if not self._verify_totp(user, totp_code):
await self._log_audit(user.tenant_id, user.id, "auth.login", "failure", ip_address, {"reason": "invalid_totp"})
raise TOTPInvalidError()
# 6. Reset contatori falliti
await self._reset_failed_login(user)
# 7. Genera token
access_token = create_access_token(
subject=user.id,
tenant_id=user.tenant_id,
role=user.role,
)
refresh_token_raw = create_refresh_token(
subject=user.id,
tenant_id=user.tenant_id,
)
# 8. Salva refresh token in DB (hash)
rt = RefreshToken(
user_id=user.id,
token_hash=hash_token(refresh_token_raw),
expires_at=datetime.now(UTC) + timedelta(days=settings.refresh_token_expire_days),
user_agent=user_agent,
ip_address=ip_address,
)
self.db.add(rt)
# 9. Aggiorna last_login_at
await self.db.execute(
update(User)
.where(User.id == user.id)
.values(last_login_at=datetime.now(UTC))
)
await self._log_audit(user.tenant_id, user.id, "auth.login", "success", ip_address, {})
return access_token, refresh_token_raw
async def refresh_tokens(self, refresh_token_raw: str) -> tuple[str, str]:
"""
Valida il refresh token e restituisce nuova coppia di token.
Implementa rotation: il vecchio refresh token viene revocato.
"""
# Valida struttura JWT
try:
payload = decode_token(refresh_token_raw)
except JWTError:
raise TokenInvalidError()
if payload.get("type") != "refresh":
raise TokenInvalidError()
# Cerca il token in DB
token_hash = hash_token(refresh_token_raw)
result = await self.db.execute(
select(RefreshToken).where(RefreshToken.token_hash == token_hash)
)
rt = result.scalar_one_or_none()
if not rt or not rt.is_valid:
raise TokenInvalidError()
# Carica l'utente
user_result = await self.db.execute(
select(User).where(User.id == rt.user_id)
)
user = user_result.scalar_one_or_none()
if not user or not user.is_active:
raise TokenInvalidError()
# Revoca il vecchio refresh token (rotation)
rt.revoked_at = datetime.now(UTC)
# Genera nuovi token
new_access = create_access_token(
subject=user.id,
tenant_id=user.tenant_id,
role=user.role,
)
new_refresh_raw = create_refresh_token(
subject=user.id,
tenant_id=user.tenant_id,
)
# Salva nuovo refresh token
new_rt = RefreshToken(
user_id=user.id,
token_hash=hash_token(new_refresh_raw),
expires_at=datetime.now(UTC) + timedelta(days=settings.refresh_token_expire_days),
ip_address=rt.ip_address,
)
self.db.add(new_rt)
return new_access, new_refresh_raw
async def logout(self, refresh_token_raw: str) -> None:
"""Revoca il refresh token (logout)."""
token_hash = hash_token(refresh_token_raw)
result = await self.db.execute(
select(RefreshToken).where(RefreshToken.token_hash == token_hash)
)
rt = result.scalar_one_or_none()
if rt:
rt.revoked_at = datetime.now(UTC)
async def setup_totp(self, user: User) -> dict:
"""
Genera segreto TOTP e QR code per l'utente.
Il segreto viene cifrato e salvato in DB ma TOTP non è ancora attivo
(richiede verifica con totp_verify).
"""
# Genera segreto base32
secret = pyotp.random_base32()
# Cifra il segreto prima di salvarlo
encrypted_secret = encrypt_credential(secret)
user.totp_secret = encrypted_secret
# Non attivare ancora: richiede verifica
user.totp_enabled = False
# Genera URI otpauth://
totp = pyotp.TOTP(secret)
uri = totp.provisioning_uri(name=user.email, issuer_name="PecFlow")
# Genera QR code
qr = qrcode.QRCode(version=1, box_size=6, border=4)
qr.add_data(uri)
qr.make(fit=True)
img = qr.make_image(fill_color="black", back_color="white")
buffered = io.BytesIO()
img.save(buffered, format="PNG")
qr_b64 = base64.b64encode(buffered.getvalue()).decode("ascii")
return {
"secret": secret,
"qr_uri": uri,
"qr_image_base64": f"data:image/png;base64,{qr_b64}",
}
async def verify_and_enable_totp(self, user: User, totp_code: str) -> bool:
"""
Verifica il codice TOTP e attiva il 2FA se corretto.
"""
if not user.totp_secret:
return False
if not self._verify_totp(user, totp_code):
raise TOTPInvalidError()
user.totp_enabled = True
return True
async def disable_totp(self, user: User) -> None:
"""Disabilita il 2FA per l'utente."""
user.totp_secret = None
user.totp_enabled = False
# ─── Private helpers ──────────────────────────────────────────────────────
async def _get_user_by_email(self, email: str) -> User | None:
result = await self.db.execute(
select(User).where(User.email == email.lower())
)
return result.scalar_one_or_none()
def _verify_totp(self, user: User, code: str) -> bool:
"""Verifica il codice TOTP (accetta ±1 intervallo per clock skew)."""
if not user.totp_secret:
return False
try:
secret = decrypt_credential(user.totp_secret)
totp = pyotp.TOTP(secret)
return totp.verify(code, valid_window=1)
except Exception:
return False
async def _handle_failed_login(self, user: User) -> None:
"""Incrementa contatore fallimenti, blocca se necessario."""
new_count = user.failed_login_count + 1
updates: dict = {"failed_login_count": new_count}
if new_count >= MAX_FAILED_ATTEMPTS:
updates["locked_until"] = datetime.now(UTC) + timedelta(minutes=LOCK_DURATION_MINUTES)
await self.db.execute(
update(User).where(User.id == user.id).values(**updates)
)
async def _reset_failed_login(self, user: User) -> None:
await self.db.execute(
update(User)
.where(User.id == user.id)
.values(failed_login_count=0, locked_until=None)
)
async def _log_audit(
self,
tenant_id: uuid.UUID | None,
user_id: uuid.UUID | None,
action: str,
outcome: str,
ip_address: str | None,
payload: dict,
) -> None:
log = AuditLog(
tenant_id=tenant_id,
user_id=user_id,
action=action,
outcome=outcome,
ip_address=ip_address,
payload=payload,
)
self.db.add(log)
+236
View File
@@ -0,0 +1,236 @@
"""
Servizio permessi granulari gestione accessi utente × casella (Fase 1-A).
Gerarchia:
super_admin / admin → accesso implicito a tutto (no record in mailbox_permissions)
supervisor / operator / readonly → richiedono record esplicito
"""
import uuid
from sqlalchemy import delete, select
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.exceptions import ConflictError, ForbiddenError, NotFoundError, PermissionDeniedError
from app.models.mailbox import Mailbox
from app.models.permission import MailboxPermission
from app.models.user import User
from app.schemas.permission import PermissionGrantRequest
class PermissionService:
def __init__(self, db: AsyncSession) -> None:
self.db = db
# ─── Verifica accessi ─────────────────────────────────────────────────────
async def get_visible_mailboxes(
self, user: User
) -> list[uuid.UUID]:
"""Restituisce gli UUID delle caselle visibili all'utente."""
if user.role in ("super_admin", "admin"):
# Admin vede tutte le caselle del tenant
result = await self.db.execute(
select(Mailbox.id).where(
Mailbox.tenant_id == user.tenant_id,
Mailbox.status != "deleted",
)
)
return [row[0] for row in result.all()]
# Operatori: solo caselle con can_read=True
result = await self.db.execute(
select(MailboxPermission.mailbox_id).where(
MailboxPermission.user_id == user.id,
MailboxPermission.can_read == True,
)
)
return [row[0] for row in result.all()]
async def check_can_read(
self, user: User, mailbox_id: uuid.UUID
) -> bool:
"""Verifica se l'utente può leggere i messaggi della casella."""
if user.role in ("super_admin", "admin"):
# Verifica solo che la casella appartenga al tenant
return await self._mailbox_belongs_to_tenant(mailbox_id, user.tenant_id)
perm = await self._get_permission(user.id, mailbox_id)
return perm is not None and perm.can_read
async def check_can_send(
self, user: User, mailbox_id: uuid.UUID
) -> bool:
"""Verifica se l'utente può inviare dalla casella."""
if user.role in ("super_admin", "admin"):
return await self._mailbox_belongs_to_tenant(mailbox_id, user.tenant_id)
perm = await self._get_permission(user.id, mailbox_id)
return perm is not None and perm.can_send
async def check_can_manage(
self, user: User, mailbox_id: uuid.UUID
) -> bool:
"""Verifica se l'utente può gestire la configurazione della casella."""
if user.role in ("super_admin", "admin"):
return await self._mailbox_belongs_to_tenant(mailbox_id, user.tenant_id)
perm = await self._get_permission(user.id, mailbox_id)
return perm is not None and perm.can_manage
async def require_can_read(self, user: User, mailbox_id: uuid.UUID) -> None:
"""Solleva 403 se l'utente non può leggere."""
if not await self.check_can_read(user, mailbox_id):
raise PermissionDeniedError("casella")
async def require_can_send(self, user: User, mailbox_id: uuid.UUID) -> None:
if not await self.check_can_send(user, mailbox_id):
raise PermissionDeniedError("casella (invio)")
# ─── CRUD permessi ────────────────────────────────────────────────────────
async def grant_permission(
self,
tenant_id: uuid.UUID,
mailbox_id: uuid.UUID,
user_id: uuid.UUID,
data: PermissionGrantRequest,
granted_by: User,
) -> MailboxPermission:
"""
Crea o aggiorna un permesso utente su una casella.
Solo admin può gestire i permessi.
"""
if not granted_by.is_admin:
raise ForbiddenError("Solo gli amministratori possono gestire i permessi")
# Verifica che casella e utente appartengano al tenant
mailbox = await self.db.get(Mailbox, mailbox_id)
if not mailbox or mailbox.tenant_id != tenant_id:
raise NotFoundError("casella")
target_user = await self.db.get(User, user_id)
if not target_user or target_user.tenant_id != tenant_id:
raise NotFoundError("utente")
# Non serve permesso esplicito per admin
if target_user.role in ("super_admin", "admin"):
raise ForbiddenError("Gli admin hanno già accesso implicito a tutte le caselle")
# Cerca permesso esistente (upsert)
existing = await self._get_permission(user_id, mailbox_id)
if existing:
existing.can_read = data.can_read
existing.can_send = data.can_send
existing.can_manage = data.can_manage
existing.granted_by = granted_by.id
return existing
perm = MailboxPermission(
tenant_id=tenant_id,
user_id=user_id,
mailbox_id=mailbox_id,
can_read=data.can_read,
can_send=data.can_send,
can_manage=data.can_manage,
granted_by=granted_by.id,
)
self.db.add(perm)
await self.db.flush()
return perm
async def revoke_permission(
self,
mailbox_id: uuid.UUID,
user_id: uuid.UUID,
revoked_by: User,
) -> None:
if not revoked_by.is_admin:
raise ForbiddenError("Solo gli amministratori possono revocare i permessi")
result = await self.db.execute(
delete(MailboxPermission).where(
MailboxPermission.mailbox_id == mailbox_id,
MailboxPermission.user_id == user_id,
)
)
if result.rowcount == 0:
raise NotFoundError("permesso")
async def list_mailbox_users(
self, mailbox_id: uuid.UUID, tenant_id: uuid.UUID
) -> list[dict]:
"""Ritorna tutti gli utenti con permesso esplicito su questa casella."""
result = await self.db.execute(
select(MailboxPermission, User)
.join(User, MailboxPermission.user_id == User.id)
.where(
MailboxPermission.mailbox_id == mailbox_id,
MailboxPermission.tenant_id == tenant_id,
)
)
rows = result.all()
return [
{
"user_id": perm.user_id,
"user_email": user.email,
"user_full_name": user.full_name,
"user_role": user.role,
"can_read": perm.can_read,
"can_send": perm.can_send,
"can_manage": perm.can_manage,
"granted_at": perm.granted_at,
}
for perm, user in rows
]
async def list_user_mailboxes(
self, user_id: uuid.UUID, tenant_id: uuid.UUID
) -> list[dict]:
"""Ritorna tutte le caselle accessibili a un utente (permessi espliciti)."""
result = await self.db.execute(
select(MailboxPermission, Mailbox)
.join(Mailbox, MailboxPermission.mailbox_id == Mailbox.id)
.where(
MailboxPermission.user_id == user_id,
MailboxPermission.tenant_id == tenant_id,
MailboxPermission.can_read == True,
)
)
rows = result.all()
return [
{
"mailbox_id": perm.mailbox_id,
"mailbox_email": mailbox.email_address,
"mailbox_display_name": mailbox.display_name,
"can_read": perm.can_read,
"can_send": perm.can_send,
"can_manage": perm.can_manage,
}
for perm, mailbox in rows
]
# ─── Private ──────────────────────────────────────────────────────────────
async def _get_permission(
self, user_id: uuid.UUID, mailbox_id: uuid.UUID
) -> MailboxPermission | None:
result = await self.db.execute(
select(MailboxPermission).where(
MailboxPermission.user_id == user_id,
MailboxPermission.mailbox_id == mailbox_id,
)
)
return result.scalar_one_or_none()
async def _mailbox_belongs_to_tenant(
self, mailbox_id: uuid.UUID, tenant_id: uuid.UUID
) -> bool:
result = await self.db.execute(
select(Mailbox.id).where(
Mailbox.id == mailbox_id,
Mailbox.tenant_id == tenant_id,
Mailbox.status != "deleted",
)
)
return result.scalar_one_or_none() is not None
+81
View File
@@ -0,0 +1,81 @@
"""
Servizio tenant gestione organizzazioni (solo super_admin).
"""
import uuid
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.exceptions import ConflictError, NotFoundError
from app.core.security import hash_password
from app.models.tenant import Tenant
from app.models.user import User
from app.schemas.tenant import TenantCreateRequest, TenantUpdateRequest
class TenantService:
def __init__(self, db: AsyncSession) -> None:
self.db = db
async def create_tenant(self, data: TenantCreateRequest) -> tuple[Tenant, User]:
"""Crea un nuovo tenant con il suo utente admin iniziale."""
# Verifica slug univoco
existing = await self.db.execute(
select(Tenant).where(Tenant.slug == data.slug)
)
if existing.scalar_one_or_none():
raise ConflictError(f"Slug '{data.slug}' già in uso")
tenant = Tenant(
slug=data.slug,
name=data.name,
plan=data.plan,
max_mailboxes=data.max_mailboxes,
max_users=data.max_users,
)
self.db.add(tenant)
await self.db.flush() # ottieni tenant.id
# Crea utente admin iniziale
admin = User(
tenant_id=tenant.id,
email=data.admin_email.lower(),
password_hash=hash_password(data.admin_password),
full_name=data.admin_full_name,
role="admin",
)
self.db.add(admin)
await self.db.flush()
return tenant, admin
async def get_tenant(self, tenant_id: uuid.UUID) -> Tenant:
tenant = await self.db.get(Tenant, tenant_id)
if not tenant:
raise NotFoundError("tenant")
return tenant
async def list_tenants(self) -> list[Tenant]:
result = await self.db.execute(
select(Tenant).order_by(Tenant.created_at.desc())
)
return list(result.scalars().all())
async def update_tenant(
self, tenant_id: uuid.UUID, data: TenantUpdateRequest
) -> Tenant:
tenant = await self.get_tenant(tenant_id)
if data.name is not None:
tenant.name = data.name
if data.plan is not None:
tenant.plan = data.plan
if data.is_active is not None:
tenant.is_active = data.is_active
if data.max_mailboxes is not None:
tenant.max_mailboxes = data.max_mailboxes
if data.max_users is not None:
tenant.max_users = data.max_users
return tenant
+145
View File
@@ -0,0 +1,145 @@
"""
Servizio utenti CRUD utenti per admin del tenant.
"""
import math
import uuid
from sqlalchemy import func, select, update
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.exceptions import ConflictError, ForbiddenError, NotFoundError, TenantLimitExceededError
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
class UserService:
def __init__(self, db: AsyncSession) -> None:
self.db = db
async def create_user(
self,
tenant_id: uuid.UUID,
data: UserCreateRequest,
created_by: User,
) -> User:
"""Crea un nuovo utente nel tenant. Solo admin può farlo."""
# Verifica limite utenti del piano
tenant = await self.db.get(Tenant, tenant_id)
if not tenant:
raise NotFoundError("tenant")
user_count_result = await self.db.execute(
select(func.count()).where(User.tenant_id == tenant_id, User.is_active == True)
)
count = user_count_result.scalar_one()
if count >= tenant.max_users:
raise TenantLimitExceededError("utenti", tenant.max_users)
# Verifica email univoca nel tenant
existing = await self.db.execute(
select(User).where(
User.tenant_id == tenant_id,
User.email == data.email.lower(),
)
)
if existing.scalar_one_or_none():
raise ConflictError(f"Email '{data.email}' già registrata in questo tenant")
# Un admin non può creare un super_admin
if data.role == "super_admin" and not created_by.is_super_admin:
raise ForbiddenError("Non puoi creare utenti super_admin")
user = User(
tenant_id=tenant_id,
email=data.email.lower(),
password_hash=hash_password(data.password),
full_name=data.full_name,
role=data.role,
)
self.db.add(user)
await self.db.flush() # ottieni l'ID
return user
async def get_user(self, user_id: uuid.UUID, tenant_id: uuid.UUID) -> User:
result = await self.db.execute(
select(User).where(User.id == user_id, User.tenant_id == tenant_id)
)
user = result.scalar_one_or_none()
if not user:
raise NotFoundError("utente")
return user
async def list_users(
self,
tenant_id: uuid.UUID,
page: int = 1,
page_size: int = 25,
) -> tuple[list[User], int]:
"""Restituisce lista utenti paginata + totale."""
offset = (page - 1) * page_size
total_result = await self.db.execute(
select(func.count()).where(User.tenant_id == tenant_id)
)
total = total_result.scalar_one()
users_result = await self.db.execute(
select(User)
.where(User.tenant_id == tenant_id)
.order_by(User.created_at.desc())
.offset(offset)
.limit(page_size)
)
users = list(users_result.scalars().all())
return users, total
async def update_user(
self,
user_id: uuid.UUID,
tenant_id: uuid.UUID,
data: UserUpdateRequest,
updated_by: User,
) -> User:
user = await self.get_user(user_id, tenant_id)
# Non si può modificare un super_admin
if user.is_super_admin and not updated_by.is_super_admin:
raise ForbiddenError("Non puoi modificare un super_admin")
if data.full_name is not None:
user.full_name = data.full_name
if data.role is not None:
user.role = data.role
if data.is_active is not None:
user.is_active = data.is_active
return user
async def reset_password(
self,
user_id: uuid.UUID,
tenant_id: uuid.UUID,
new_password: str,
) -> None:
user = await self.get_user(user_id, tenant_id)
user.password_hash = hash_password(new_password)
async def delete_user(
self,
user_id: uuid.UUID,
tenant_id: uuid.UUID,
deleted_by: User,
) -> None:
user = await self.get_user(user_id, tenant_id)
if user.id == deleted_by.id:
raise ForbiddenError("Non puoi eliminare il tuo stesso account")
if user.is_super_admin:
raise ForbiddenError("Non puoi eliminare un super_admin")
# Soft delete (disabilita invece di eliminare)
user.is_active = False