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
+26
View File
@@ -0,0 +1,26 @@
FROM python:3.12-slim
# Dipendenze di sistema
RUN apt-get update && apt-get install -y --no-install-recommends \
curl \
gcc \
libpq-dev \
&& rm -rf /var/lib/apt/lists/*
WORKDIR /app
# Aggiorna pip e setuptools prima di tutto
RUN pip install --no-cache-dir --upgrade pip setuptools wheel
# Copia pyproject.toml e installa dipendenze (layer cache separato dal codice)
COPY pyproject.toml .
# Crea struttura minima per permettere l'installazione
RUN mkdir -p app && touch app/__init__.py
RUN pip install --no-cache-dir -e ".[dev]"
# Copia il codice sorgente (sovrascrive app/__init__.py vuoto)
COPY . .
EXPOSE 8000
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]
+55
View File
@@ -0,0 +1,55 @@
# Alembic configurazione migrazioni database
[alembic]
# Percorso directory migrazioni
script_location = alembic
# Template per nuovi file migrazione
file_template = %%(rev)s_%%(slug)s
# Timezone per timestamp nelle revisioni
timezone = UTC
# Opzioni connessione (override da env.py)
# sqlalchemy.url = postgresql://...
[post_write_hooks]
# ruff format sui file migrazione generati
hooks = ruff
ruff.type = exec
ruff.executable = %(here)s/.venv/bin/ruff
ruff.options = format REVISION_SCRIPT_FILENAME
[loggers]
keys = root,sqlalchemy,alembic
[handlers]
keys = console
[formatters]
keys = generic
[logger_root]
level = WARN
handlers = console
qualname =
[logger_sqlalchemy]
level = WARN
handlers =
qualname = sqlalchemy.engine
[logger_alembic]
level = INFO
handlers =
qualname = alembic
[handler_console]
class = StreamHandler
args = (sys.stderr,)
level = NOTSET
formatter = generic
[formatter_generic]
format = %(levelname)-5.5s [%(name)s] %(message)s
datefmt = %H:%M:%S
+1
View File
@@ -0,0 +1 @@
# Alembic migrations
+76
View File
@@ -0,0 +1,76 @@
"""
Alembic env.py configurazione migrazioni per PostgreSQL.
Usa il driver SYNC (psycopg2) che è più semplice e compatibile con Alembic.
"""
from logging.config import fileConfig
from alembic import context
from sqlalchemy import engine_from_config, pool
import sys
import os
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
from app.config import get_settings
from app.database import Base
# Importa tutti i modelli per permettere l'autogenerazione
import app.models # noqa: F401
settings = get_settings()
# Configurazione Alembic
config = context.config
# Configura logging da alembic.ini
if config.config_file_name is not None:
fileConfig(config.config_file_name)
# Metadata per autogenerazione
target_metadata = Base.metadata
# Usa DATABASE_URL_SYNC (psycopg2) per le migrazioni
config.set_main_option("sqlalchemy.url", settings.database_url_sync)
def run_migrations_offline() -> None:
"""Esegui migrazioni in modalità 'offline' (genera SQL senza connettersi)."""
url = config.get_main_option("sqlalchemy.url")
context.configure(
url=url,
target_metadata=target_metadata,
literal_binds=True,
dialect_opts={"paramstyle": "named"},
compare_type=True,
compare_server_default=True,
)
with context.begin_transaction():
context.run_migrations()
def run_migrations_online() -> None:
"""Esegui migrazioni in modalità 'online' con connessione DB diretta."""
connectable = engine_from_config(
config.get_section(config.config_ini_section, {}),
prefix="sqlalchemy.",
poolclass=pool.NullPool,
)
with connectable.connect() as connection:
context.configure(
connection=connection,
target_metadata=target_metadata,
compare_type=True,
compare_server_default=True,
)
with context.begin_transaction():
context.run_migrations()
if context.is_offline_mode():
run_migrations_offline()
else:
run_migrations_online()
@@ -0,0 +1,358 @@
"""Initial schema tutte le tabelle PecFlow Fase 1
Revision ID: 0001
Revises:
Create Date: 2026-03-18 00:00:00.000000
"""
from alembic import op
revision = "0001"
down_revision = None
branch_labels = None
depends_on = None
def upgrade() -> None:
# Esegui l'intero schema come SQL puro (più affidabile con ENUM types)
op.execute("""
-- ── Estensioni ───────────────────────────────────────────────────────
CREATE EXTENSION IF NOT EXISTS "uuid-ossp";
CREATE EXTENSION IF NOT EXISTS "pgcrypto";
""")
# ── ENUM types ────────────────────────────────────────────────────────────
op.execute("CREATE TYPE user_role AS ENUM ('super_admin','admin','supervisor','operator','readonly')")
op.execute("CREATE TYPE mailbox_status AS ENUM ('active','paused','error','deleted')")
op.execute("CREATE TYPE pec_direction AS ENUM ('inbound','outbound')")
op.execute("CREATE TYPE pec_state AS ENUM ('draft','queued','sent','accepted','delivered','anomaly','failed','received')")
op.execute("CREATE TYPE pec_msg_type AS ENUM ('posta_certificata','accettazione','non_accettazione','presa_in_carico','avvenuta_consegna','mancata_consegna','errore_consegna','preavviso_mancata_consegna','rilevazione_virus','unknown')")
op.execute("CREATE TYPE send_job_status AS ENUM ('pending','sending','sent','failed','retrying')")
op.execute("CREATE TYPE archival_status AS ENUM ('pending','building_sip','uploading','uploaded','confirmed','rejected','failed')")
# ── 1. TENANTS ────────────────────────────────────────────────────────────
op.execute("""
CREATE TABLE tenants (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
slug VARCHAR(63) NOT NULL UNIQUE,
name VARCHAR(255) NOT NULL,
plan VARCHAR(50) NOT NULL DEFAULT 'starter',
is_active BOOLEAN NOT NULL DEFAULT TRUE,
max_mailboxes INT NOT NULL DEFAULT 5,
max_users INT NOT NULL DEFAULT 10,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
)
""")
op.execute("CREATE INDEX idx_tenants_slug ON tenants (slug)")
# ── 2. USERS ──────────────────────────────────────────────────────────────
op.execute("""
CREATE TABLE users (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
tenant_id UUID NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
email VARCHAR(255) NOT NULL,
password_hash TEXT NOT NULL,
full_name VARCHAR(255) NOT NULL,
role user_role NOT NULL DEFAULT 'operator',
is_active BOOLEAN NOT NULL DEFAULT TRUE,
totp_secret TEXT,
totp_enabled BOOLEAN NOT NULL DEFAULT FALSE,
last_login_at TIMESTAMPTZ,
failed_login_count INT NOT NULL DEFAULT 0,
locked_until TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
CONSTRAINT uq_user_email_tenant UNIQUE (tenant_id, email)
)
""")
op.execute("CREATE INDEX idx_users_tenant ON users (tenant_id)")
op.execute("CREATE INDEX idx_users_email ON users (email)")
op.execute("ALTER TABLE users ENABLE ROW LEVEL SECURITY")
op.execute("""
CREATE POLICY users_tenant_isolation ON users
USING (tenant_id = current_setting('app.current_tenant_id', TRUE)::UUID)
""")
# ── 3. REFRESH TOKENS ─────────────────────────────────────────────────────
op.execute("""
CREATE TABLE refresh_tokens (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
token_hash TEXT NOT NULL UNIQUE,
issued_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
expires_at TIMESTAMPTZ NOT NULL,
revoked_at TIMESTAMPTZ,
user_agent TEXT,
ip_address INET
)
""")
op.execute("CREATE INDEX idx_rt_user ON refresh_tokens (user_id)")
op.execute("CREATE INDEX idx_rt_expires ON refresh_tokens (expires_at) WHERE revoked_at IS NULL")
# ── 4. MAILBOXES ──────────────────────────────────────────────────────────
op.execute("""
CREATE TABLE mailboxes (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
tenant_id UUID NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
email_address VARCHAR(255) NOT NULL,
display_name VARCHAR(255),
provider VARCHAR(100),
imap_host_enc TEXT NOT NULL,
imap_port_enc TEXT NOT NULL,
imap_user_enc TEXT NOT NULL,
imap_pass_enc TEXT NOT NULL,
imap_use_ssl BOOLEAN NOT NULL DEFAULT TRUE,
smtp_host_enc TEXT NOT NULL,
smtp_port_enc TEXT NOT NULL,
smtp_user_enc TEXT NOT NULL,
smtp_pass_enc TEXT NOT NULL,
smtp_use_tls BOOLEAN NOT NULL DEFAULT TRUE,
status mailbox_status NOT NULL DEFAULT 'active',
last_sync_at TIMESTAMPTZ,
last_sync_uid BIGINT,
sync_error_msg TEXT,
sync_error_count INT NOT NULL DEFAULT 0,
created_by UUID REFERENCES users(id),
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
CONSTRAINT uq_mailbox_email_tenant UNIQUE (tenant_id, email_address)
)
""")
op.execute("CREATE INDEX idx_mailboxes_tenant ON mailboxes (tenant_id)")
op.execute("CREATE INDEX idx_mailboxes_status ON mailboxes (status) WHERE status = 'active'")
op.execute("ALTER TABLE mailboxes ENABLE ROW LEVEL SECURITY")
op.execute("""
CREATE POLICY mailboxes_tenant_isolation ON mailboxes
USING (tenant_id = current_setting('app.current_tenant_id', TRUE)::UUID)
""")
# ── 5. MESSAGES ───────────────────────────────────────────────────────────
op.execute("""
CREATE TABLE messages (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
tenant_id UUID NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
mailbox_id UUID NOT NULL REFERENCES mailboxes(id) ON DELETE CASCADE,
message_id_header TEXT,
imap_uid BIGINT,
imap_folder VARCHAR(255) NOT NULL DEFAULT 'INBOX',
direction pec_direction NOT NULL,
pec_type pec_msg_type NOT NULL DEFAULT 'posta_certificata',
state pec_state NOT NULL,
subject TEXT,
from_address VARCHAR(255),
to_addresses TEXT[],
cc_addresses TEXT[],
sent_at TIMESTAMPTZ,
received_at TIMESTAMPTZ,
size_bytes BIGINT,
body_text TEXT,
body_html TEXT,
has_attachments BOOLEAN NOT NULL DEFAULT FALSE,
parent_message_id UUID REFERENCES messages(id),
is_read BOOLEAN NOT NULL DEFAULT FALSE,
is_starred BOOLEAN NOT NULL DEFAULT FALSE,
is_archived BOOLEAN NOT NULL DEFAULT FALSE,
archived_at TIMESTAMPTZ,
raw_eml_path TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
)
""")
op.execute("CREATE INDEX idx_messages_tenant ON messages (tenant_id)")
op.execute("CREATE INDEX idx_messages_mailbox ON messages (mailbox_id)")
op.execute("CREATE INDEX idx_messages_state ON messages (state)")
op.execute("CREATE INDEX idx_messages_received_at ON messages (received_at DESC)")
op.execute("CREATE INDEX idx_messages_imap_uid ON messages (mailbox_id, imap_uid)")
op.execute("CREATE INDEX idx_messages_parent ON messages (parent_message_id) WHERE parent_message_id IS NOT NULL")
op.execute("CREATE INDEX idx_messages_subject_fts ON messages USING GIN (to_tsvector('italian', COALESCE(subject, '')))")
op.execute("ALTER TABLE messages ENABLE ROW LEVEL SECURITY")
op.execute("""
CREATE POLICY messages_tenant_isolation ON messages
USING (tenant_id = current_setting('app.current_tenant_id', TRUE)::UUID)
""")
# ── 6. ATTACHMENTS ────────────────────────────────────────────────────────
op.execute("""
CREATE TABLE attachments (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
tenant_id UUID NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
message_id UUID NOT NULL REFERENCES messages(id) ON DELETE CASCADE,
filename VARCHAR(512) NOT NULL,
content_type VARCHAR(255),
size_bytes BIGINT,
storage_path TEXT NOT NULL,
checksum_sha256 CHAR(64),
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
)
""")
op.execute("CREATE INDEX idx_attachments_message ON attachments (message_id)")
# ── 7. SEND_JOBS ──────────────────────────────────────────────────────────
op.execute("""
CREATE TABLE send_jobs (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
tenant_id UUID NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
mailbox_id UUID NOT NULL REFERENCES mailboxes(id),
message_id UUID REFERENCES messages(id),
status send_job_status NOT NULL DEFAULT 'pending',
attempt_count INT NOT NULL DEFAULT 0,
max_attempts INT NOT NULL DEFAULT 5,
next_retry_at TIMESTAMPTZ,
last_error TEXT,
queued_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
sent_at TIMESTAMPTZ,
created_by UUID REFERENCES users(id)
)
""")
op.execute("CREATE INDEX idx_sendjobs_tenant ON send_jobs (tenant_id)")
op.execute("CREATE INDEX idx_sendjobs_status ON send_jobs (status, next_retry_at) WHERE status IN ('pending','retrying')")
# ── 8. ARCHIVAL ───────────────────────────────────────────────────────────
op.execute("""
CREATE TABLE archival_batches (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
tenant_id UUID NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
conservatore_id VARCHAR(100) NOT NULL,
status archival_status NOT NULL DEFAULT 'pending',
sip_path TEXT,
sip_checksum CHAR(64),
versamento_id TEXT,
rdv_received_at TIMESTAMPTZ,
rdv_path TEXT,
rdv_checksum CHAR(64),
attempt_count INT NOT NULL DEFAULT 0,
max_attempts INT NOT NULL DEFAULT 3,
next_retry_at TIMESTAMPTZ,
last_error TEXT,
period_from DATE NOT NULL,
period_to DATE NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
)
""")
op.execute("CREATE INDEX idx_archival_tenant ON archival_batches (tenant_id)")
op.execute("CREATE INDEX idx_archival_status ON archival_batches (status, next_retry_at)")
op.execute("""
CREATE TABLE archival_batch_messages (
batch_id UUID NOT NULL REFERENCES archival_batches(id) ON DELETE CASCADE,
message_id UUID NOT NULL REFERENCES messages(id) ON DELETE CASCADE,
PRIMARY KEY (batch_id, message_id)
)
""")
op.execute("""
CREATE TABLE archival_dips (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
tenant_id UUID NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
batch_id UUID REFERENCES archival_batches(id),
requested_by UUID REFERENCES users(id),
dip_path TEXT,
status VARCHAR(50) NOT NULL DEFAULT 'requested',
requested_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
received_at TIMESTAMPTZ
)
""")
# ── 9. AUDIT_LOG ──────────────────────────────────────────────────────────
op.execute("""
CREATE TABLE audit_log (
id BIGSERIAL PRIMARY KEY,
tenant_id UUID REFERENCES tenants(id),
user_id UUID REFERENCES users(id),
action VARCHAR(100) NOT NULL,
resource_type VARCHAR(100),
resource_id UUID,
ip_address INET,
user_agent TEXT,
payload JSONB,
outcome VARCHAR(20) NOT NULL DEFAULT 'success',
occurred_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
)
""")
op.execute("CREATE INDEX idx_audit_tenant_date ON audit_log (tenant_id, occurred_at DESC)")
op.execute("CREATE INDEX idx_audit_user ON audit_log (user_id, occurred_at DESC)")
op.execute("CREATE INDEX idx_audit_action ON audit_log (action)")
op.execute("ALTER TABLE audit_log ENABLE ROW LEVEL SECURITY")
op.execute("CREATE POLICY audit_no_delete ON audit_log FOR DELETE USING (FALSE)")
op.execute("CREATE POLICY audit_no_update ON audit_log FOR UPDATE USING (FALSE)")
op.execute("""
CREATE POLICY audit_tenant_read ON audit_log FOR SELECT
USING (tenant_id = current_setting('app.current_tenant_id', TRUE)::UUID)
""")
# ── 10. LABELS ────────────────────────────────────────────────────────────
op.execute("""
CREATE TABLE labels (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
tenant_id UUID NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
name VARCHAR(100) NOT NULL,
color CHAR(7),
CONSTRAINT uq_label_name_tenant UNIQUE (tenant_id, name)
)
""")
op.execute("""
CREATE TABLE message_labels (
message_id UUID NOT NULL REFERENCES messages(id) ON DELETE CASCADE,
label_id UUID NOT NULL REFERENCES labels(id) ON DELETE CASCADE,
PRIMARY KEY (message_id, label_id)
)
""")
# ── 11. MAILBOX_PERMISSIONS (Fase 1-A) ────────────────────────────────────
op.execute("""
CREATE TABLE mailbox_permissions (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
tenant_id UUID NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
mailbox_id UUID NOT NULL REFERENCES mailboxes(id) ON DELETE CASCADE,
can_read BOOLEAN NOT NULL DEFAULT TRUE,
can_send BOOLEAN NOT NULL DEFAULT FALSE,
can_manage BOOLEAN NOT NULL DEFAULT FALSE,
granted_by UUID REFERENCES users(id),
granted_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
CONSTRAINT uq_perm_user_mailbox UNIQUE (user_id, mailbox_id)
)
""")
op.execute("CREATE INDEX idx_mbperm_user ON mailbox_permissions (user_id)")
op.execute("CREATE INDEX idx_mbperm_mailbox ON mailbox_permissions (mailbox_id)")
op.execute("CREATE INDEX idx_mbperm_tenant ON mailbox_permissions (tenant_id)")
# ── Trigger updated_at ────────────────────────────────────────────────────
op.execute("""
CREATE OR REPLACE FUNCTION set_updated_at()
RETURNS TRIGGER LANGUAGE plpgsql AS $$
BEGIN NEW.updated_at = NOW(); RETURN NEW; END;
$$
""")
for table in ["tenants", "users", "mailboxes", "messages", "archival_batches"]:
op.execute(f"""
CREATE TRIGGER trg_{table}_updated_at
BEFORE UPDATE ON {table}
FOR EACH ROW EXECUTE FUNCTION set_updated_at()
""")
def downgrade() -> None:
# Rimuovi trigger
for table in ["tenants", "users", "mailboxes", "messages", "archival_batches"]:
op.execute(f"DROP TRIGGER IF EXISTS trg_{table}_updated_at ON {table}")
op.execute("DROP FUNCTION IF EXISTS set_updated_at()")
# Rimuovi tabelle (ordine inverso per FK)
for table in [
"mailbox_permissions", "message_labels", "labels",
"audit_log", "archival_dips", "archival_batch_messages",
"archival_batches", "send_jobs", "attachments", "messages",
"mailboxes", "refresh_tokens", "users", "tenants",
]:
op.execute(f"DROP TABLE IF EXISTS {table} CASCADE")
# Rimuovi enum types
for enum_name in [
"archival_status", "send_job_status", "pec_msg_type",
"pec_state", "pec_direction", "mailbox_status", "user_role",
]:
op.execute(f"DROP TYPE IF EXISTS {enum_name}")
+1
View File
@@ -0,0 +1 @@
# PecFlow Backend
+1
View File
@@ -0,0 +1 @@
# API routers
+1
View File
@@ -0,0 +1 @@
# API v1 routers
+173
View File
@@ -0,0 +1,173 @@
"""
Router autenticazione login, refresh, logout, 2FA TOTP.
Endpoint:
POST /api/v1/auth/login → access + refresh token
POST /api/v1/auth/refresh → rinnova token
POST /api/v1/auth/logout → revoca refresh token
GET /api/v1/auth/me → utente corrente
POST /api/v1/auth/totp/setup → genera segreto TOTP + QR
POST /api/v1/auth/totp/verify → verifica e attiva TOTP
POST /api/v1/auth/totp/disable → disabilita TOTP
POST /api/v1/auth/change-password → cambio password
"""
from typing import Annotated
from fastapi import APIRouter, Depends, Request
from slowapi import Limiter
from slowapi.util import get_remote_address
from sqlalchemy.ext.asyncio import AsyncSession
from app.config import get_settings
from app.core.exceptions import InvalidCredentialsError
from app.database import get_db
from app.dependencies import CurrentUser, DB
from app.schemas.auth import (
LoginRequest,
PasswordChangeRequest,
RefreshRequest,
TOTPSetupResponse,
TOTPStatusResponse,
TOTPVerifyRequest,
TokenResponse,
)
from app.schemas.user import UserResponse
from app.services.auth_service import AuthService
settings = get_settings()
router = APIRouter(prefix="/auth", tags=["Autenticazione"])
limiter = Limiter(key_func=get_remote_address)
@router.post(
"/login",
response_model=TokenResponse,
summary="Login con email e password",
description="Autentica l'utente. Se 2FA è attivo, richiede anche il codice TOTP.",
)
async def login(
request: Request,
body: LoginRequest,
db: DB,
) -> TokenResponse:
ip = request.client.host if request.client else None
ua = request.headers.get("user-agent")
service = AuthService(db)
access_token, refresh_token = await service.login(
email=body.email,
password=body.password,
totp_code=body.totp_code,
ip_address=ip,
user_agent=ua,
)
return TokenResponse(
access_token=access_token,
refresh_token=refresh_token,
expires_in=settings.access_token_expire_minutes * 60,
)
@router.post(
"/refresh",
response_model=TokenResponse,
summary="Rinnova access token",
)
async def refresh_tokens(
body: RefreshRequest,
db: DB,
) -> TokenResponse:
service = AuthService(db)
access_token, refresh_token = await service.refresh_tokens(body.refresh_token)
return TokenResponse(
access_token=access_token,
refresh_token=refresh_token,
expires_in=settings.access_token_expire_minutes * 60,
)
@router.post(
"/logout",
status_code=204,
summary="Logout revoca refresh token",
)
async def logout(
body: RefreshRequest,
db: DB,
) -> None:
service = AuthService(db)
await service.logout(body.refresh_token)
@router.get(
"/me",
response_model=UserResponse,
summary="Utente corrente autenticato",
)
async def me(current_user: CurrentUser) -> UserResponse:
return UserResponse.model_validate(current_user)
@router.post(
"/totp/setup",
response_model=TOTPSetupResponse,
summary="Avvia setup 2FA TOTP",
description="Genera segreto TOTP e QR code. Il 2FA viene attivato solo dopo la verifica.",
)
async def totp_setup(
current_user: CurrentUser,
db: DB,
) -> TOTPSetupResponse:
service = AuthService(db)
data = await service.setup_totp(current_user)
return TOTPSetupResponse(**data)
@router.post(
"/totp/verify",
response_model=TOTPStatusResponse,
summary="Verifica codice TOTP e attiva 2FA",
)
async def totp_verify(
body: TOTPVerifyRequest,
current_user: CurrentUser,
db: DB,
) -> TOTPStatusResponse:
service = AuthService(db)
await service.verify_and_enable_totp(current_user, body.totp_code)
return TOTPStatusResponse(totp_enabled=True)
@router.post(
"/totp/disable",
response_model=TOTPStatusResponse,
summary="Disabilita 2FA TOTP",
)
async def totp_disable(
current_user: CurrentUser,
db: DB,
) -> TOTPStatusResponse:
service = AuthService(db)
await service.disable_totp(current_user)
return TOTPStatusResponse(totp_enabled=False)
@router.post(
"/change-password",
status_code=204,
summary="Cambio password utente corrente",
)
async def change_password(
body: PasswordChangeRequest,
current_user: CurrentUser,
db: DB,
) -> None:
from app.core.security import verify_password, hash_password
if not verify_password(body.current_password, current_user.password_hash):
raise InvalidCredentialsError()
current_user.password_hash = hash_password(body.new_password)
+112
View File
@@ -0,0 +1,112 @@
"""
Router permessi granulari casella (Fase 1-A).
Endpoint:
POST /api/v1/permissions/mailboxes/{mailbox_id}/users/{user_id} → assegna permesso
DELETE /api/v1/permissions/mailboxes/{mailbox_id}/users/{user_id} → revoca permesso
GET /api/v1/permissions/mailboxes/{mailbox_id}/users → utenti con accesso
GET /api/v1/permissions/users/{user_id}/mailboxes → caselle accessibili
"""
import uuid
from fastapi import APIRouter
from app.dependencies import AdminUser, CurrentUser, DB
from app.schemas.permission import (
MailboxUserPermissionResponse,
PermissionGrantRequest,
PermissionResponse,
UserMailboxPermissionResponse,
)
from app.services.permission_service import PermissionService
router = APIRouter(prefix="/permissions", tags=["Permessi casella"])
@router.post(
"/mailboxes/{mailbox_id}/users/{user_id}",
response_model=PermissionResponse,
status_code=201,
summary="Assegna permesso utente su casella",
description="Crea o aggiorna i permessi di un utente su una specifica casella PEC.",
)
async def grant_permission(
mailbox_id: uuid.UUID,
user_id: uuid.UUID,
body: PermissionGrantRequest,
current_user: AdminUser,
db: DB,
) -> PermissionResponse:
service = PermissionService(db)
perm = await service.grant_permission(
tenant_id=current_user.tenant_id,
mailbox_id=mailbox_id,
user_id=user_id,
data=body,
granted_by=current_user,
)
return PermissionResponse.model_validate(perm)
@router.delete(
"/mailboxes/{mailbox_id}/users/{user_id}",
status_code=204,
summary="Revoca permesso utente su casella",
)
async def revoke_permission(
mailbox_id: uuid.UUID,
user_id: uuid.UUID,
current_user: AdminUser,
db: DB,
) -> None:
service = PermissionService(db)
await service.revoke_permission(
mailbox_id=mailbox_id,
user_id=user_id,
revoked_by=current_user,
)
@router.get(
"/mailboxes/{mailbox_id}/users",
response_model=list[MailboxUserPermissionResponse],
summary="Utenti con accesso a una casella",
)
async def list_mailbox_users(
mailbox_id: uuid.UUID,
current_user: AdminUser,
db: DB,
) -> list[MailboxUserPermissionResponse]:
service = PermissionService(db)
rows = await service.list_mailbox_users(mailbox_id, current_user.tenant_id)
return [MailboxUserPermissionResponse(**row) for row in rows]
@router.get(
"/users/{user_id}/mailboxes",
response_model=list[UserMailboxPermissionResponse],
summary="Caselle accessibili a un utente",
)
async def list_user_mailboxes(
user_id: uuid.UUID,
current_user: AdminUser,
db: DB,
) -> list[UserMailboxPermissionResponse]:
service = PermissionService(db)
rows = await service.list_user_mailboxes(user_id, current_user.tenant_id)
return [UserMailboxPermissionResponse(**row) for row in rows]
@router.get(
"/my/mailboxes",
response_model=list[UserMailboxPermissionResponse],
summary="Caselle accessibili all'utente corrente",
)
async def my_mailboxes(
current_user: CurrentUser,
db: DB,
) -> list[UserMailboxPermissionResponse]:
service = PermissionService(db)
rows = await service.list_user_mailboxes(current_user.id, current_user.tenant_id)
return [UserMailboxPermissionResponse(**row) for row in rows]
+80
View File
@@ -0,0 +1,80 @@
"""
Router tenant gestione organizzazioni (solo super_admin).
Endpoint:
GET /api/v1/tenants → lista tenant
POST /api/v1/tenants → crea tenant + admin
GET /api/v1/tenants/{id} → dettaglio tenant
PATCH /api/v1/tenants/{id} → modifica tenant
"""
import uuid
from fastapi import APIRouter
from app.dependencies import SuperAdminUser, DB
from app.schemas.tenant import TenantCreateRequest, TenantResponse, TenantUpdateRequest
from app.services.tenant_service import TenantService
router = APIRouter(prefix="/tenants", tags=["Tenant (super-admin)"])
@router.get(
"",
response_model=list[TenantResponse],
summary="Lista tutti i tenant",
)
async def list_tenants(
_: SuperAdminUser,
db: DB,
) -> list[TenantResponse]:
service = TenantService(db)
tenants = await service.list_tenants()
return [TenantResponse.model_validate(t) for t in tenants]
@router.post(
"",
response_model=TenantResponse,
status_code=201,
summary="Crea nuovo tenant con admin iniziale",
)
async def create_tenant(
body: TenantCreateRequest,
_: SuperAdminUser,
db: DB,
) -> TenantResponse:
service = TenantService(db)
tenant, _ = await service.create_tenant(body)
return TenantResponse.model_validate(tenant)
@router.get(
"/{tenant_id}",
response_model=TenantResponse,
summary="Dettaglio tenant",
)
async def get_tenant(
tenant_id: uuid.UUID,
_: SuperAdminUser,
db: DB,
) -> TenantResponse:
service = TenantService(db)
tenant = await service.get_tenant(tenant_id)
return TenantResponse.model_validate(tenant)
@router.patch(
"/{tenant_id}",
response_model=TenantResponse,
summary="Modifica tenant",
)
async def update_tenant(
tenant_id: uuid.UUID,
body: TenantUpdateRequest,
_: SuperAdminUser,
db: DB,
) -> TenantResponse:
service = TenantService(db)
tenant = await service.update_tenant(tenant_id, body)
return TenantResponse.model_validate(tenant)
+147
View File
@@ -0,0 +1,147 @@
"""
Router utenti CRUD per admin del tenant.
Endpoint:
GET /api/v1/users → lista utenti (admin)
POST /api/v1/users → crea utente (admin)
GET /api/v1/users/{id} → dettaglio utente (admin)
PATCH /api/v1/users/{id} → modifica utente (admin)
DELETE /api/v1/users/{id} → disabilita utente (admin)
POST /api/v1/users/{id}/reset-password → reset password (admin)
"""
import uuid
from fastapi import APIRouter, Query
from app.dependencies import AdminUser, DB
from app.schemas.user import (
UserCreateRequest,
UserListResponse,
UserPasswordResetRequest,
UserResponse,
UserUpdateRequest,
)
from app.services.user_service import UserService
router = APIRouter(prefix="/users", tags=["Utenti"])
@router.get(
"",
response_model=UserListResponse,
summary="Lista utenti del tenant",
)
async def list_users(
current_user: AdminUser,
db: DB,
page: int = Query(default=1, ge=1),
page_size: int = Query(default=25, ge=1, le=100),
) -> UserListResponse:
service = UserService(db)
users, total = await service.list_users(
tenant_id=current_user.tenant_id,
page=page,
page_size=page_size,
)
import math
return UserListResponse(
items=[UserResponse.model_validate(u) for u in users],
total=total,
page=page,
page_size=page_size,
pages=math.ceil(total / page_size) if page_size else 0,
)
@router.post(
"",
response_model=UserResponse,
status_code=201,
summary="Crea nuovo utente nel tenant",
)
async def create_user(
body: UserCreateRequest,
current_user: AdminUser,
db: DB,
) -> UserResponse:
service = UserService(db)
user = await service.create_user(
tenant_id=current_user.tenant_id,
data=body,
created_by=current_user,
)
return UserResponse.model_validate(user)
@router.get(
"/{user_id}",
response_model=UserResponse,
summary="Dettaglio utente",
)
async def get_user(
user_id: uuid.UUID,
current_user: AdminUser,
db: DB,
) -> UserResponse:
service = UserService(db)
user = await service.get_user(user_id, current_user.tenant_id)
return UserResponse.model_validate(user)
@router.patch(
"/{user_id}",
response_model=UserResponse,
summary="Modifica utente",
)
async def update_user(
user_id: uuid.UUID,
body: UserUpdateRequest,
current_user: AdminUser,
db: DB,
) -> UserResponse:
service = UserService(db)
user = await service.update_user(
user_id=user_id,
tenant_id=current_user.tenant_id,
data=body,
updated_by=current_user,
)
return UserResponse.model_validate(user)
@router.delete(
"/{user_id}",
status_code=204,
summary="Disabilita utente (soft delete)",
)
async def delete_user(
user_id: uuid.UUID,
current_user: AdminUser,
db: DB,
) -> None:
service = UserService(db)
await service.delete_user(
user_id=user_id,
tenant_id=current_user.tenant_id,
deleted_by=current_user,
)
@router.post(
"/{user_id}/reset-password",
status_code=204,
summary="Reset password utente (admin)",
)
async def reset_password(
user_id: uuid.UUID,
body: UserPasswordResetRequest,
current_user: AdminUser,
db: DB,
) -> None:
service = UserService(db)
await service.reset_password(
user_id=user_id,
tenant_id=current_user.tenant_id,
new_password=body.new_password,
)
+90
View File
@@ -0,0 +1,90 @@
"""
Configurazione applicazione legge variabili d'ambiente tramite pydantic-settings.
"""
from functools import lru_cache
from typing import Literal
from pydantic import field_validator
from pydantic_settings import BaseSettings, SettingsConfigDict
class Settings(BaseSettings):
model_config = SettingsConfigDict(
env_file=".env",
env_file_encoding="utf-8",
case_sensitive=False,
extra="ignore",
)
# ── Applicazione ──────────────────────────────────────────────────────────
app_env: Literal["development", "staging", "production"] = "development"
app_debug: bool = True
app_host: str = "0.0.0.0"
app_port: int = 8000
app_base_url: str = "http://localhost:8000"
# ── Sicurezza / JWT ───────────────────────────────────────────────────────
secret_key: str = "change-me-in-production"
algorithm: str = "HS256"
access_token_expire_minutes: int = 15
refresh_token_expire_days: int = 30
# Chiave AES-256-GCM per cifratura credenziali IMAP/SMTP (hex 64 chars = 32 bytes)
encryption_key: str = "0" * 64
# ── Database ──────────────────────────────────────────────────────────────
database_url: str = "postgresql+asyncpg://pecflow:pecflow_dev_password@db:5432/pecflow"
database_url_sync: str = "postgresql://pecflow:pecflow_dev_password@db:5432/pecflow"
# ── Redis ─────────────────────────────────────────────────────────────────
redis_url: str = "redis://redis:6379/0"
# ── MinIO ─────────────────────────────────────────────────────────────────
minio_endpoint: str = "minio:9000"
minio_access_key: str = "minioadmin"
minio_secret_key: str = "minioadmin"
minio_bucket: str = "pecflow"
minio_use_ssl: bool = False
# ── CORS ──────────────────────────────────────────────────────────────────
cors_origins: str = "http://localhost:3000,http://localhost:5173"
# ── Rate Limiting ─────────────────────────────────────────────────────────
rate_limit_auth: str = "10/minute"
rate_limit_default: str = "100/minute"
# ── Logging ───────────────────────────────────────────────────────────────
log_level: str = "INFO"
log_json: bool = False
@field_validator("encryption_key")
@classmethod
def validate_encryption_key(cls, v: str) -> str:
if len(v) != 64:
raise ValueError(
"ENCRYPTION_KEY deve essere una stringa hex di 64 caratteri (32 bytes)"
)
try:
bytes.fromhex(v)
except ValueError:
raise ValueError("ENCRYPTION_KEY deve essere una stringa esadecimale valida")
return v
@property
def cors_origins_list(self) -> list[str]:
return [origin.strip() for origin in self.cors_origins.split(",")]
@property
def is_production(self) -> bool:
return self.app_env == "production"
@property
def encryption_key_bytes(self) -> bytes:
return bytes.fromhex(self.encryption_key)
@lru_cache
def get_settings() -> Settings:
"""Restituisce istanza singleton delle impostazioni (cachata)."""
return Settings()
+1
View File
@@ -0,0 +1 @@
# Core utilities
+123
View File
@@ -0,0 +1,123 @@
"""
Eccezioni applicative custom per PecFlow.
"""
from fastapi import HTTPException, status
# ─── Autenticazione ───────────────────────────────────────────────────────────
class InvalidCredentialsError(HTTPException):
def __init__(self) -> None:
super().__init__(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Credenziali non valide",
headers={"WWW-Authenticate": "Bearer"},
)
class TokenExpiredError(HTTPException):
def __init__(self) -> None:
super().__init__(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Token scaduto",
headers={"WWW-Authenticate": "Bearer"},
)
class TokenInvalidError(HTTPException):
def __init__(self) -> None:
super().__init__(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Token non valido",
headers={"WWW-Authenticate": "Bearer"},
)
class AccountLockedError(HTTPException):
def __init__(self, locked_until: str = "") -> None:
detail = "Account temporaneamente bloccato per troppi tentativi falliti"
if locked_until:
detail += f" fino a {locked_until}"
super().__init__(
status_code=status.HTTP_423_LOCKED,
detail=detail,
)
class AccountDisabledError(HTTPException):
def __init__(self) -> None:
super().__init__(
status_code=status.HTTP_403_FORBIDDEN,
detail="Account disabilitato",
)
class TOTPRequiredError(HTTPException):
def __init__(self) -> None:
super().__init__(
status_code=status.HTTP_403_FORBIDDEN,
detail="Autenticazione a due fattori richiesta",
)
class TOTPInvalidError(HTTPException):
def __init__(self) -> None:
super().__init__(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Codice TOTP non valido o scaduto",
)
# ─── Autorizzazione ───────────────────────────────────────────────────────────
class ForbiddenError(HTTPException):
def __init__(self, detail: str = "Accesso non autorizzato") -> None:
super().__init__(
status_code=status.HTTP_403_FORBIDDEN,
detail=detail,
)
class PermissionDeniedError(HTTPException):
def __init__(self, resource: str = "risorsa") -> None:
super().__init__(
status_code=status.HTTP_403_FORBIDDEN,
detail=f"Permessi insufficienti per accedere a questa {resource}",
)
# ─── Risorse ──────────────────────────────────────────────────────────────────
class NotFoundError(HTTPException):
def __init__(self, resource: str = "risorsa") -> None:
super().__init__(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"{resource.capitalize()} non trovata",
)
class ConflictError(HTTPException):
def __init__(self, detail: str = "Conflitto: risorsa già esistente") -> None:
super().__init__(
status_code=status.HTTP_409_CONFLICT,
detail=detail,
)
class TenantLimitExceededError(HTTPException):
def __init__(self, resource: str, limit: int) -> None:
super().__init__(
status_code=status.HTTP_402_PAYMENT_REQUIRED,
detail=f"Limite del piano raggiunto: massimo {limit} {resource} per questo tenant",
)
# ─── Validazione ──────────────────────────────────────────────────────────────
class ValidationError(HTTPException):
def __init__(self, detail: str) -> None:
super().__init__(
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
detail=detail,
)
+65
View File
@@ -0,0 +1,65 @@
"""
Structured logging per PecFlow.
In produzione (LOG_JSON=true) emette log JSON per aggregatori (Loki, ELK).
In sviluppo emette log leggibili colorati.
"""
import logging
import sys
from typing import Any
from app.config import get_settings
settings = get_settings()
def _build_handler() -> logging.Handler:
handler = logging.StreamHandler(sys.stdout)
if settings.log_json:
try:
import json
class JsonFormatter(logging.Formatter):
def format(self, record: logging.LogRecord) -> str:
log_entry: dict[str, Any] = {
"timestamp": self.formatTime(record, "%Y-%m-%dT%H:%M:%S"),
"level": record.levelname,
"logger": record.name,
"message": record.getMessage(),
}
if record.exc_info:
log_entry["exception"] = self.formatException(record.exc_info)
return json.dumps(log_entry, ensure_ascii=False)
handler.setFormatter(JsonFormatter())
except Exception:
pass
else:
fmt = "%(asctime)s %(levelname)-8s %(name)s %(message)s"
handler.setFormatter(logging.Formatter(fmt, datefmt="%H:%M:%S"))
return handler
def setup_logging() -> None:
"""Configura il logging applicativo. Da chiamare all'avvio dell'app."""
level = getattr(logging, settings.log_level.upper(), logging.INFO)
root_logger = logging.getLogger()
root_logger.setLevel(level)
# Rimuovi handler esistenti per evitare duplicati
root_logger.handlers.clear()
root_logger.addHandler(_build_handler())
# Riduci verbosità librerie rumorose
logging.getLogger("uvicorn.access").setLevel(logging.WARNING)
logging.getLogger("sqlalchemy.engine").setLevel(
logging.INFO if settings.app_debug else logging.WARNING
)
def get_logger(name: str) -> logging.Logger:
"""Restituisce un logger con il nome specificato."""
return logging.getLogger(name)
+56
View File
@@ -0,0 +1,56 @@
"""
Utility per paginazione standardizzata nelle API.
"""
from typing import Generic, TypeVar
from pydantic import BaseModel, Field
T = TypeVar("T")
DEFAULT_PAGE_SIZE = 25
MAX_PAGE_SIZE = 100
class PaginationParams(BaseModel):
page: int = Field(default=1, ge=1, description="Numero di pagina (1-based)")
page_size: int = Field(
default=DEFAULT_PAGE_SIZE,
ge=1,
le=MAX_PAGE_SIZE,
description=f"Elementi per pagina (max {MAX_PAGE_SIZE})",
)
@property
def offset(self) -> int:
return (self.page - 1) * self.page_size
@property
def limit(self) -> int:
return self.page_size
class PaginatedResponse(BaseModel, Generic[T]):
"""Risposta paginata generica."""
items: list[T]
total: int
page: int
page_size: int
pages: int
@classmethod
def create(
cls,
items: list[T],
total: int,
params: PaginationParams,
) -> "PaginatedResponse[T]":
import math
pages = math.ceil(total / params.page_size) if params.page_size > 0 else 0
return cls(
items=items,
total=total,
page=params.page,
page_size=params.page_size,
pages=pages,
)
+158
View File
@@ -0,0 +1,158 @@
"""
Modulo sicurezza cifratura AES-256-GCM, hashing password, JWT utilities.
ADR-002: Le credenziali IMAP/SMTP vengono cifrate con AES-256-GCM prima di
essere scritte in DB. La chiave è in variabile d'ambiente (ENCRYPTION_KEY).
Formato storage: base64(nonce_12byte || ciphertext || tag_16byte)
"""
import base64
import os
from datetime import UTC, datetime, timedelta
from typing import Any
from uuid import UUID
import bcrypt as _bcrypt
from cryptography.hazmat.primitives.ciphers.aead import AESGCM
from jose import JWTError, jwt
from app.config import get_settings
settings = get_settings()
# ─── Password hashing (bcrypt diretto, compatibile con bcrypt 4.x/5.x) ───────
_BCRYPT_ROUNDS = 12
def hash_password(password: str) -> str:
"""Genera hash bcrypt della password (work factor 12)."""
pwd_bytes = password.encode("utf-8")
salt = _bcrypt.gensalt(rounds=_BCRYPT_ROUNDS)
return _bcrypt.hashpw(pwd_bytes, salt).decode("utf-8")
def verify_password(plain_password: str, hashed_password: str) -> bool:
"""Verifica password contro il suo hash."""
try:
return _bcrypt.checkpw(
plain_password.encode("utf-8"),
hashed_password.encode("utf-8"),
)
except Exception:
return False
# ─── JWT ──────────────────────────────────────────────────────────────────────
def create_access_token(
subject: str | UUID,
tenant_id: str | UUID,
role: str,
extra_claims: dict[str, Any] | None = None,
) -> str:
"""
Crea un JWT access token con scadenza configurabile.
Claims standard:
- sub: user_id (string)
- tid: tenant_id
- role: ruolo utente
- exp: scadenza
- iat: emesso a
"""
now = datetime.now(UTC)
expire = now + timedelta(minutes=settings.access_token_expire_minutes)
payload: dict[str, Any] = {
"sub": str(subject),
"tid": str(tenant_id),
"role": role,
"iat": now,
"exp": expire,
"type": "access",
}
if extra_claims:
payload.update(extra_claims)
return jwt.encode(payload, settings.secret_key, algorithm=settings.algorithm)
def create_refresh_token(subject: str | UUID, tenant_id: str | UUID) -> str:
"""
Crea un JWT refresh token con scadenza lunga (30 giorni default).
Non contiene il ruolo viene rivalutato a ogni refresh.
"""
now = datetime.now(UTC)
expire = now + timedelta(days=settings.refresh_token_expire_days)
payload: dict[str, Any] = {
"sub": str(subject),
"tid": str(tenant_id),
"iat": now,
"exp": expire,
"type": "refresh",
}
return jwt.encode(payload, settings.secret_key, algorithm=settings.algorithm)
def decode_token(token: str) -> dict[str, Any]:
"""
Decodifica e valida un JWT token.
Solleva JWTError se il token è invalido o scaduto.
"""
return jwt.decode(token, settings.secret_key, algorithms=[settings.algorithm])
def is_token_valid(token: str, expected_type: str = "access") -> bool:
"""Verifica rapidamente la validità del token senza sollevare eccezioni."""
try:
payload = decode_token(token)
return payload.get("type") == expected_type
except JWTError:
return False
# ─── AES-256-GCM cifratura credenziali (ADR-002) ─────────────────────────────
def encrypt_credential(plaintext: str) -> str:
"""
Cifra una stringa con AES-256-GCM usando la chiave applicativa.
Formato output: base64(nonce_12byte || ciphertext || tag_16byte)
Il tag GCM (16 byte) è automaticamente concatenato al ciphertext da AESGCM.
"""
key = settings.encryption_key_bytes
aesgcm = AESGCM(key)
nonce = os.urandom(12) # 12 byte nonce raccomandato per GCM
# AESGCM.encrypt() restituisce ciphertext + tag concatenati
ciphertext_with_tag = aesgcm.encrypt(nonce, plaintext.encode("utf-8"), None)
# Concatena nonce + ciphertext_with_tag e codifica in base64
raw = nonce + ciphertext_with_tag
return base64.b64encode(raw).decode("ascii")
def decrypt_credential(encrypted: str) -> str:
"""
Decifra una stringa cifrata con encrypt_credential().
Solleva ValueError se la decifratura fallisce (chiave errata o dati corrotti).
"""
key = settings.encryption_key_bytes
aesgcm = AESGCM(key)
try:
raw = base64.b64decode(encrypted.encode("ascii"))
nonce = raw[:12]
ciphertext_with_tag = raw[12:]
plaintext_bytes = aesgcm.decrypt(nonce, ciphertext_with_tag, None)
return plaintext_bytes.decode("utf-8")
except Exception as e:
raise ValueError(f"Decifratura fallita: {e}") from e
# ─── Hash sicuro per refresh token storage ────────────────────────────────────
import hashlib
def hash_token(token: str) -> str:
"""SHA-256 del token raw per storage sicuro in DB."""
return hashlib.sha256(token.encode("utf-8")).hexdigest()
+52
View File
@@ -0,0 +1,52 @@
"""
Configurazione database engine SQLAlchemy async e session factory.
"""
from collections.abc import AsyncGenerator
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
from sqlalchemy.orm import DeclarativeBase
from app.config import get_settings
settings = get_settings()
# Engine async con pool connection
engine = create_async_engine(
settings.database_url,
echo=settings.app_debug, # log SQL in development
pool_size=10,
max_overflow=20,
pool_pre_ping=True, # verifica connessione prima di usarla
pool_recycle=3600, # ricicla connessioni ogni ora
)
# Session factory
AsyncSessionLocal = async_sessionmaker(
bind=engine,
class_=AsyncSession,
expire_on_commit=False,
autocommit=False,
autoflush=False,
)
class Base(DeclarativeBase):
"""Base class per tutti i modelli SQLAlchemy."""
pass
async def get_db() -> AsyncGenerator[AsyncSession, None]:
"""
Dependency FastAPI: restituisce una sessione DB per ogni request.
La sessione viene chiusa automaticamente al termine del request.
"""
async with AsyncSessionLocal() as session:
try:
yield session
await session.commit()
except Exception:
await session.rollback()
raise
finally:
await session.close()
+117
View File
@@ -0,0 +1,117 @@
"""
Dependency FastAPI get_db, get_current_user, require_admin, RLS middleware.
"""
import uuid
from typing import Annotated
from fastapi import Depends, Request
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
from jose import JWTError
from sqlalchemy import text
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.exceptions import ForbiddenError, TokenInvalidError
from app.core.security import decode_token
from app.database import get_db
from app.models.user import User
from sqlalchemy import select
security = HTTPBearer()
# ─── Database con RLS ─────────────────────────────────────────────────────────
async def get_db_with_rls(
tenant_id: uuid.UUID,
db: AsyncSession = Depends(get_db),
) -> AsyncSession:
"""
Imposta la variabile di sessione PostgreSQL per RLS.
Da usare dopo aver estratto il tenant_id dall'utente autenticato.
"""
await db.execute(
text("SET LOCAL app.current_tenant_id = :tenant_id"),
{"tenant_id": str(tenant_id)},
)
return db
# ─── Utente corrente ──────────────────────────────────────────────────────────
async def get_current_user(
credentials: Annotated[HTTPAuthorizationCredentials, Depends(security)],
db: AsyncSession = Depends(get_db),
) -> User:
"""
Estrae e valida il JWT dall'header Authorization: Bearer <token>.
Carica l'utente dal DB e imposta RLS.
"""
token = credentials.credentials
try:
payload = decode_token(token)
except JWTError:
raise TokenInvalidError()
if payload.get("type") != "access":
raise TokenInvalidError()
user_id_str = payload.get("sub")
tenant_id_str = payload.get("tid")
if not user_id_str or not tenant_id_str:
raise TokenInvalidError()
try:
user_id = uuid.UUID(user_id_str)
tenant_id = uuid.UUID(tenant_id_str)
except ValueError:
raise TokenInvalidError()
# Imposta RLS per questo tenant
# SET LOCAL non supporta parametri $1, usiamo text() con valore inline
await db.execute(
text(f"SET LOCAL app.current_tenant_id = '{tenant_id!s}'")
)
# Carica utente
result = await db.execute(
select(User).where(User.id == user_id, User.tenant_id == tenant_id)
)
user = result.scalar_one_or_none()
if not user:
raise TokenInvalidError()
if not user.is_active:
from app.core.exceptions import AccountDisabledError
raise AccountDisabledError()
return user
# ─── Role guards ──────────────────────────────────────────────────────────────
async def require_admin(
current_user: Annotated[User, Depends(get_current_user)],
) -> User:
"""Richiede ruolo admin o super_admin."""
if not current_user.is_admin:
raise ForbiddenError("Richiesto ruolo amministratore")
return current_user
async def require_super_admin(
current_user: Annotated[User, Depends(get_current_user)],
) -> User:
"""Richiede ruolo super_admin."""
if not current_user.is_super_admin:
raise ForbiddenError("Richiesto ruolo super_admin")
return current_user
# ─── Tipo annotato per ridurre boilerplate negli endpoint ─────────────────────
CurrentUser = Annotated[User, Depends(get_current_user)]
AdminUser = Annotated[User, Depends(require_admin)]
SuperAdminUser = Annotated[User, Depends(require_super_admin)]
DB = Annotated[AsyncSession, Depends(get_db)]
+108
View File
@@ -0,0 +1,108 @@
"""
Entrypoint FastAPI registra router, middleware, startup/shutdown.
"""
from contextlib import asynccontextmanager
from typing import AsyncGenerator
from fastapi import FastAPI, Request, status
from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import JSONResponse
from slowapi import Limiter, _rate_limit_exceeded_handler
from slowapi.errors import RateLimitExceeded
from slowapi.middleware import SlowAPIMiddleware
from slowapi.util import get_remote_address
from app.api.v1 import auth, permissions, tenants, users
from app.config import get_settings
from app.core.logging import get_logger, setup_logging
from app.database import engine
settings = get_settings()
logger = get_logger(__name__)
@asynccontextmanager
async def lifespan(app: FastAPI) -> AsyncGenerator[None, None]:
"""Gestione ciclo di vita dell'applicazione."""
setup_logging()
logger.info(
"🚀 PecFlow Backend avviato",
extra={"env": settings.app_env, "debug": settings.app_debug},
)
yield
# Cleanup: chiudi connessioni DB
await engine.dispose()
logger.info("🛑 PecFlow Backend fermato")
# ─── Applicazione FastAPI ─────────────────────────────────────────────────────
limiter = Limiter(key_func=get_remote_address, default_limits=["200/minute"])
app = FastAPI(
title="PecFlow API",
description="API per la gestione PEC SaaS multi-tenant",
version="1.0.0",
docs_url="/docs" if not settings.is_production else None,
redoc_url="/redoc" if not settings.is_production else None,
lifespan=lifespan,
)
# ─── Middleware ───────────────────────────────────────────────────────────────
app.state.limiter = limiter
app.add_exception_handler(RateLimitExceeded, _rate_limit_exceeded_handler)
app.add_middleware(SlowAPIMiddleware)
app.add_middleware(
CORSMiddleware,
allow_origins=settings.cors_origins_list,
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
# ─── Router ───────────────────────────────────────────────────────────────────
API_PREFIX = "/api/v1"
app.include_router(auth.router, prefix=API_PREFIX)
app.include_router(users.router, prefix=API_PREFIX)
app.include_router(tenants.router, prefix=API_PREFIX)
app.include_router(permissions.router, prefix=API_PREFIX)
# ─── Health check ─────────────────────────────────────────────────────────────
@app.get("/health", tags=["Health"], include_in_schema=False)
async def health_check() -> dict:
"""Endpoint di health check per Docker/Kubernetes."""
return {
"status": "ok",
"version": "1.0.0",
"env": settings.app_env,
}
@app.get("/health/db", tags=["Health"], include_in_schema=False)
async def health_db() -> dict:
"""Verifica connessione al database."""
from sqlalchemy import text
from app.database import AsyncSessionLocal
try:
async with AsyncSessionLocal() as session:
await session.execute(text("SELECT 1"))
return {"status": "ok", "database": "connected"}
except Exception as e:
return JSONResponse(
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
content={"status": "error", "database": str(e)},
)
# ─── Error handler globale ────────────────────────────────────────────────────
@app.exception_handler(Exception)
async def global_exception_handler(request: Request, exc: Exception) -> JSONResponse:
logger.exception(f"Errore non gestito: {exc}")
return JSONResponse(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
content={"detail": "Errore interno del server"},
)
+9
View File
@@ -0,0 +1,9 @@
# Importa tutti i modelli per permettere ad Alembic di rilevarli
from app.models.tenant import Tenant # noqa: F401
from app.models.user import User, RefreshToken # noqa: F401
from app.models.mailbox import Mailbox # noqa: F401
from app.models.message import Message, Attachment, SendJob # noqa: F401
from app.models.archival import ArchivalBatch, ArchivalBatchMessage, ArchivalDip # noqa: F401
from app.models.audit_log import AuditLog # noqa: F401
from app.models.label import Label, MessageLabel # noqa: F401
from app.models.permission import MailboxPermission # noqa: F401
+108
View File
@@ -0,0 +1,108 @@
"""
Modelli Archival versamenti verso conservatore AgID.
"""
import uuid
from datetime import date, datetime
from sqlalchemy import (
CHAR,
DateTime,
Enum,
ForeignKey,
Index,
Integer,
String,
Text,
func,
)
from sqlalchemy.dialects.postgresql import UUID
from sqlalchemy.orm import Mapped, mapped_column
from app.database import Base
ArchivalStatus = Enum(
"pending", "building_sip", "uploading", "uploaded",
"confirmed", "rejected", "failed",
name="archival_status",
create_type=False,
)
class ArchivalBatch(Base):
__tablename__ = "archival_batches"
id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True), primary_key=True, default=uuid.uuid4
)
tenant_id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True), ForeignKey("tenants.id", ondelete="CASCADE"), nullable=False
)
conservatore_id: Mapped[str] = mapped_column(String(100), nullable=False)
status: Mapped[str] = mapped_column(ArchivalStatus, nullable=False, default="pending")
sip_path: Mapped[str | None] = mapped_column(Text, nullable=True)
sip_checksum: Mapped[str | None] = mapped_column(CHAR(64), nullable=True)
versamento_id: Mapped[str | None] = mapped_column(Text, nullable=True)
rdv_received_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
rdv_path: Mapped[str | None] = mapped_column(Text, nullable=True)
rdv_checksum: Mapped[str | None] = mapped_column(CHAR(64), nullable=True)
attempt_count: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
max_attempts: Mapped[int] = mapped_column(Integer, nullable=False, default=3)
next_retry_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
last_error: Mapped[str | None] = mapped_column(Text, nullable=True)
period_from: Mapped[date] = mapped_column(nullable=False)
period_to: Mapped[date] = mapped_column(nullable=False)
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), nullable=False, server_default=func.now()
)
updated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), nullable=False, server_default=func.now(), onupdate=func.now()
)
__table_args__ = (
Index("idx_archival_tenant", "tenant_id"),
Index("idx_archival_status", "status", "next_retry_at"),
)
class ArchivalBatchMessage(Base):
__tablename__ = "archival_batch_messages"
batch_id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True),
ForeignKey("archival_batches.id", ondelete="CASCADE"),
primary_key=True,
)
message_id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True),
ForeignKey("messages.id", ondelete="CASCADE"),
primary_key=True,
)
class ArchivalDip(Base):
__tablename__ = "archival_dips"
id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True), primary_key=True, default=uuid.uuid4
)
tenant_id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True), ForeignKey("tenants.id", ondelete="CASCADE"), nullable=False
)
batch_id: Mapped[uuid.UUID | None] = mapped_column(
UUID(as_uuid=True), ForeignKey("archival_batches.id"), nullable=True
)
requested_by: Mapped[uuid.UUID | None] = mapped_column(
UUID(as_uuid=True), ForeignKey("users.id"), nullable=True
)
dip_path: Mapped[str | None] = mapped_column(Text, nullable=True)
status: Mapped[str] = mapped_column(String(50), nullable=False, default="requested")
requested_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), nullable=False, server_default=func.now()
)
received_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
+51
View File
@@ -0,0 +1,51 @@
"""
Modello AuditLog immutabile per compliance e tracciabilità.
"""
import uuid
from datetime import datetime
from sqlalchemy import (
BigInteger,
DateTime,
ForeignKey,
Index,
String,
Text,
func,
)
from sqlalchemy.dialects.postgresql import INET, JSONB, UUID
from sqlalchemy.orm import Mapped, mapped_column
from app.database import Base
class AuditLog(Base):
__tablename__ = "audit_log"
id: Mapped[int] = mapped_column(BigInteger, primary_key=True, autoincrement=True)
tenant_id: Mapped[uuid.UUID | None] = mapped_column(
UUID(as_uuid=True), ForeignKey("tenants.id"), nullable=True
)
user_id: Mapped[uuid.UUID | None] = mapped_column(
UUID(as_uuid=True), ForeignKey("users.id"), nullable=True
)
action: Mapped[str] = mapped_column(String(100), nullable=False)
resource_type: Mapped[str | None] = mapped_column(String(100), nullable=True)
resource_id: Mapped[uuid.UUID | None] = mapped_column(UUID(as_uuid=True), nullable=True)
ip_address: Mapped[str | None] = mapped_column(INET, nullable=True)
user_agent: Mapped[str | None] = mapped_column(Text, nullable=True)
payload: Mapped[dict | None] = mapped_column(JSONB, nullable=True)
outcome: Mapped[str] = mapped_column(String(20), nullable=False, default="success")
occurred_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), nullable=False, server_default=func.now()
)
__table_args__ = (
Index("idx_audit_tenant_date", "tenant_id", "occurred_at"),
Index("idx_audit_user", "user_id", "occurred_at"),
Index("idx_audit_action", "action"),
)
def __repr__(self) -> str:
return f"<AuditLog {self.action!r} user={self.user_id} outcome={self.outcome!r}>"
+46
View File
@@ -0,0 +1,46 @@
"""
Modelli Label e MessageLabel tagging messaggi.
"""
import uuid
from sqlalchemy import CHAR, ForeignKey, Index, String, UniqueConstraint
from sqlalchemy.dialects.postgresql import UUID
from sqlalchemy.orm import Mapped, mapped_column
from app.database import Base
class Label(Base):
__tablename__ = "labels"
id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True), primary_key=True, default=uuid.uuid4
)
tenant_id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True), ForeignKey("tenants.id", ondelete="CASCADE"), nullable=False
)
name: Mapped[str] = mapped_column(String(100), nullable=False)
color: Mapped[str | None] = mapped_column(CHAR(7), nullable=True) # hex #RRGGBB
__table_args__ = (
UniqueConstraint("tenant_id", "name", name="uq_label_name_tenant"),
)
def __repr__(self) -> str:
return f"<Label {self.name!r}>"
class MessageLabel(Base):
__tablename__ = "message_labels"
message_id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True),
ForeignKey("messages.id", ondelete="CASCADE"),
primary_key=True,
)
label_id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True),
ForeignKey("labels.id", ondelete="CASCADE"),
primary_key=True,
)
+94
View File
@@ -0,0 +1,94 @@
"""
Modello Mailbox casella PEC con credenziali IMAP/SMTP cifrate.
"""
import uuid
from datetime import datetime
from sqlalchemy import (
BigInteger,
Boolean,
DateTime,
Enum,
ForeignKey,
Index,
Integer,
String,
Text,
UniqueConstraint,
func,
)
from sqlalchemy.dialects.postgresql import UUID
from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.database import Base
MailboxStatus = Enum(
"active", "paused", "error", "deleted",
name="mailbox_status",
create_type=False,
)
class Mailbox(Base):
__tablename__ = "mailboxes"
id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True), primary_key=True, default=uuid.uuid4
)
tenant_id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True), ForeignKey("tenants.id", ondelete="CASCADE"), nullable=False
)
email_address: Mapped[str] = mapped_column(String(255), nullable=False)
display_name: Mapped[str | None] = mapped_column(String(255), nullable=True)
provider: Mapped[str | None] = mapped_column(String(100), nullable=True)
# Credenziali IMAP cifrate (AES-256-GCM)
imap_host_enc: Mapped[str] = mapped_column(Text, nullable=False)
imap_port_enc: Mapped[str] = mapped_column(Text, nullable=False)
imap_user_enc: Mapped[str] = mapped_column(Text, nullable=False)
imap_pass_enc: Mapped[str] = mapped_column(Text, nullable=False)
imap_use_ssl: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True)
# Credenziali SMTP cifrate (AES-256-GCM)
smtp_host_enc: Mapped[str] = mapped_column(Text, nullable=False)
smtp_port_enc: Mapped[str] = mapped_column(Text, nullable=False)
smtp_user_enc: Mapped[str] = mapped_column(Text, nullable=False)
smtp_pass_enc: Mapped[str] = mapped_column(Text, nullable=False)
smtp_use_tls: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True)
# Stato sincronizzazione
status: Mapped[str] = mapped_column(MailboxStatus, nullable=False, default="active")
last_sync_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
last_sync_uid: Mapped[int | None] = mapped_column(BigInteger, nullable=True)
sync_error_msg: Mapped[str | None] = mapped_column(Text, nullable=True)
sync_error_count: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
created_by: Mapped[uuid.UUID | None] = mapped_column(
UUID(as_uuid=True), ForeignKey("users.id"), nullable=True
)
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), nullable=False, server_default=func.now()
)
updated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), nullable=False, server_default=func.now(), onupdate=func.now()
)
# Relazioni
tenant: Mapped["Tenant"] = relationship("Tenant", back_populates="mailboxes") # noqa: F821
permissions: Mapped[list["MailboxPermission"]] = relationship( # noqa: F821
"MailboxPermission", back_populates="mailbox", cascade="all, delete-orphan"
)
__table_args__ = (
UniqueConstraint("tenant_id", "email_address", name="uq_mailbox_email_tenant"),
Index("idx_mailboxes_tenant", "tenant_id"),
Index(
"idx_mailboxes_status",
"status",
postgresql_where="status = 'active'",
),
)
def __repr__(self) -> str:
return f"<Mailbox {self.email_address!r} status={self.status!r}>"
+191
View File
@@ -0,0 +1,191 @@
"""
Modelli Message, Attachment, SendJob.
"""
import uuid
from datetime import datetime
from sqlalchemy import (
ARRAY,
BigInteger,
Boolean,
DateTime,
Enum,
ForeignKey,
Index,
Integer,
String,
Text,
func,
)
from sqlalchemy.dialects.postgresql import UUID
from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.database import Base
PecDirection = Enum("inbound", "outbound", name="pec_direction", create_type=False)
PecState = Enum(
"draft", "queued", "sent", "accepted", "delivered", "anomaly", "failed", "received",
name="pec_state",
create_type=False,
)
PecMsgType = Enum(
"posta_certificata", "accettazione", "non_accettazione", "presa_in_carico",
"avvenuta_consegna", "mancata_consegna", "errore_consegna",
"preavviso_mancata_consegna", "rilevazione_virus", "unknown",
name="pec_msg_type",
create_type=False,
)
SendJobStatus = Enum(
"pending", "sending", "sent", "failed", "retrying",
name="send_job_status",
create_type=False,
)
class Message(Base):
__tablename__ = "messages"
id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True), primary_key=True, default=uuid.uuid4
)
tenant_id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True), ForeignKey("tenants.id", ondelete="CASCADE"), nullable=False
)
mailbox_id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True), ForeignKey("mailboxes.id", ondelete="CASCADE"), nullable=False
)
# Identificatori
message_id_header: Mapped[str | None] = mapped_column(Text, nullable=True)
imap_uid: Mapped[int | None] = mapped_column(BigInteger, nullable=True)
imap_folder: Mapped[str] = mapped_column(String(255), nullable=False, default="INBOX")
direction: Mapped[str] = mapped_column(PecDirection, nullable=False)
pec_type: Mapped[str] = mapped_column(
PecMsgType, nullable=False, default="posta_certificata"
)
state: Mapped[str] = mapped_column(PecState, nullable=False)
# Busta PEC
subject: Mapped[str | None] = mapped_column(Text, nullable=True)
from_address: Mapped[str | None] = mapped_column(String(255), nullable=True)
to_addresses: Mapped[list[str] | None] = mapped_column(ARRAY(Text), nullable=True)
cc_addresses: Mapped[list[str] | None] = mapped_column(ARRAY(Text), nullable=True)
sent_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
received_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
size_bytes: Mapped[int | None] = mapped_column(BigInteger, nullable=True)
# Corpo
body_text: Mapped[str | None] = mapped_column(Text, nullable=True)
body_html: Mapped[str | None] = mapped_column(Text, nullable=True)
has_attachments: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
# Collegamento ricevute
parent_message_id: Mapped[uuid.UUID | None] = mapped_column(
UUID(as_uuid=True), ForeignKey("messages.id"), nullable=True
)
# Flag operativi
is_read: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
is_starred: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
is_archived: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
archived_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
raw_eml_path: Mapped[str | None] = mapped_column(Text, nullable=True)
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), nullable=False, server_default=func.now()
)
updated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), nullable=False, server_default=func.now(), onupdate=func.now()
)
# Relazioni
attachments: Mapped[list["Attachment"]] = relationship(
"Attachment", back_populates="message", cascade="all, delete-orphan"
)
children: Mapped[list["Message"]] = relationship(
"Message", foreign_keys=[parent_message_id]
)
__table_args__ = (
Index("idx_messages_tenant", "tenant_id"),
Index("idx_messages_mailbox", "mailbox_id"),
Index("idx_messages_state", "state"),
Index("idx_messages_received_at", "received_at", postgresql_ops={"received_at": "DESC"}),
Index(
"idx_messages_parent", "parent_message_id",
postgresql_where="parent_message_id IS NOT NULL",
),
Index("idx_messages_imap_uid", "mailbox_id", "imap_uid"),
)
def __repr__(self) -> str:
return f"<Message {self.id} {self.pec_type!r} {self.state!r}>"
class Attachment(Base):
__tablename__ = "attachments"
id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True), primary_key=True, default=uuid.uuid4
)
tenant_id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True), ForeignKey("tenants.id", ondelete="CASCADE"), nullable=False
)
message_id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True), ForeignKey("messages.id", ondelete="CASCADE"), nullable=False
)
filename: Mapped[str] = mapped_column(String(512), nullable=False)
content_type: Mapped[str | None] = mapped_column(String(255), nullable=True)
size_bytes: Mapped[int | None] = mapped_column(BigInteger, nullable=True)
storage_path: Mapped[str] = mapped_column(Text, nullable=False)
checksum_sha256: Mapped[str | None] = mapped_column(String(64), nullable=True)
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), nullable=False, server_default=func.now()
)
# Relazioni
message: Mapped["Message"] = relationship("Message", back_populates="attachments")
__table_args__ = (
Index("idx_attachments_message", "message_id"),
)
class SendJob(Base):
__tablename__ = "send_jobs"
id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True), primary_key=True, default=uuid.uuid4
)
tenant_id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True), ForeignKey("tenants.id", ondelete="CASCADE"), nullable=False
)
mailbox_id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True), ForeignKey("mailboxes.id"), nullable=False
)
message_id: Mapped[uuid.UUID | None] = mapped_column(
UUID(as_uuid=True), ForeignKey("messages.id"), nullable=True
)
status: Mapped[str] = mapped_column(SendJobStatus, nullable=False, default="pending")
attempt_count: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
max_attempts: Mapped[int] = mapped_column(Integer, nullable=False, default=5)
next_retry_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
last_error: Mapped[str | None] = mapped_column(Text, nullable=True)
queued_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), nullable=False, server_default=func.now()
)
sent_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
created_by: Mapped[uuid.UUID | None] = mapped_column(
UUID(as_uuid=True), ForeignKey("users.id"), nullable=True
)
__table_args__ = (
Index("idx_sendjobs_tenant", "tenant_id"),
Index(
"idx_sendjobs_status", "status", "next_retry_at",
postgresql_where="status IN ('pending', 'retrying')",
),
)
+71
View File
@@ -0,0 +1,71 @@
"""
Modello MailboxPermission matrice permessi utente × casella (Fase 1-A).
ADR permessi granulari: admin ha accesso implicito a tutto.
Gli operator/readonly/supervisor devono avere un record esplicito.
"""
import uuid
from datetime import datetime
from sqlalchemy import (
Boolean,
DateTime,
ForeignKey,
Index,
UniqueConstraint,
func,
)
from sqlalchemy.dialects.postgresql import UUID
from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.database import Base
class MailboxPermission(Base):
__tablename__ = "mailbox_permissions"
id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True), primary_key=True, default=uuid.uuid4
)
tenant_id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True), ForeignKey("tenants.id", ondelete="CASCADE"), nullable=False
)
user_id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True), ForeignKey("users.id", ondelete="CASCADE"), nullable=False
)
mailbox_id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True), ForeignKey("mailboxes.id", ondelete="CASCADE"), nullable=False
)
can_read: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True)
can_send: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
can_manage: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
granted_by: Mapped[uuid.UUID | None] = mapped_column(
UUID(as_uuid=True), ForeignKey("users.id"), nullable=True
)
granted_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), nullable=False, server_default=func.now()
)
# Relazioni
user: Mapped["User"] = relationship( # noqa: F821
"User", back_populates="mailbox_permissions", foreign_keys=[user_id]
)
mailbox: Mapped["Mailbox"] = relationship( # noqa: F821
"Mailbox", back_populates="permissions"
)
__table_args__ = (
UniqueConstraint("user_id", "mailbox_id", name="uq_perm_user_mailbox"),
Index("idx_mbperm_user", "user_id"),
Index("idx_mbperm_mailbox", "mailbox_id"),
Index("idx_mbperm_tenant", "tenant_id"),
)
def __repr__(self) -> str:
return (
f"<MailboxPermission user={self.user_id} mailbox={self.mailbox_id} "
f"read={self.can_read} send={self.can_send}>"
)
+47
View File
@@ -0,0 +1,47 @@
"""
Modello Tenant ogni organizzazione cliente del SaaS.
"""
import uuid
from datetime import datetime
from sqlalchemy import Boolean, DateTime, Index, Integer, String, func
from sqlalchemy.dialects.postgresql import UUID
from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.database import Base
class Tenant(Base):
__tablename__ = "tenants"
id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True), primary_key=True, default=uuid.uuid4
)
slug: Mapped[str] = mapped_column(String(63), nullable=False, unique=True)
name: Mapped[str] = mapped_column(String(255), nullable=False)
plan: Mapped[str] = mapped_column(String(50), nullable=False, default="starter")
is_active: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True)
max_mailboxes: Mapped[int] = mapped_column(Integer, nullable=False, default=5)
max_users: Mapped[int] = mapped_column(Integer, nullable=False, default=10)
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), nullable=False, server_default=func.now()
)
updated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), nullable=False, server_default=func.now(), onupdate=func.now()
)
# Relazioni
users: Mapped[list["User"]] = relationship( # noqa: F821
"User", back_populates="tenant", cascade="all, delete-orphan"
)
mailboxes: Mapped[list["Mailbox"]] = relationship( # noqa: F821
"Mailbox", back_populates="tenant", cascade="all, delete-orphan"
)
__table_args__ = (
Index("idx_tenants_slug", "slug"),
)
def __repr__(self) -> str:
return f"<Tenant {self.slug!r} ({self.plan})>"
+129
View File
@@ -0,0 +1,129 @@
"""
Modelli User e RefreshToken.
"""
import uuid
from datetime import datetime
from sqlalchemy import (
Boolean,
DateTime,
Enum,
ForeignKey,
Index,
Integer,
String,
Text,
UniqueConstraint,
func,
)
from sqlalchemy.dialects.postgresql import INET, UUID
from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.database import Base
UserRole = Enum(
"super_admin", "admin", "supervisor", "operator", "readonly",
name="user_role",
create_type=False, # creato dalla migrazione Alembic
)
class User(Base):
__tablename__ = "users"
id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True), primary_key=True, default=uuid.uuid4
)
tenant_id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True), ForeignKey("tenants.id", ondelete="CASCADE"), nullable=False
)
email: Mapped[str] = mapped_column(String(255), nullable=False)
password_hash: Mapped[str] = mapped_column(Text, nullable=False)
full_name: Mapped[str] = mapped_column(String(255), nullable=False)
role: Mapped[str] = mapped_column(UserRole, nullable=False, default="operator")
is_active: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True)
# 2FA TOTP
totp_secret: Mapped[str | None] = mapped_column(Text, nullable=True)
totp_enabled: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
# Sicurezza accesso
last_login_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
failed_login_count: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
locked_until: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), nullable=False, server_default=func.now()
)
updated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), nullable=False, server_default=func.now(), onupdate=func.now()
)
# Relazioni
tenant: Mapped["Tenant"] = relationship("Tenant", back_populates="users") # noqa: F821
refresh_tokens: Mapped[list["RefreshToken"]] = relationship(
"RefreshToken", back_populates="user", cascade="all, delete-orphan"
)
mailbox_permissions: Mapped[list["MailboxPermission"]] = relationship( # noqa: F821
"MailboxPermission",
back_populates="user",
foreign_keys="[MailboxPermission.user_id]",
cascade="all, delete-orphan",
)
__table_args__ = (
UniqueConstraint("tenant_id", "email", name="uq_user_email_tenant"),
Index("idx_users_tenant", "tenant_id"),
Index("idx_users_email", "email"),
)
@property
def is_admin(self) -> bool:
return self.role in ("super_admin", "admin")
@property
def is_super_admin(self) -> bool:
return self.role == "super_admin"
def __repr__(self) -> str:
return f"<User {self.email!r} role={self.role!r}>"
class RefreshToken(Base):
__tablename__ = "refresh_tokens"
id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True), primary_key=True, default=uuid.uuid4
)
user_id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True), ForeignKey("users.id", ondelete="CASCADE"), nullable=False
)
token_hash: Mapped[str] = mapped_column(Text, nullable=False, unique=True)
issued_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), nullable=False, server_default=func.now()
)
expires_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False)
revoked_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
user_agent: Mapped[str | None] = mapped_column(Text, nullable=True)
ip_address: Mapped[str | None] = mapped_column(INET, nullable=True)
# Relazioni
user: Mapped["User"] = relationship("User", back_populates="refresh_tokens")
__table_args__ = (
Index("idx_rt_user", "user_id"),
Index(
"idx_rt_expires",
"expires_at",
postgresql_where="revoked_at IS NULL",
),
)
@property
def is_valid(self) -> bool:
from datetime import UTC
return self.revoked_at is None and self.expires_at > datetime.now(UTC)
def __repr__(self) -> str:
return f"<RefreshToken user_id={self.user_id!r}>"
+1
View File
@@ -0,0 +1 @@
# Schemas Pydantic
+68
View File
@@ -0,0 +1,68 @@
"""
Schema Pydantic per autenticazione e autorizzazione.
"""
import uuid
from datetime import datetime
from pydantic import BaseModel, EmailStr, Field, field_validator
# ─── Request ──────────────────────────────────────────────────────────────────
class LoginRequest(BaseModel):
email: EmailStr
password: str = Field(min_length=1)
totp_code: str | None = Field(default=None, description="Codice TOTP 6 cifre (se 2FA attivo)")
class RefreshRequest(BaseModel):
refresh_token: str
class TOTPVerifyRequest(BaseModel):
totp_code: str = Field(min_length=6, max_length=6, description="Codice TOTP 6 cifre")
class PasswordChangeRequest(BaseModel):
current_password: str
new_password: str = Field(min_length=8)
@field_validator("new_password")
@classmethod
def validate_password_strength(cls, v: str) -> str:
if len(v) < 8:
raise ValueError("La password deve essere almeno 8 caratteri")
if not any(c.isupper() for c in v):
raise ValueError("La password deve contenere almeno una lettera maiuscola")
if not any(c.isdigit() for c in v):
raise ValueError("La password deve contenere almeno un numero")
return v
# ─── Response ─────────────────────────────────────────────────────────────────
class TokenResponse(BaseModel):
access_token: str
refresh_token: str
token_type: str = "bearer"
expires_in: int = Field(description="Scadenza access token in secondi")
class TOTPSetupResponse(BaseModel):
"""Dati per configurare TOTP sull'authenticator app."""
secret: str = Field(description="Segreto TOTP base32 (da inserire manualmente)")
qr_uri: str = Field(description="URI otpauth:// per il QR code")
qr_image_base64: str = Field(description="QR code come immagine PNG base64")
class TOTPStatusResponse(BaseModel):
totp_enabled: bool
class UserTokenData(BaseModel):
"""Dati estratti dal JWT per l'utente corrente."""
user_id: uuid.UUID
tenant_id: uuid.UUID
role: str
email: str | None = None
+50
View File
@@ -0,0 +1,50 @@
"""
Schema Pydantic per permessi granulari casella (Fase 1-A).
"""
import uuid
from datetime import datetime
from pydantic import BaseModel
class PermissionGrantRequest(BaseModel):
can_read: bool = True
can_send: bool = False
can_manage: bool = False
class PermissionResponse(BaseModel):
id: uuid.UUID
tenant_id: uuid.UUID
user_id: uuid.UUID
mailbox_id: uuid.UUID
can_read: bool
can_send: bool
can_manage: bool
granted_by: uuid.UUID | None
granted_at: datetime
model_config = {"from_attributes": True}
class UserMailboxPermissionResponse(BaseModel):
"""Vista utente: caselle accessibili con relativi permessi."""
mailbox_id: uuid.UUID
mailbox_email: str
mailbox_display_name: str | None
can_read: bool
can_send: bool
can_manage: bool
class MailboxUserPermissionResponse(BaseModel):
"""Vista casella: utenti con accesso."""
user_id: uuid.UUID
user_email: str
user_full_name: str
user_role: str
can_read: bool
can_send: bool
can_manage: bool
granted_at: datetime
+54
View File
@@ -0,0 +1,54 @@
"""
Schema Pydantic per tenant (super-admin).
"""
import uuid
from datetime import datetime
from typing import Literal
from pydantic import BaseModel, Field, field_validator
TenantPlanType = Literal["starter", "pro", "enterprise"]
class TenantCreateRequest(BaseModel):
slug: str = Field(min_length=3, max_length=63, pattern=r"^[a-z0-9-]+$")
name: str = Field(min_length=2, max_length=255)
plan: TenantPlanType = "starter"
max_mailboxes: int = Field(default=5, ge=1, le=1000)
max_users: int = Field(default=10, ge=1, le=1000)
# Utente admin iniziale
admin_email: str
admin_password: str = Field(min_length=8)
admin_full_name: str = Field(min_length=2, max_length=255)
@field_validator("slug")
@classmethod
def validate_slug(cls, v: str) -> str:
reserved = {"api", "admin", "www", "mail", "smtp", "imap", "pecflow", "app"}
if v in reserved:
raise ValueError(f"Slug '{v}' riservato")
return v.lower()
class TenantUpdateRequest(BaseModel):
name: str | None = Field(default=None, min_length=2, max_length=255)
plan: TenantPlanType | None = None
is_active: bool | None = None
max_mailboxes: int | None = Field(default=None, ge=1, le=1000)
max_users: int | None = Field(default=None, ge=1, le=1000)
class TenantResponse(BaseModel):
id: uuid.UUID
slug: str
name: str
plan: str
is_active: bool
max_mailboxes: int
max_users: int
created_at: datetime
updated_at: datetime
model_config = {"from_attributes": True}
+89
View File
@@ -0,0 +1,89 @@
"""
Schema Pydantic per utenti.
"""
import uuid
from datetime import datetime
from typing import Literal
from pydantic import BaseModel, EmailStr, Field, field_validator
UserRoleType = Literal["super_admin", "admin", "supervisor", "operator", "readonly"]
# ─── Request ──────────────────────────────────────────────────────────────────
class UserCreateRequest(BaseModel):
email: EmailStr
password: str = Field(min_length=8)
full_name: str = Field(min_length=2, max_length=255)
role: UserRoleType = "operator"
@field_validator("password")
@classmethod
def validate_password(cls, v: str) -> str:
if len(v) < 8:
raise ValueError("La password deve essere almeno 8 caratteri")
if not any(c.isupper() for c in v):
raise ValueError("Almeno una lettera maiuscola richiesta")
if not any(c.isdigit() for c in v):
raise ValueError("Almeno un numero richiesto")
return v
@field_validator("role")
@classmethod
def validate_role_not_superadmin(cls, v: str) -> str:
if v == "super_admin":
raise ValueError("Non è possibile creare utenti super_admin via API")
return v
class UserUpdateRequest(BaseModel):
full_name: str | None = Field(default=None, min_length=2, max_length=255)
role: UserRoleType | None = None
is_active: bool | None = None
@field_validator("role")
@classmethod
def validate_role_not_superadmin(cls, v: str | None) -> str | None:
if v == "super_admin":
raise ValueError("Non è possibile assegnare il ruolo super_admin via API")
return v
class UserPasswordResetRequest(BaseModel):
new_password: str = Field(min_length=8)
@field_validator("new_password")
@classmethod
def validate_password(cls, v: str) -> str:
if not any(c.isupper() for c in v):
raise ValueError("Almeno una lettera maiuscola richiesta")
if not any(c.isdigit() for c in v):
raise ValueError("Almeno un numero richiesto")
return v
# ─── Response ─────────────────────────────────────────────────────────────────
class UserResponse(BaseModel):
id: uuid.UUID
tenant_id: uuid.UUID
email: str
full_name: str
role: str
is_active: bool
totp_enabled: bool
last_login_at: datetime | None
created_at: datetime
updated_at: datetime
model_config = {"from_attributes": True}
class UserListResponse(BaseModel):
items: list[UserResponse]
total: int
page: int
page_size: int
pages: int
+1
View File
@@ -0,0 +1 @@
# Services
+302
View File
@@ -0,0 +1,302 @@
"""
Servizio autenticazione login, JWT, TOTP 2FA, refresh token.
"""
import base64
import io
import uuid
from datetime import UTC, datetime, timedelta
import pyotp
import qrcode
from jose import JWTError
from sqlalchemy import select, update
from sqlalchemy.ext.asyncio import AsyncSession
from app.config import get_settings
from app.core.exceptions import (
AccountDisabledError,
AccountLockedError,
InvalidCredentialsError,
TOTPInvalidError,
TOTPRequiredError,
TokenInvalidError,
)
from app.core.security import (
create_access_token,
create_refresh_token,
decode_token,
encrypt_credential,
decrypt_credential,
hash_password,
hash_token,
verify_password,
)
from app.models.audit_log import AuditLog
from app.models.user import RefreshToken, User
settings = get_settings()
# Numero massimo di tentativi falliti prima del blocco
MAX_FAILED_ATTEMPTS = 5
LOCK_DURATION_MINUTES = 15
class AuthService:
def __init__(self, db: AsyncSession) -> None:
self.db = db
async def login(
self,
email: str,
password: str,
totp_code: str | None,
ip_address: str | None = None,
user_agent: str | None = None,
) -> tuple[str, str]:
"""
Autentica l'utente con email + password (+ TOTP se abilitato).
Restituisce (access_token, refresh_token).
"""
# 1. Trova utente per email (non filtrare per tenant: l'email è unica globalmente
# per ora, ma in futuro si potrebbe filtrare per subdomain)
user = await self._get_user_by_email(email)
if not user:
await self._log_audit(None, None, "auth.login", "failure", ip_address, {"reason": "user_not_found"})
raise InvalidCredentialsError()
# 2. Verifica account attivo
if not user.is_active:
raise AccountDisabledError()
# 3. Verifica blocco temporaneo
if user.locked_until and user.locked_until > datetime.now(UTC):
locked_str = user.locked_until.strftime("%H:%M")
raise AccountLockedError(locked_until=locked_str)
# 4. Verifica password
if not verify_password(password, user.password_hash):
await self._handle_failed_login(user)
await self._log_audit(user.tenant_id, user.id, "auth.login", "failure", ip_address, {"reason": "wrong_password"})
raise InvalidCredentialsError()
# 5. Verifica TOTP (se abilitato)
if user.totp_enabled:
if not totp_code:
raise TOTPRequiredError()
if not self._verify_totp(user, totp_code):
await self._log_audit(user.tenant_id, user.id, "auth.login", "failure", ip_address, {"reason": "invalid_totp"})
raise TOTPInvalidError()
# 6. Reset contatori falliti
await self._reset_failed_login(user)
# 7. Genera token
access_token = create_access_token(
subject=user.id,
tenant_id=user.tenant_id,
role=user.role,
)
refresh_token_raw = create_refresh_token(
subject=user.id,
tenant_id=user.tenant_id,
)
# 8. Salva refresh token in DB (hash)
rt = RefreshToken(
user_id=user.id,
token_hash=hash_token(refresh_token_raw),
expires_at=datetime.now(UTC) + timedelta(days=settings.refresh_token_expire_days),
user_agent=user_agent,
ip_address=ip_address,
)
self.db.add(rt)
# 9. Aggiorna last_login_at
await self.db.execute(
update(User)
.where(User.id == user.id)
.values(last_login_at=datetime.now(UTC))
)
await self._log_audit(user.tenant_id, user.id, "auth.login", "success", ip_address, {})
return access_token, refresh_token_raw
async def refresh_tokens(self, refresh_token_raw: str) -> tuple[str, str]:
"""
Valida il refresh token e restituisce nuova coppia di token.
Implementa rotation: il vecchio refresh token viene revocato.
"""
# Valida struttura JWT
try:
payload = decode_token(refresh_token_raw)
except JWTError:
raise TokenInvalidError()
if payload.get("type") != "refresh":
raise TokenInvalidError()
# Cerca il token in DB
token_hash = hash_token(refresh_token_raw)
result = await self.db.execute(
select(RefreshToken).where(RefreshToken.token_hash == token_hash)
)
rt = result.scalar_one_or_none()
if not rt or not rt.is_valid:
raise TokenInvalidError()
# Carica l'utente
user_result = await self.db.execute(
select(User).where(User.id == rt.user_id)
)
user = user_result.scalar_one_or_none()
if not user or not user.is_active:
raise TokenInvalidError()
# Revoca il vecchio refresh token (rotation)
rt.revoked_at = datetime.now(UTC)
# Genera nuovi token
new_access = create_access_token(
subject=user.id,
tenant_id=user.tenant_id,
role=user.role,
)
new_refresh_raw = create_refresh_token(
subject=user.id,
tenant_id=user.tenant_id,
)
# Salva nuovo refresh token
new_rt = RefreshToken(
user_id=user.id,
token_hash=hash_token(new_refresh_raw),
expires_at=datetime.now(UTC) + timedelta(days=settings.refresh_token_expire_days),
ip_address=rt.ip_address,
)
self.db.add(new_rt)
return new_access, new_refresh_raw
async def logout(self, refresh_token_raw: str) -> None:
"""Revoca il refresh token (logout)."""
token_hash = hash_token(refresh_token_raw)
result = await self.db.execute(
select(RefreshToken).where(RefreshToken.token_hash == token_hash)
)
rt = result.scalar_one_or_none()
if rt:
rt.revoked_at = datetime.now(UTC)
async def setup_totp(self, user: User) -> dict:
"""
Genera segreto TOTP e QR code per l'utente.
Il segreto viene cifrato e salvato in DB ma TOTP non è ancora attivo
(richiede verifica con totp_verify).
"""
# Genera segreto base32
secret = pyotp.random_base32()
# Cifra il segreto prima di salvarlo
encrypted_secret = encrypt_credential(secret)
user.totp_secret = encrypted_secret
# Non attivare ancora: richiede verifica
user.totp_enabled = False
# Genera URI otpauth://
totp = pyotp.TOTP(secret)
uri = totp.provisioning_uri(name=user.email, issuer_name="PecFlow")
# Genera QR code
qr = qrcode.QRCode(version=1, box_size=6, border=4)
qr.add_data(uri)
qr.make(fit=True)
img = qr.make_image(fill_color="black", back_color="white")
buffered = io.BytesIO()
img.save(buffered, format="PNG")
qr_b64 = base64.b64encode(buffered.getvalue()).decode("ascii")
return {
"secret": secret,
"qr_uri": uri,
"qr_image_base64": f"data:image/png;base64,{qr_b64}",
}
async def verify_and_enable_totp(self, user: User, totp_code: str) -> bool:
"""
Verifica il codice TOTP e attiva il 2FA se corretto.
"""
if not user.totp_secret:
return False
if not self._verify_totp(user, totp_code):
raise TOTPInvalidError()
user.totp_enabled = True
return True
async def disable_totp(self, user: User) -> None:
"""Disabilita il 2FA per l'utente."""
user.totp_secret = None
user.totp_enabled = False
# ─── Private helpers ──────────────────────────────────────────────────────
async def _get_user_by_email(self, email: str) -> User | None:
result = await self.db.execute(
select(User).where(User.email == email.lower())
)
return result.scalar_one_or_none()
def _verify_totp(self, user: User, code: str) -> bool:
"""Verifica il codice TOTP (accetta ±1 intervallo per clock skew)."""
if not user.totp_secret:
return False
try:
secret = decrypt_credential(user.totp_secret)
totp = pyotp.TOTP(secret)
return totp.verify(code, valid_window=1)
except Exception:
return False
async def _handle_failed_login(self, user: User) -> None:
"""Incrementa contatore fallimenti, blocca se necessario."""
new_count = user.failed_login_count + 1
updates: dict = {"failed_login_count": new_count}
if new_count >= MAX_FAILED_ATTEMPTS:
updates["locked_until"] = datetime.now(UTC) + timedelta(minutes=LOCK_DURATION_MINUTES)
await self.db.execute(
update(User).where(User.id == user.id).values(**updates)
)
async def _reset_failed_login(self, user: User) -> None:
await self.db.execute(
update(User)
.where(User.id == user.id)
.values(failed_login_count=0, locked_until=None)
)
async def _log_audit(
self,
tenant_id: uuid.UUID | None,
user_id: uuid.UUID | None,
action: str,
outcome: str,
ip_address: str | None,
payload: dict,
) -> None:
log = AuditLog(
tenant_id=tenant_id,
user_id=user_id,
action=action,
outcome=outcome,
ip_address=ip_address,
payload=payload,
)
self.db.add(log)
+236
View File
@@ -0,0 +1,236 @@
"""
Servizio permessi granulari gestione accessi utente × casella (Fase 1-A).
Gerarchia:
super_admin / admin → accesso implicito a tutto (no record in mailbox_permissions)
supervisor / operator / readonly → richiedono record esplicito
"""
import uuid
from sqlalchemy import delete, select
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.exceptions import ConflictError, ForbiddenError, NotFoundError, PermissionDeniedError
from app.models.mailbox import Mailbox
from app.models.permission import MailboxPermission
from app.models.user import User
from app.schemas.permission import PermissionGrantRequest
class PermissionService:
def __init__(self, db: AsyncSession) -> None:
self.db = db
# ─── Verifica accessi ─────────────────────────────────────────────────────
async def get_visible_mailboxes(
self, user: User
) -> list[uuid.UUID]:
"""Restituisce gli UUID delle caselle visibili all'utente."""
if user.role in ("super_admin", "admin"):
# Admin vede tutte le caselle del tenant
result = await self.db.execute(
select(Mailbox.id).where(
Mailbox.tenant_id == user.tenant_id,
Mailbox.status != "deleted",
)
)
return [row[0] for row in result.all()]
# Operatori: solo caselle con can_read=True
result = await self.db.execute(
select(MailboxPermission.mailbox_id).where(
MailboxPermission.user_id == user.id,
MailboxPermission.can_read == True,
)
)
return [row[0] for row in result.all()]
async def check_can_read(
self, user: User, mailbox_id: uuid.UUID
) -> bool:
"""Verifica se l'utente può leggere i messaggi della casella."""
if user.role in ("super_admin", "admin"):
# Verifica solo che la casella appartenga al tenant
return await self._mailbox_belongs_to_tenant(mailbox_id, user.tenant_id)
perm = await self._get_permission(user.id, mailbox_id)
return perm is not None and perm.can_read
async def check_can_send(
self, user: User, mailbox_id: uuid.UUID
) -> bool:
"""Verifica se l'utente può inviare dalla casella."""
if user.role in ("super_admin", "admin"):
return await self._mailbox_belongs_to_tenant(mailbox_id, user.tenant_id)
perm = await self._get_permission(user.id, mailbox_id)
return perm is not None and perm.can_send
async def check_can_manage(
self, user: User, mailbox_id: uuid.UUID
) -> bool:
"""Verifica se l'utente può gestire la configurazione della casella."""
if user.role in ("super_admin", "admin"):
return await self._mailbox_belongs_to_tenant(mailbox_id, user.tenant_id)
perm = await self._get_permission(user.id, mailbox_id)
return perm is not None and perm.can_manage
async def require_can_read(self, user: User, mailbox_id: uuid.UUID) -> None:
"""Solleva 403 se l'utente non può leggere."""
if not await self.check_can_read(user, mailbox_id):
raise PermissionDeniedError("casella")
async def require_can_send(self, user: User, mailbox_id: uuid.UUID) -> None:
if not await self.check_can_send(user, mailbox_id):
raise PermissionDeniedError("casella (invio)")
# ─── CRUD permessi ────────────────────────────────────────────────────────
async def grant_permission(
self,
tenant_id: uuid.UUID,
mailbox_id: uuid.UUID,
user_id: uuid.UUID,
data: PermissionGrantRequest,
granted_by: User,
) -> MailboxPermission:
"""
Crea o aggiorna un permesso utente su una casella.
Solo admin può gestire i permessi.
"""
if not granted_by.is_admin:
raise ForbiddenError("Solo gli amministratori possono gestire i permessi")
# Verifica che casella e utente appartengano al tenant
mailbox = await self.db.get(Mailbox, mailbox_id)
if not mailbox or mailbox.tenant_id != tenant_id:
raise NotFoundError("casella")
target_user = await self.db.get(User, user_id)
if not target_user or target_user.tenant_id != tenant_id:
raise NotFoundError("utente")
# Non serve permesso esplicito per admin
if target_user.role in ("super_admin", "admin"):
raise ForbiddenError("Gli admin hanno già accesso implicito a tutte le caselle")
# Cerca permesso esistente (upsert)
existing = await self._get_permission(user_id, mailbox_id)
if existing:
existing.can_read = data.can_read
existing.can_send = data.can_send
existing.can_manage = data.can_manage
existing.granted_by = granted_by.id
return existing
perm = MailboxPermission(
tenant_id=tenant_id,
user_id=user_id,
mailbox_id=mailbox_id,
can_read=data.can_read,
can_send=data.can_send,
can_manage=data.can_manage,
granted_by=granted_by.id,
)
self.db.add(perm)
await self.db.flush()
return perm
async def revoke_permission(
self,
mailbox_id: uuid.UUID,
user_id: uuid.UUID,
revoked_by: User,
) -> None:
if not revoked_by.is_admin:
raise ForbiddenError("Solo gli amministratori possono revocare i permessi")
result = await self.db.execute(
delete(MailboxPermission).where(
MailboxPermission.mailbox_id == mailbox_id,
MailboxPermission.user_id == user_id,
)
)
if result.rowcount == 0:
raise NotFoundError("permesso")
async def list_mailbox_users(
self, mailbox_id: uuid.UUID, tenant_id: uuid.UUID
) -> list[dict]:
"""Ritorna tutti gli utenti con permesso esplicito su questa casella."""
result = await self.db.execute(
select(MailboxPermission, User)
.join(User, MailboxPermission.user_id == User.id)
.where(
MailboxPermission.mailbox_id == mailbox_id,
MailboxPermission.tenant_id == tenant_id,
)
)
rows = result.all()
return [
{
"user_id": perm.user_id,
"user_email": user.email,
"user_full_name": user.full_name,
"user_role": user.role,
"can_read": perm.can_read,
"can_send": perm.can_send,
"can_manage": perm.can_manage,
"granted_at": perm.granted_at,
}
for perm, user in rows
]
async def list_user_mailboxes(
self, user_id: uuid.UUID, tenant_id: uuid.UUID
) -> list[dict]:
"""Ritorna tutte le caselle accessibili a un utente (permessi espliciti)."""
result = await self.db.execute(
select(MailboxPermission, Mailbox)
.join(Mailbox, MailboxPermission.mailbox_id == Mailbox.id)
.where(
MailboxPermission.user_id == user_id,
MailboxPermission.tenant_id == tenant_id,
MailboxPermission.can_read == True,
)
)
rows = result.all()
return [
{
"mailbox_id": perm.mailbox_id,
"mailbox_email": mailbox.email_address,
"mailbox_display_name": mailbox.display_name,
"can_read": perm.can_read,
"can_send": perm.can_send,
"can_manage": perm.can_manage,
}
for perm, mailbox in rows
]
# ─── Private ──────────────────────────────────────────────────────────────
async def _get_permission(
self, user_id: uuid.UUID, mailbox_id: uuid.UUID
) -> MailboxPermission | None:
result = await self.db.execute(
select(MailboxPermission).where(
MailboxPermission.user_id == user_id,
MailboxPermission.mailbox_id == mailbox_id,
)
)
return result.scalar_one_or_none()
async def _mailbox_belongs_to_tenant(
self, mailbox_id: uuid.UUID, tenant_id: uuid.UUID
) -> bool:
result = await self.db.execute(
select(Mailbox.id).where(
Mailbox.id == mailbox_id,
Mailbox.tenant_id == tenant_id,
Mailbox.status != "deleted",
)
)
return result.scalar_one_or_none() is not None
+81
View File
@@ -0,0 +1,81 @@
"""
Servizio tenant gestione organizzazioni (solo super_admin).
"""
import uuid
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.exceptions import ConflictError, NotFoundError
from app.core.security import hash_password
from app.models.tenant import Tenant
from app.models.user import User
from app.schemas.tenant import TenantCreateRequest, TenantUpdateRequest
class TenantService:
def __init__(self, db: AsyncSession) -> None:
self.db = db
async def create_tenant(self, data: TenantCreateRequest) -> tuple[Tenant, User]:
"""Crea un nuovo tenant con il suo utente admin iniziale."""
# Verifica slug univoco
existing = await self.db.execute(
select(Tenant).where(Tenant.slug == data.slug)
)
if existing.scalar_one_or_none():
raise ConflictError(f"Slug '{data.slug}' già in uso")
tenant = Tenant(
slug=data.slug,
name=data.name,
plan=data.plan,
max_mailboxes=data.max_mailboxes,
max_users=data.max_users,
)
self.db.add(tenant)
await self.db.flush() # ottieni tenant.id
# Crea utente admin iniziale
admin = User(
tenant_id=tenant.id,
email=data.admin_email.lower(),
password_hash=hash_password(data.admin_password),
full_name=data.admin_full_name,
role="admin",
)
self.db.add(admin)
await self.db.flush()
return tenant, admin
async def get_tenant(self, tenant_id: uuid.UUID) -> Tenant:
tenant = await self.db.get(Tenant, tenant_id)
if not tenant:
raise NotFoundError("tenant")
return tenant
async def list_tenants(self) -> list[Tenant]:
result = await self.db.execute(
select(Tenant).order_by(Tenant.created_at.desc())
)
return list(result.scalars().all())
async def update_tenant(
self, tenant_id: uuid.UUID, data: TenantUpdateRequest
) -> Tenant:
tenant = await self.get_tenant(tenant_id)
if data.name is not None:
tenant.name = data.name
if data.plan is not None:
tenant.plan = data.plan
if data.is_active is not None:
tenant.is_active = data.is_active
if data.max_mailboxes is not None:
tenant.max_mailboxes = data.max_mailboxes
if data.max_users is not None:
tenant.max_users = data.max_users
return tenant
+145
View File
@@ -0,0 +1,145 @@
"""
Servizio utenti CRUD utenti per admin del tenant.
"""
import math
import uuid
from sqlalchemy import func, select, update
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.exceptions import ConflictError, ForbiddenError, NotFoundError, TenantLimitExceededError
from app.core.security import hash_password
from app.models.tenant import Tenant
from app.models.user import User
from app.schemas.user import UserCreateRequest, UserUpdateRequest
class UserService:
def __init__(self, db: AsyncSession) -> None:
self.db = db
async def create_user(
self,
tenant_id: uuid.UUID,
data: UserCreateRequest,
created_by: User,
) -> User:
"""Crea un nuovo utente nel tenant. Solo admin può farlo."""
# Verifica limite utenti del piano
tenant = await self.db.get(Tenant, tenant_id)
if not tenant:
raise NotFoundError("tenant")
user_count_result = await self.db.execute(
select(func.count()).where(User.tenant_id == tenant_id, User.is_active == True)
)
count = user_count_result.scalar_one()
if count >= tenant.max_users:
raise TenantLimitExceededError("utenti", tenant.max_users)
# Verifica email univoca nel tenant
existing = await self.db.execute(
select(User).where(
User.tenant_id == tenant_id,
User.email == data.email.lower(),
)
)
if existing.scalar_one_or_none():
raise ConflictError(f"Email '{data.email}' già registrata in questo tenant")
# Un admin non può creare un super_admin
if data.role == "super_admin" and not created_by.is_super_admin:
raise ForbiddenError("Non puoi creare utenti super_admin")
user = User(
tenant_id=tenant_id,
email=data.email.lower(),
password_hash=hash_password(data.password),
full_name=data.full_name,
role=data.role,
)
self.db.add(user)
await self.db.flush() # ottieni l'ID
return user
async def get_user(self, user_id: uuid.UUID, tenant_id: uuid.UUID) -> User:
result = await self.db.execute(
select(User).where(User.id == user_id, User.tenant_id == tenant_id)
)
user = result.scalar_one_or_none()
if not user:
raise NotFoundError("utente")
return user
async def list_users(
self,
tenant_id: uuid.UUID,
page: int = 1,
page_size: int = 25,
) -> tuple[list[User], int]:
"""Restituisce lista utenti paginata + totale."""
offset = (page - 1) * page_size
total_result = await self.db.execute(
select(func.count()).where(User.tenant_id == tenant_id)
)
total = total_result.scalar_one()
users_result = await self.db.execute(
select(User)
.where(User.tenant_id == tenant_id)
.order_by(User.created_at.desc())
.offset(offset)
.limit(page_size)
)
users = list(users_result.scalars().all())
return users, total
async def update_user(
self,
user_id: uuid.UUID,
tenant_id: uuid.UUID,
data: UserUpdateRequest,
updated_by: User,
) -> User:
user = await self.get_user(user_id, tenant_id)
# Non si può modificare un super_admin
if user.is_super_admin and not updated_by.is_super_admin:
raise ForbiddenError("Non puoi modificare un super_admin")
if data.full_name is not None:
user.full_name = data.full_name
if data.role is not None:
user.role = data.role
if data.is_active is not None:
user.is_active = data.is_active
return user
async def reset_password(
self,
user_id: uuid.UUID,
tenant_id: uuid.UUID,
new_password: str,
) -> None:
user = await self.get_user(user_id, tenant_id)
user.password_hash = hash_password(new_password)
async def delete_user(
self,
user_id: uuid.UUID,
tenant_id: uuid.UUID,
deleted_by: User,
) -> None:
user = await self.get_user(user_id, tenant_id)
if user.id == deleted_by.id:
raise ForbiddenError("Non puoi eliminare il tuo stesso account")
if user.is_super_admin:
raise ForbiddenError("Non puoi eliminare un super_admin")
# Soft delete (disabilita invece di eliminare)
user.is_active = False
+115
View File
@@ -0,0 +1,115 @@
[build-system]
requires = ["setuptools>=68", "wheel"]
build-backend = "setuptools.build_meta"
[project]
name = "pecflow-backend"
version = "1.0.0"
description = "PecFlow Backend API per gestione PEC SaaS"
requires-python = ">=3.12"
dependencies = [
# Web framework
"fastapi>=0.115.0",
"uvicorn[standard]>=0.30.0",
# Database
"sqlalchemy>=2.0.36",
"asyncpg>=0.29.0", # driver async PostgreSQL
"psycopg2-binary>=2.9.9", # driver sync (Alembic)
"alembic>=1.13.0",
# Validazione e configurazione
"pydantic>=2.9.0",
"pydantic-settings>=2.5.0",
"email-validator>=2.2.0",
# Autenticazione e sicurezza
"python-jose[cryptography]>=3.3.0",
"bcrypt>=4.0.0", # password hashing (usato direttamente, senza passlib)
"pyotp>=2.9.0", # TOTP 2FA
"qrcode[pil]>=7.4.2", # generazione QR code TOTP
"cryptography>=43.0.0", # AES-256-GCM cifratura credenziali
# Rate limiting
"slowapi>=0.1.9",
# HTTP client
"httpx>=0.27.0",
# Storage MinIO/S3
"miniopy-async>=1.21.0",
# Utilities
"python-multipart>=0.0.9", # upload file
"python-dotenv>=1.0.0",
]
[project.optional-dependencies]
dev = [
# Test
"pytest>=8.3.0",
"pytest-asyncio>=0.24.0",
"pytest-cov>=5.0.0",
"httpx>=0.27.0", # test client FastAPI
"anyio>=4.6.0",
"aiosqlite>=0.20.0", # driver SQLite async per i test di integrazione
# Linting e formatting
"ruff>=0.7.0",
"mypy>=1.13.0",
]
[tool.setuptools.packages.find]
where = ["."]
include = ["app*"]
# ─── Ruff ─────────────────────────────────────────────────────────────────────
[tool.ruff]
target-version = "py312"
line-length = 100
src = ["app", "tests"]
[tool.ruff.lint]
select = [
"E", # pycodestyle errors
"W", # pycodestyle warnings
"F", # pyflakes
"I", # isort
"B", # flake8-bugbear
"C4", # flake8-comprehensions
"UP", # pyupgrade
]
ignore = ["E501", "B008", "B904"]
[tool.ruff.lint.isort]
known-first-party = ["app"]
# ─── MyPy ─────────────────────────────────────────────────────────────────────
[tool.mypy]
python_version = "3.12"
strict = false
ignore_missing_imports = true
plugins = ["pydantic.mypy"]
# ─── Pytest ───────────────────────────────────────────────────────────────────
[tool.pytest.ini_options]
asyncio_mode = "auto"
testpaths = ["tests"]
python_files = ["test_*.py"]
python_classes = ["Test*"]
python_functions = ["test_*"]
filterwarnings = [
"ignore::DeprecationWarning",
"ignore::PendingDeprecationWarning",
]
# ─── Coverage ─────────────────────────────────────────────────────────────────
[tool.coverage.run]
source = ["app"]
omit = ["tests/*", "alembic/*"]
[tool.coverage.report]
show_missing = true
fail_under = 70
+1
View File
@@ -0,0 +1 @@
# Tests
+1
View File
@@ -0,0 +1 @@
# Integration tests
+144
View File
@@ -0,0 +1,144 @@
"""
Fixtures per test di integrazione DB in-memory SQLite + app FastAPI.
Per i test di integrazione si usa SQLite async invece di PostgreSQL per
semplicità e velocità. In CI si può aggiungere un servizio PostgreSQL reale.
"""
import os
import uuid
from typing import AsyncGenerator
import pytest
import pytest_asyncio
from httpx import ASGITransport, AsyncClient
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
# Override variabili d'ambiente prima di importare l'app
os.environ["ENCRYPTION_KEY"] = "b" * 64
os.environ["SECRET_KEY"] = "integration-test-secret-key-only-for-tests"
os.environ["DATABASE_URL"] = "sqlite+aiosqlite:///./test_integration.db"
os.environ["DATABASE_URL_SYNC"] = "sqlite:///./test_integration.db"
os.environ["APP_ENV"] = "development"
os.environ["APP_DEBUG"] = "false"
from app.database import Base
from app.main import app
# Engine SQLite per test
TEST_DATABASE_URL = "sqlite+aiosqlite:///./test_integration.db"
test_engine = create_async_engine(
TEST_DATABASE_URL,
echo=False,
connect_args={"check_same_thread": False},
)
TestAsyncSessionLocal = async_sessionmaker(
bind=test_engine,
class_=AsyncSession,
expire_on_commit=False,
autoflush=False,
)
@pytest_asyncio.fixture(scope="session", autouse=True)
async def setup_database():
"""Crea tutte le tabelle nel DB di test."""
# Import modelli per registrarli nel metadata
import app.models # noqa: F401
async with test_engine.begin() as conn:
# SQLite non supporta tutti i tipi PostgreSQL, usiamo tabelle semplici
await conn.run_sync(Base.metadata.drop_all)
await conn.run_sync(Base.metadata.create_all)
yield
async with test_engine.begin() as conn:
await conn.run_sync(Base.metadata.drop_all)
await test_engine.dispose()
# Pulisci file DB
import os
if os.path.exists("test_integration.db"):
os.remove("test_integration.db")
@pytest_asyncio.fixture
async def db_session() -> AsyncGenerator[AsyncSession, None]:
"""Session DB isolata per ogni test (rollback automatico)."""
async with TestAsyncSessionLocal() as session:
yield session
await session.rollback()
@pytest_asyncio.fixture
async def client(db_session: AsyncSession) -> AsyncGenerator[AsyncClient, None]:
"""HTTP client per test API con override del DB."""
from app.database import get_db
async def override_get_db():
yield db_session
app.dependency_overrides[get_db] = override_get_db
async with AsyncClient(
transport=ASGITransport(app=app),
base_url="http://testserver",
) as c:
yield c
app.dependency_overrides.clear()
@pytest_asyncio.fixture
async def demo_tenant(db_session: AsyncSession):
"""Crea un tenant di test."""
from app.models.tenant import Tenant
tenant = Tenant(
id=uuid.UUID("aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa"),
slug="test-tenant",
name="Test Tenant",
plan="pro",
max_mailboxes=10,
max_users=10,
)
db_session.add(tenant)
await db_session.commit()
await db_session.refresh(tenant)
return tenant
@pytest_asyncio.fixture
async def admin_user(db_session: AsyncSession, demo_tenant):
"""Crea un utente admin nel tenant di test."""
from app.core.security import hash_password
from app.models.user import User
user = User(
tenant_id=demo_tenant.id,
email="admin@test.com",
password_hash=hash_password("AdminPass1!"),
full_name="Test Admin",
role="admin",
is_active=True,
)
db_session.add(user)
await db_session.commit()
await db_session.refresh(user)
return user
@pytest_asyncio.fixture
async def admin_token(client: AsyncClient, admin_user, db_session: AsyncSession) -> str:
"""
Token JWT per l'utente admin.
Nota: il login usa il DB sovrapposto dalla fixture, quindi
il seed_user deve già esistere.
"""
from app.core.security import create_access_token
token = create_access_token(
subject=admin_user.id,
tenant_id=admin_user.tenant_id,
role=admin_user.role,
)
return token
+142
View File
@@ -0,0 +1,142 @@
"""
Test di integrazione per gli endpoint di autenticazione.
"""
import os
import pytest
os.environ.setdefault("ENCRYPTION_KEY", "b" * 64)
os.environ.setdefault("SECRET_KEY", "integration-test-secret-key-only-for-tests")
os.environ.setdefault("DATABASE_URL", "sqlite+aiosqlite:///./test_integration.db")
os.environ.setdefault("DATABASE_URL_SYNC", "sqlite:///./test_integration.db")
class TestLoginEndpoint:
@pytest.mark.asyncio
async def test_login_success(self, client, admin_user):
response = await client.post(
"/api/v1/auth/login",
json={
"email": "admin@test.com",
"password": "AdminPass1!",
},
)
assert response.status_code == 200
data = response.json()
assert "access_token" in data
assert "refresh_token" in data
assert data["token_type"] == "bearer"
assert data["expires_in"] > 0
@pytest.mark.asyncio
async def test_login_wrong_password_returns_401(self, client, admin_user):
response = await client.post(
"/api/v1/auth/login",
json={
"email": "admin@test.com",
"password": "WrongPassword1!",
},
)
assert response.status_code == 401
@pytest.mark.asyncio
async def test_login_nonexistent_user_returns_401(self, client):
response = await client.post(
"/api/v1/auth/login",
json={
"email": "nobody@example.com",
"password": "Password1!",
},
)
assert response.status_code == 401
@pytest.mark.asyncio
async def test_login_missing_fields_returns_422(self, client):
response = await client.post(
"/api/v1/auth/login",
json={"email": "test@test.com"}, # manca password
)
assert response.status_code == 422
@pytest.mark.asyncio
async def test_login_invalid_email_returns_422(self, client):
response = await client.post(
"/api/v1/auth/login",
json={"email": "not-an-email", "password": "Password1!"},
)
assert response.status_code == 422
class TestMeEndpoint:
@pytest.mark.asyncio
async def test_me_returns_current_user(self, client, admin_token, admin_user):
response = await client.get(
"/api/v1/auth/me",
headers={"Authorization": f"Bearer {admin_token}"},
)
assert response.status_code == 200
data = response.json()
assert data["email"] == "admin@test.com"
assert data["role"] == "admin"
@pytest.mark.asyncio
async def test_me_without_token_returns_403(self, client):
response = await client.get("/api/v1/auth/me")
assert response.status_code == 403
@pytest.mark.asyncio
async def test_me_with_invalid_token_returns_401(self, client):
response = await client.get(
"/api/v1/auth/me",
headers={"Authorization": "Bearer invalid.token.here"},
)
assert response.status_code == 401
class TestHealthEndpoint:
@pytest.mark.asyncio
async def test_health_returns_ok(self, client):
response = await client.get("/health")
assert response.status_code == 200
data = response.json()
assert data["status"] == "ok"
class TestRefreshEndpoint:
@pytest.mark.asyncio
async def test_refresh_with_invalid_token_returns_401(self, client):
response = await client.post(
"/api/v1/auth/refresh",
json={"refresh_token": "invalid.token.here"},
)
assert response.status_code == 401
class TestTOTPEndpoints:
@pytest.mark.asyncio
async def test_totp_setup_returns_qr(self, client, admin_token):
response = await client.post(
"/api/v1/auth/totp/setup",
headers={"Authorization": f"Bearer {admin_token}"},
)
assert response.status_code == 200
data = response.json()
assert "secret" in data
assert "qr_uri" in data
assert "qr_image_base64" in data
assert data["qr_uri"].startswith("otpauth://totp/")
@pytest.mark.asyncio
async def test_totp_verify_wrong_code_returns_400(self, client, admin_token):
# Prima setup
await client.post(
"/api/v1/auth/totp/setup",
headers={"Authorization": f"Bearer {admin_token}"},
)
# Poi verify con codice errato
response = await client.post(
"/api/v1/auth/totp/verify",
headers={"Authorization": f"Bearer {admin_token}"},
json={"totp_code": "000000"},
)
assert response.status_code == 400
+131
View File
@@ -0,0 +1,131 @@
"""
Test di integrazione per gli endpoint utenti.
"""
import os
import pytest
os.environ.setdefault("ENCRYPTION_KEY", "b" * 64)
os.environ.setdefault("SECRET_KEY", "integration-test-secret-key-only-for-tests")
class TestUsersEndpoint:
@pytest.mark.asyncio
async def test_list_users_admin(self, client, admin_token):
response = await client.get(
"/api/v1/users",
headers={"Authorization": f"Bearer {admin_token}"},
)
assert response.status_code == 200
data = response.json()
assert "items" in data
assert "total" in data
assert data["total"] >= 1 # almeno l'admin stesso
@pytest.mark.asyncio
async def test_list_users_no_auth_returns_403(self, client):
response = await client.get("/api/v1/users")
assert response.status_code == 403
@pytest.mark.asyncio
async def test_create_user_success(self, client, admin_token):
response = await client.post(
"/api/v1/users",
headers={"Authorization": f"Bearer {admin_token}"},
json={
"email": "newuser@test.com",
"password": "NewUser1!",
"full_name": "Nuovo Utente",
"role": "operator",
},
)
assert response.status_code == 201
data = response.json()
assert data["email"] == "newuser@test.com"
assert data["role"] == "operator"
assert "password_hash" not in data # non deve esporre hash
@pytest.mark.asyncio
async def test_create_user_duplicate_email_returns_409(self, client, admin_token):
# Crea primo utente
await client.post(
"/api/v1/users",
headers={"Authorization": f"Bearer {admin_token}"},
json={
"email": "duplicate@test.com",
"password": "DupUser1!",
"full_name": "Dup User",
"role": "operator",
},
)
# Secondo tentativo con stessa email
response = await client.post(
"/api/v1/users",
headers={"Authorization": f"Bearer {admin_token}"},
json={
"email": "duplicate@test.com",
"password": "DupUser1!",
"full_name": "Dup User 2",
"role": "operator",
},
)
assert response.status_code == 409
@pytest.mark.asyncio
async def test_create_superadmin_forbidden(self, client, admin_token):
response = await client.post(
"/api/v1/users",
headers={"Authorization": f"Bearer {admin_token}"},
json={
"email": "sadmin@test.com",
"password": "SuperAdmin1!",
"full_name": "Super",
"role": "super_admin",
},
)
# Il validator Pydantic blocca la creazione di super_admin
assert response.status_code in (400, 422)
@pytest.mark.asyncio
async def test_create_user_weak_password_returns_422(self, client, admin_token):
response = await client.post(
"/api/v1/users",
headers={"Authorization": f"Bearer {admin_token}"},
json={
"email": "weakpwd@test.com",
"password": "weak", # troppo corta e senza maiuscole/numeri
"full_name": "Weak Pwd User",
"role": "operator",
},
)
assert response.status_code == 422
@pytest.mark.asyncio
async def test_get_user_by_id(self, client, admin_token, admin_user):
response = await client.get(
f"/api/v1/users/{admin_user.id}",
headers={"Authorization": f"Bearer {admin_token}"},
)
assert response.status_code == 200
data = response.json()
assert data["id"] == str(admin_user.id)
@pytest.mark.asyncio
async def test_get_nonexistent_user_returns_404(self, client, admin_token):
import uuid
fake_id = uuid.uuid4()
response = await client.get(
f"/api/v1/users/{fake_id}",
headers={"Authorization": f"Bearer {admin_token}"},
)
assert response.status_code == 404
@pytest.mark.asyncio
async def test_update_user(self, client, admin_token, admin_user):
response = await client.patch(
f"/api/v1/users/{admin_user.id}",
headers={"Authorization": f"Bearer {admin_token}"},
json={"full_name": "Admin Aggiornato"},
)
assert response.status_code == 200
assert response.json()["full_name"] == "Admin Aggiornato"
+1
View File
@@ -0,0 +1 @@
# Unit tests
+149
View File
@@ -0,0 +1,149 @@
"""
Test unitari per AuthService (mock del DB).
"""
import os
import uuid
from datetime import UTC, datetime, timedelta
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
os.environ.setdefault("ENCRYPTION_KEY", "a" * 64)
os.environ.setdefault("SECRET_KEY", "test-secret-key-only")
os.environ.setdefault("DATABASE_URL", "postgresql+asyncpg://test:test@localhost:5432/test")
os.environ.setdefault("DATABASE_URL_SYNC", "postgresql://test:test@localhost:5432/test")
from app.core.exceptions import (
AccountDisabledError,
AccountLockedError,
InvalidCredentialsError,
TOTPRequiredError,
)
from app.core.security import hash_password
from app.models.user import User
def make_user(**kwargs) -> User:
"""Factory per creare un utente mock."""
user = MagicMock(spec=User)
user.id = kwargs.get("id", uuid.uuid4())
user.tenant_id = kwargs.get("tenant_id", uuid.uuid4())
user.email = kwargs.get("email", "test@example.com")
user.password_hash = kwargs.get("password_hash", hash_password("Password1!"))
user.role = kwargs.get("role", "operator")
user.is_active = kwargs.get("is_active", True)
user.totp_enabled = kwargs.get("totp_enabled", False)
user.totp_secret = kwargs.get("totp_secret", None)
user.failed_login_count = kwargs.get("failed_login_count", 0)
user.locked_until = kwargs.get("locked_until", None)
return user
class TestAuthServiceLogin:
@pytest.fixture
def mock_db(self):
db = AsyncMock()
db.add = MagicMock()
db.execute = AsyncMock()
db.flush = AsyncMock()
return db
@pytest.mark.asyncio
async def test_login_with_correct_credentials(self, mock_db):
from app.services.auth_service import AuthService
user = make_user(password_hash=hash_password("Password1!"))
# Mock query utente
mock_result = MagicMock()
mock_result.scalar_one_or_none.return_value = user
mock_db.execute.return_value = mock_result
service = AuthService(mock_db)
with patch.object(service, "_handle_failed_login", new_callable=AsyncMock):
with patch.object(service, "_reset_failed_login", new_callable=AsyncMock):
with patch.object(service, "_log_audit", new_callable=AsyncMock):
access, refresh = await service.login(
email="test@example.com",
password="Password1!",
totp_code=None,
)
assert access is not None
assert refresh is not None
@pytest.mark.asyncio
async def test_login_user_not_found_raises(self, mock_db):
from app.services.auth_service import AuthService
mock_result = MagicMock()
mock_result.scalar_one_or_none.return_value = None
mock_db.execute.return_value = mock_result
service = AuthService(mock_db)
with patch.object(service, "_log_audit", new_callable=AsyncMock):
with pytest.raises(InvalidCredentialsError):
await service.login("notfound@example.com", "Password1!", None)
@pytest.mark.asyncio
async def test_login_inactive_user_raises(self, mock_db):
from app.services.auth_service import AuthService
user = make_user(is_active=False)
mock_result = MagicMock()
mock_result.scalar_one_or_none.return_value = user
mock_db.execute.return_value = mock_result
service = AuthService(mock_db)
with pytest.raises(AccountDisabledError):
await service.login("test@example.com", "Password1!", None)
@pytest.mark.asyncio
async def test_login_locked_account_raises(self, mock_db):
from app.services.auth_service import AuthService
user = make_user(
locked_until=datetime.now(UTC) + timedelta(minutes=10)
)
mock_result = MagicMock()
mock_result.scalar_one_or_none.return_value = user
mock_db.execute.return_value = mock_result
service = AuthService(mock_db)
with pytest.raises(AccountLockedError):
await service.login("test@example.com", "Password1!", None)
@pytest.mark.asyncio
async def test_login_wrong_password_raises(self, mock_db):
from app.services.auth_service import AuthService
user = make_user(password_hash=hash_password("CorrectPassword1!"))
mock_result = MagicMock()
mock_result.scalar_one_or_none.return_value = user
mock_db.execute.return_value = mock_result
service = AuthService(mock_db)
with patch.object(service, "_handle_failed_login", new_callable=AsyncMock):
with patch.object(service, "_log_audit", new_callable=AsyncMock):
with pytest.raises(InvalidCredentialsError):
await service.login("test@example.com", "WrongPassword1!", None)
@pytest.mark.asyncio
async def test_login_totp_required_when_enabled(self, mock_db):
from app.services.auth_service import AuthService
user = make_user(
password_hash=hash_password("Password1!"),
totp_enabled=True,
)
mock_result = MagicMock()
mock_result.scalar_one_or_none.return_value = user
mock_db.execute.return_value = mock_result
service = AuthService(mock_db)
with patch.object(service, "_reset_failed_login", new_callable=AsyncMock):
with patch.object(service, "_log_audit", new_callable=AsyncMock):
with pytest.raises(TOTPRequiredError):
await service.login("test@example.com", "Password1!", totp_code=None)
+143
View File
@@ -0,0 +1,143 @@
"""
Test unitari per app.core.security.
"""
import os
import pytest
# Override variabili d'ambiente per i test (prima di importare app)
os.environ["ENCRYPTION_KEY"] = "a" * 64
os.environ["SECRET_KEY"] = "test-secret-key-for-unit-tests-only"
os.environ["DATABASE_URL"] = "postgresql+asyncpg://test:test@localhost:5432/test"
os.environ["DATABASE_URL_SYNC"] = "postgresql://test:test@localhost:5432/test"
from app.core.security import (
hash_password,
verify_password,
create_access_token,
create_refresh_token,
decode_token,
encrypt_credential,
decrypt_credential,
hash_token,
)
class TestPasswordHashing:
def test_hash_password_returns_bcrypt_hash(self):
hashed = hash_password("MySecurePassword1!")
assert hashed.startswith("$2b$")
assert len(hashed) > 20
def test_verify_correct_password(self):
password = "MySecurePassword1!"
hashed = hash_password(password)
assert verify_password(password, hashed) is True
def test_verify_wrong_password(self):
hashed = hash_password("CorrectPassword1!")
assert verify_password("WrongPassword1!", hashed) is False
def test_hash_is_different_each_time(self):
"""Bcrypt usa salt casuale: due hash dello stesso secret sono diversi."""
p = "SamePassword1!"
h1 = hash_password(p)
h2 = hash_password(p)
assert h1 != h2
# Ma entrambi verificano correttamente
assert verify_password(p, h1)
assert verify_password(p, h2)
class TestJWT:
def test_create_and_decode_access_token(self):
import uuid
user_id = uuid.uuid4()
tenant_id = uuid.uuid4()
token = create_access_token(
subject=user_id,
tenant_id=tenant_id,
role="admin",
)
payload = decode_token(token)
assert payload["sub"] == str(user_id)
assert payload["tid"] == str(tenant_id)
assert payload["role"] == "admin"
assert payload["type"] == "access"
def test_create_and_decode_refresh_token(self):
import uuid
user_id = uuid.uuid4()
tenant_id = uuid.uuid4()
token = create_refresh_token(subject=user_id, tenant_id=tenant_id)
payload = decode_token(token)
assert payload["sub"] == str(user_id)
assert payload["type"] == "refresh"
def test_expired_token_raises(self):
from datetime import UTC, datetime, timedelta
from jose import jwt
from app.config import get_settings
from jose import JWTError
settings = get_settings()
payload = {
"sub": "user-id",
"tid": "tenant-id",
"type": "access",
"exp": datetime.now(UTC) - timedelta(seconds=1), # già scaduto
}
token = jwt.encode(payload, settings.secret_key, algorithm=settings.algorithm)
with pytest.raises(JWTError):
decode_token(token)
def test_invalid_signature_raises(self):
from jose import JWTError
with pytest.raises(JWTError):
decode_token("this.is.not.a.valid.token")
class TestAESEncryption:
def test_encrypt_decrypt_roundtrip(self):
secret = "imap_password_super_secret_123!"
encrypted = encrypt_credential(secret)
decrypted = decrypt_credential(encrypted)
assert decrypted == secret
def test_encrypt_produces_different_output_each_time(self):
"""Nonce casuale garantisce che due cifrature dello stesso plaintext siano diverse."""
secret = "same_secret"
enc1 = encrypt_credential(secret)
enc2 = encrypt_credential(secret)
assert enc1 != enc2
# Ma entrambi decifrano correttamente
assert decrypt_credential(enc1) == secret
assert decrypt_credential(enc2) == secret
def test_decrypt_with_wrong_data_raises(self):
with pytest.raises(ValueError):
decrypt_credential("dGhpcyBpcyBub3QgdmFsaWQgYWVzIGRhdGE=")
def test_encrypt_empty_string(self):
encrypted = encrypt_credential("")
decrypted = decrypt_credential(encrypted)
assert decrypted == ""
def test_encrypt_unicode_string(self):
secret = "pàssword_con_àccenti_è_ù!"
encrypted = encrypt_credential(secret)
decrypted = decrypt_credential(encrypted)
assert decrypted == secret
class TestHashToken:
def test_hash_is_deterministic(self):
token = "my_refresh_token"
assert hash_token(token) == hash_token(token)
def test_different_tokens_different_hashes(self):
assert hash_token("token1") != hash_token("token2")
def test_hash_is_64_chars(self):
"""SHA-256 produce 32 bytes = 64 caratteri hex."""
assert len(hash_token("any_token")) == 64