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

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

Definition of Done:
 Login, refresh token e TOTP funzionanti
 make dev porta in piedi tutto lo stack locale
 CI configurata
This commit is contained in:
2026-03-18 16:42:01 +01:00
parent 0251c2bbb0
commit 58a233236c
60 changed files with 6942 additions and 0 deletions
+1
View File
@@ -0,0 +1 @@
# Services
+302
View File
@@ -0,0 +1,302 @@
"""
Servizio autenticazione login, JWT, TOTP 2FA, refresh token.
"""
import base64
import io
import uuid
from datetime import UTC, datetime, timedelta
import pyotp
import qrcode
from jose import JWTError
from sqlalchemy import select, update
from sqlalchemy.ext.asyncio import AsyncSession
from app.config import get_settings
from app.core.exceptions import (
AccountDisabledError,
AccountLockedError,
InvalidCredentialsError,
TOTPInvalidError,
TOTPRequiredError,
TokenInvalidError,
)
from app.core.security import (
create_access_token,
create_refresh_token,
decode_token,
encrypt_credential,
decrypt_credential,
hash_password,
hash_token,
verify_password,
)
from app.models.audit_log import AuditLog
from app.models.user import RefreshToken, User
settings = get_settings()
# Numero massimo di tentativi falliti prima del blocco
MAX_FAILED_ATTEMPTS = 5
LOCK_DURATION_MINUTES = 15
class AuthService:
def __init__(self, db: AsyncSession) -> None:
self.db = db
async def login(
self,
email: str,
password: str,
totp_code: str | None,
ip_address: str | None = None,
user_agent: str | None = None,
) -> tuple[str, str]:
"""
Autentica l'utente con email + password (+ TOTP se abilitato).
Restituisce (access_token, refresh_token).
"""
# 1. Trova utente per email (non filtrare per tenant: l'email è unica globalmente
# per ora, ma in futuro si potrebbe filtrare per subdomain)
user = await self._get_user_by_email(email)
if not user:
await self._log_audit(None, None, "auth.login", "failure", ip_address, {"reason": "user_not_found"})
raise InvalidCredentialsError()
# 2. Verifica account attivo
if not user.is_active:
raise AccountDisabledError()
# 3. Verifica blocco temporaneo
if user.locked_until and user.locked_until > datetime.now(UTC):
locked_str = user.locked_until.strftime("%H:%M")
raise AccountLockedError(locked_until=locked_str)
# 4. Verifica password
if not verify_password(password, user.password_hash):
await self._handle_failed_login(user)
await self._log_audit(user.tenant_id, user.id, "auth.login", "failure", ip_address, {"reason": "wrong_password"})
raise InvalidCredentialsError()
# 5. Verifica TOTP (se abilitato)
if user.totp_enabled:
if not totp_code:
raise TOTPRequiredError()
if not self._verify_totp(user, totp_code):
await self._log_audit(user.tenant_id, user.id, "auth.login", "failure", ip_address, {"reason": "invalid_totp"})
raise TOTPInvalidError()
# 6. Reset contatori falliti
await self._reset_failed_login(user)
# 7. Genera token
access_token = create_access_token(
subject=user.id,
tenant_id=user.tenant_id,
role=user.role,
)
refresh_token_raw = create_refresh_token(
subject=user.id,
tenant_id=user.tenant_id,
)
# 8. Salva refresh token in DB (hash)
rt = RefreshToken(
user_id=user.id,
token_hash=hash_token(refresh_token_raw),
expires_at=datetime.now(UTC) + timedelta(days=settings.refresh_token_expire_days),
user_agent=user_agent,
ip_address=ip_address,
)
self.db.add(rt)
# 9. Aggiorna last_login_at
await self.db.execute(
update(User)
.where(User.id == user.id)
.values(last_login_at=datetime.now(UTC))
)
await self._log_audit(user.tenant_id, user.id, "auth.login", "success", ip_address, {})
return access_token, refresh_token_raw
async def refresh_tokens(self, refresh_token_raw: str) -> tuple[str, str]:
"""
Valida il refresh token e restituisce nuova coppia di token.
Implementa rotation: il vecchio refresh token viene revocato.
"""
# Valida struttura JWT
try:
payload = decode_token(refresh_token_raw)
except JWTError:
raise TokenInvalidError()
if payload.get("type") != "refresh":
raise TokenInvalidError()
# Cerca il token in DB
token_hash = hash_token(refresh_token_raw)
result = await self.db.execute(
select(RefreshToken).where(RefreshToken.token_hash == token_hash)
)
rt = result.scalar_one_or_none()
if not rt or not rt.is_valid:
raise TokenInvalidError()
# Carica l'utente
user_result = await self.db.execute(
select(User).where(User.id == rt.user_id)
)
user = user_result.scalar_one_or_none()
if not user or not user.is_active:
raise TokenInvalidError()
# Revoca il vecchio refresh token (rotation)
rt.revoked_at = datetime.now(UTC)
# Genera nuovi token
new_access = create_access_token(
subject=user.id,
tenant_id=user.tenant_id,
role=user.role,
)
new_refresh_raw = create_refresh_token(
subject=user.id,
tenant_id=user.tenant_id,
)
# Salva nuovo refresh token
new_rt = RefreshToken(
user_id=user.id,
token_hash=hash_token(new_refresh_raw),
expires_at=datetime.now(UTC) + timedelta(days=settings.refresh_token_expire_days),
ip_address=rt.ip_address,
)
self.db.add(new_rt)
return new_access, new_refresh_raw
async def logout(self, refresh_token_raw: str) -> None:
"""Revoca il refresh token (logout)."""
token_hash = hash_token(refresh_token_raw)
result = await self.db.execute(
select(RefreshToken).where(RefreshToken.token_hash == token_hash)
)
rt = result.scalar_one_or_none()
if rt:
rt.revoked_at = datetime.now(UTC)
async def setup_totp(self, user: User) -> dict:
"""
Genera segreto TOTP e QR code per l'utente.
Il segreto viene cifrato e salvato in DB ma TOTP non è ancora attivo
(richiede verifica con totp_verify).
"""
# Genera segreto base32
secret = pyotp.random_base32()
# Cifra il segreto prima di salvarlo
encrypted_secret = encrypt_credential(secret)
user.totp_secret = encrypted_secret
# Non attivare ancora: richiede verifica
user.totp_enabled = False
# Genera URI otpauth://
totp = pyotp.TOTP(secret)
uri = totp.provisioning_uri(name=user.email, issuer_name="PecFlow")
# Genera QR code
qr = qrcode.QRCode(version=1, box_size=6, border=4)
qr.add_data(uri)
qr.make(fit=True)
img = qr.make_image(fill_color="black", back_color="white")
buffered = io.BytesIO()
img.save(buffered, format="PNG")
qr_b64 = base64.b64encode(buffered.getvalue()).decode("ascii")
return {
"secret": secret,
"qr_uri": uri,
"qr_image_base64": f"data:image/png;base64,{qr_b64}",
}
async def verify_and_enable_totp(self, user: User, totp_code: str) -> bool:
"""
Verifica il codice TOTP e attiva il 2FA se corretto.
"""
if not user.totp_secret:
return False
if not self._verify_totp(user, totp_code):
raise TOTPInvalidError()
user.totp_enabled = True
return True
async def disable_totp(self, user: User) -> None:
"""Disabilita il 2FA per l'utente."""
user.totp_secret = None
user.totp_enabled = False
# ─── Private helpers ──────────────────────────────────────────────────────
async def _get_user_by_email(self, email: str) -> User | None:
result = await self.db.execute(
select(User).where(User.email == email.lower())
)
return result.scalar_one_or_none()
def _verify_totp(self, user: User, code: str) -> bool:
"""Verifica il codice TOTP (accetta ±1 intervallo per clock skew)."""
if not user.totp_secret:
return False
try:
secret = decrypt_credential(user.totp_secret)
totp = pyotp.TOTP(secret)
return totp.verify(code, valid_window=1)
except Exception:
return False
async def _handle_failed_login(self, user: User) -> None:
"""Incrementa contatore fallimenti, blocca se necessario."""
new_count = user.failed_login_count + 1
updates: dict = {"failed_login_count": new_count}
if new_count >= MAX_FAILED_ATTEMPTS:
updates["locked_until"] = datetime.now(UTC) + timedelta(minutes=LOCK_DURATION_MINUTES)
await self.db.execute(
update(User).where(User.id == user.id).values(**updates)
)
async def _reset_failed_login(self, user: User) -> None:
await self.db.execute(
update(User)
.where(User.id == user.id)
.values(failed_login_count=0, locked_until=None)
)
async def _log_audit(
self,
tenant_id: uuid.UUID | None,
user_id: uuid.UUID | None,
action: str,
outcome: str,
ip_address: str | None,
payload: dict,
) -> None:
log = AuditLog(
tenant_id=tenant_id,
user_id=user_id,
action=action,
outcome=outcome,
ip_address=ip_address,
payload=payload,
)
self.db.add(log)
+236
View File
@@ -0,0 +1,236 @@
"""
Servizio permessi granulari gestione accessi utente × casella (Fase 1-A).
Gerarchia:
super_admin / admin → accesso implicito a tutto (no record in mailbox_permissions)
supervisor / operator / readonly → richiedono record esplicito
"""
import uuid
from sqlalchemy import delete, select
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.exceptions import ConflictError, ForbiddenError, NotFoundError, PermissionDeniedError
from app.models.mailbox import Mailbox
from app.models.permission import MailboxPermission
from app.models.user import User
from app.schemas.permission import PermissionGrantRequest
class PermissionService:
def __init__(self, db: AsyncSession) -> None:
self.db = db
# ─── Verifica accessi ─────────────────────────────────────────────────────
async def get_visible_mailboxes(
self, user: User
) -> list[uuid.UUID]:
"""Restituisce gli UUID delle caselle visibili all'utente."""
if user.role in ("super_admin", "admin"):
# Admin vede tutte le caselle del tenant
result = await self.db.execute(
select(Mailbox.id).where(
Mailbox.tenant_id == user.tenant_id,
Mailbox.status != "deleted",
)
)
return [row[0] for row in result.all()]
# Operatori: solo caselle con can_read=True
result = await self.db.execute(
select(MailboxPermission.mailbox_id).where(
MailboxPermission.user_id == user.id,
MailboxPermission.can_read == True,
)
)
return [row[0] for row in result.all()]
async def check_can_read(
self, user: User, mailbox_id: uuid.UUID
) -> bool:
"""Verifica se l'utente può leggere i messaggi della casella."""
if user.role in ("super_admin", "admin"):
# Verifica solo che la casella appartenga al tenant
return await self._mailbox_belongs_to_tenant(mailbox_id, user.tenant_id)
perm = await self._get_permission(user.id, mailbox_id)
return perm is not None and perm.can_read
async def check_can_send(
self, user: User, mailbox_id: uuid.UUID
) -> bool:
"""Verifica se l'utente può inviare dalla casella."""
if user.role in ("super_admin", "admin"):
return await self._mailbox_belongs_to_tenant(mailbox_id, user.tenant_id)
perm = await self._get_permission(user.id, mailbox_id)
return perm is not None and perm.can_send
async def check_can_manage(
self, user: User, mailbox_id: uuid.UUID
) -> bool:
"""Verifica se l'utente può gestire la configurazione della casella."""
if user.role in ("super_admin", "admin"):
return await self._mailbox_belongs_to_tenant(mailbox_id, user.tenant_id)
perm = await self._get_permission(user.id, mailbox_id)
return perm is not None and perm.can_manage
async def require_can_read(self, user: User, mailbox_id: uuid.UUID) -> None:
"""Solleva 403 se l'utente non può leggere."""
if not await self.check_can_read(user, mailbox_id):
raise PermissionDeniedError("casella")
async def require_can_send(self, user: User, mailbox_id: uuid.UUID) -> None:
if not await self.check_can_send(user, mailbox_id):
raise PermissionDeniedError("casella (invio)")
# ─── CRUD permessi ────────────────────────────────────────────────────────
async def grant_permission(
self,
tenant_id: uuid.UUID,
mailbox_id: uuid.UUID,
user_id: uuid.UUID,
data: PermissionGrantRequest,
granted_by: User,
) -> MailboxPermission:
"""
Crea o aggiorna un permesso utente su una casella.
Solo admin può gestire i permessi.
"""
if not granted_by.is_admin:
raise ForbiddenError("Solo gli amministratori possono gestire i permessi")
# Verifica che casella e utente appartengano al tenant
mailbox = await self.db.get(Mailbox, mailbox_id)
if not mailbox or mailbox.tenant_id != tenant_id:
raise NotFoundError("casella")
target_user = await self.db.get(User, user_id)
if not target_user or target_user.tenant_id != tenant_id:
raise NotFoundError("utente")
# Non serve permesso esplicito per admin
if target_user.role in ("super_admin", "admin"):
raise ForbiddenError("Gli admin hanno già accesso implicito a tutte le caselle")
# Cerca permesso esistente (upsert)
existing = await self._get_permission(user_id, mailbox_id)
if existing:
existing.can_read = data.can_read
existing.can_send = data.can_send
existing.can_manage = data.can_manage
existing.granted_by = granted_by.id
return existing
perm = MailboxPermission(
tenant_id=tenant_id,
user_id=user_id,
mailbox_id=mailbox_id,
can_read=data.can_read,
can_send=data.can_send,
can_manage=data.can_manage,
granted_by=granted_by.id,
)
self.db.add(perm)
await self.db.flush()
return perm
async def revoke_permission(
self,
mailbox_id: uuid.UUID,
user_id: uuid.UUID,
revoked_by: User,
) -> None:
if not revoked_by.is_admin:
raise ForbiddenError("Solo gli amministratori possono revocare i permessi")
result = await self.db.execute(
delete(MailboxPermission).where(
MailboxPermission.mailbox_id == mailbox_id,
MailboxPermission.user_id == user_id,
)
)
if result.rowcount == 0:
raise NotFoundError("permesso")
async def list_mailbox_users(
self, mailbox_id: uuid.UUID, tenant_id: uuid.UUID
) -> list[dict]:
"""Ritorna tutti gli utenti con permesso esplicito su questa casella."""
result = await self.db.execute(
select(MailboxPermission, User)
.join(User, MailboxPermission.user_id == User.id)
.where(
MailboxPermission.mailbox_id == mailbox_id,
MailboxPermission.tenant_id == tenant_id,
)
)
rows = result.all()
return [
{
"user_id": perm.user_id,
"user_email": user.email,
"user_full_name": user.full_name,
"user_role": user.role,
"can_read": perm.can_read,
"can_send": perm.can_send,
"can_manage": perm.can_manage,
"granted_at": perm.granted_at,
}
for perm, user in rows
]
async def list_user_mailboxes(
self, user_id: uuid.UUID, tenant_id: uuid.UUID
) -> list[dict]:
"""Ritorna tutte le caselle accessibili a un utente (permessi espliciti)."""
result = await self.db.execute(
select(MailboxPermission, Mailbox)
.join(Mailbox, MailboxPermission.mailbox_id == Mailbox.id)
.where(
MailboxPermission.user_id == user_id,
MailboxPermission.tenant_id == tenant_id,
MailboxPermission.can_read == True,
)
)
rows = result.all()
return [
{
"mailbox_id": perm.mailbox_id,
"mailbox_email": mailbox.email_address,
"mailbox_display_name": mailbox.display_name,
"can_read": perm.can_read,
"can_send": perm.can_send,
"can_manage": perm.can_manage,
}
for perm, mailbox in rows
]
# ─── Private ──────────────────────────────────────────────────────────────
async def _get_permission(
self, user_id: uuid.UUID, mailbox_id: uuid.UUID
) -> MailboxPermission | None:
result = await self.db.execute(
select(MailboxPermission).where(
MailboxPermission.user_id == user_id,
MailboxPermission.mailbox_id == mailbox_id,
)
)
return result.scalar_one_or_none()
async def _mailbox_belongs_to_tenant(
self, mailbox_id: uuid.UUID, tenant_id: uuid.UUID
) -> bool:
result = await self.db.execute(
select(Mailbox.id).where(
Mailbox.id == mailbox_id,
Mailbox.tenant_id == tenant_id,
Mailbox.status != "deleted",
)
)
return result.scalar_one_or_none() is not None
+81
View File
@@ -0,0 +1,81 @@
"""
Servizio tenant gestione organizzazioni (solo super_admin).
"""
import uuid
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.exceptions import ConflictError, NotFoundError
from app.core.security import hash_password
from app.models.tenant import Tenant
from app.models.user import User
from app.schemas.tenant import TenantCreateRequest, TenantUpdateRequest
class TenantService:
def __init__(self, db: AsyncSession) -> None:
self.db = db
async def create_tenant(self, data: TenantCreateRequest) -> tuple[Tenant, User]:
"""Crea un nuovo tenant con il suo utente admin iniziale."""
# Verifica slug univoco
existing = await self.db.execute(
select(Tenant).where(Tenant.slug == data.slug)
)
if existing.scalar_one_or_none():
raise ConflictError(f"Slug '{data.slug}' già in uso")
tenant = Tenant(
slug=data.slug,
name=data.name,
plan=data.plan,
max_mailboxes=data.max_mailboxes,
max_users=data.max_users,
)
self.db.add(tenant)
await self.db.flush() # ottieni tenant.id
# Crea utente admin iniziale
admin = User(
tenant_id=tenant.id,
email=data.admin_email.lower(),
password_hash=hash_password(data.admin_password),
full_name=data.admin_full_name,
role="admin",
)
self.db.add(admin)
await self.db.flush()
return tenant, admin
async def get_tenant(self, tenant_id: uuid.UUID) -> Tenant:
tenant = await self.db.get(Tenant, tenant_id)
if not tenant:
raise NotFoundError("tenant")
return tenant
async def list_tenants(self) -> list[Tenant]:
result = await self.db.execute(
select(Tenant).order_by(Tenant.created_at.desc())
)
return list(result.scalars().all())
async def update_tenant(
self, tenant_id: uuid.UUID, data: TenantUpdateRequest
) -> Tenant:
tenant = await self.get_tenant(tenant_id)
if data.name is not None:
tenant.name = data.name
if data.plan is not None:
tenant.plan = data.plan
if data.is_active is not None:
tenant.is_active = data.is_active
if data.max_mailboxes is not None:
tenant.max_mailboxes = data.max_mailboxes
if data.max_users is not None:
tenant.max_users = data.max_users
return tenant
+145
View File
@@ -0,0 +1,145 @@
"""
Servizio utenti CRUD utenti per admin del tenant.
"""
import math
import uuid
from sqlalchemy import func, select, update
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.exceptions import ConflictError, ForbiddenError, NotFoundError, TenantLimitExceededError
from app.core.security import hash_password
from app.models.tenant import Tenant
from app.models.user import User
from app.schemas.user import UserCreateRequest, UserUpdateRequest
class UserService:
def __init__(self, db: AsyncSession) -> None:
self.db = db
async def create_user(
self,
tenant_id: uuid.UUID,
data: UserCreateRequest,
created_by: User,
) -> User:
"""Crea un nuovo utente nel tenant. Solo admin può farlo."""
# Verifica limite utenti del piano
tenant = await self.db.get(Tenant, tenant_id)
if not tenant:
raise NotFoundError("tenant")
user_count_result = await self.db.execute(
select(func.count()).where(User.tenant_id == tenant_id, User.is_active == True)
)
count = user_count_result.scalar_one()
if count >= tenant.max_users:
raise TenantLimitExceededError("utenti", tenant.max_users)
# Verifica email univoca nel tenant
existing = await self.db.execute(
select(User).where(
User.tenant_id == tenant_id,
User.email == data.email.lower(),
)
)
if existing.scalar_one_or_none():
raise ConflictError(f"Email '{data.email}' già registrata in questo tenant")
# Un admin non può creare un super_admin
if data.role == "super_admin" and not created_by.is_super_admin:
raise ForbiddenError("Non puoi creare utenti super_admin")
user = User(
tenant_id=tenant_id,
email=data.email.lower(),
password_hash=hash_password(data.password),
full_name=data.full_name,
role=data.role,
)
self.db.add(user)
await self.db.flush() # ottieni l'ID
return user
async def get_user(self, user_id: uuid.UUID, tenant_id: uuid.UUID) -> User:
result = await self.db.execute(
select(User).where(User.id == user_id, User.tenant_id == tenant_id)
)
user = result.scalar_one_or_none()
if not user:
raise NotFoundError("utente")
return user
async def list_users(
self,
tenant_id: uuid.UUID,
page: int = 1,
page_size: int = 25,
) -> tuple[list[User], int]:
"""Restituisce lista utenti paginata + totale."""
offset = (page - 1) * page_size
total_result = await self.db.execute(
select(func.count()).where(User.tenant_id == tenant_id)
)
total = total_result.scalar_one()
users_result = await self.db.execute(
select(User)
.where(User.tenant_id == tenant_id)
.order_by(User.created_at.desc())
.offset(offset)
.limit(page_size)
)
users = list(users_result.scalars().all())
return users, total
async def update_user(
self,
user_id: uuid.UUID,
tenant_id: uuid.UUID,
data: UserUpdateRequest,
updated_by: User,
) -> User:
user = await self.get_user(user_id, tenant_id)
# Non si può modificare un super_admin
if user.is_super_admin and not updated_by.is_super_admin:
raise ForbiddenError("Non puoi modificare un super_admin")
if data.full_name is not None:
user.full_name = data.full_name
if data.role is not None:
user.role = data.role
if data.is_active is not None:
user.is_active = data.is_active
return user
async def reset_password(
self,
user_id: uuid.UUID,
tenant_id: uuid.UUID,
new_password: str,
) -> None:
user = await self.get_user(user_id, tenant_id)
user.password_hash = hash_password(new_password)
async def delete_user(
self,
user_id: uuid.UUID,
tenant_id: uuid.UUID,
deleted_by: User,
) -> None:
user = await self.get_user(user_id, tenant_id)
if user.id == deleted_by.id:
raise ForbiddenError("Non puoi eliminare il tuo stesso account")
if user.is_super_admin:
raise ForbiddenError("Non puoi eliminare un super_admin")
# Soft delete (disabilita invece di eliminare)
user.is_active = False