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 @@
|
||||
# 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