mirror of
https://github.com/idrainformatica/PecFlow.git
synced 2026-06-16 12:45:42 +02:00
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:
@@ -0,0 +1 @@
|
||||
# PecFlow Backend
|
||||
@@ -0,0 +1 @@
|
||||
# API routers
|
||||
@@ -0,0 +1 @@
|
||||
# API v1 routers
|
||||
@@ -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)
|
||||
@@ -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]
|
||||
@@ -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)
|
||||
@@ -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,
|
||||
)
|
||||
@@ -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()
|
||||
@@ -0,0 +1 @@
|
||||
# Core utilities
|
||||
@@ -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,
|
||||
)
|
||||
@@ -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)
|
||||
@@ -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,
|
||||
)
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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)]
|
||||
@@ -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"},
|
||||
)
|
||||
@@ -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
|
||||
@@ -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)
|
||||
@@ -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}>"
|
||||
@@ -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,
|
||||
)
|
||||
@@ -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}>"
|
||||
@@ -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')",
|
||||
),
|
||||
)
|
||||
@@ -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}>"
|
||||
)
|
||||
@@ -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})>"
|
||||
@@ -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}>"
|
||||
@@ -0,0 +1 @@
|
||||
# Schemas Pydantic
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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}
|
||||
@@ -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
|
||||
@@ -0,0 +1 @@
|
||||
# Services
|
||||
@@ -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)
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user