mirror of
https://github.com/idrainformatica/PecFlow.git
synced 2026-06-16 12:45:42 +02:00
feat: Fase 1 – Fondamenta complete (backend FastAPI + auth + permessi)
- docker-compose.yml: PostgreSQL 16, Redis 7, MinIO, Nginx - backend FastAPI: struttura monorepo, config pydantic-settings - modelli SQLAlchemy: tutti i modelli (tenants, users, mailboxes, messages, archival, permissions, labels, audit_log) - migrazione Alembic 0001: schema completo in pure SQL - auth API: login JWT, refresh token rotation, logout, 2FA TOTP (setup/verify/disable) - CRUD utenti: lista, crea, modifica, reset password, soft delete - permessi granulari (Fase 1-A): mailbox_permissions, assegna/revoca/lista - CRUD tenant: gestione super-admin - sicurezza: AES-256-GCM cifratura credenziali IMAP/SMTP, bcrypt password - RLS PostgreSQL: isolamento multi-tenant per request - seed sviluppo: tenant demo + admin + operator - test unit: security (bcrypt, JWT, AES), auth_service - test integration: auth endpoints, users endpoints - CI GitHub Actions: lint (ruff), test (pytest), build Docker, security scan - infra: nginx.conf, redis.conf - Makefile con comandi make dev/test/migrate/seed Definition of Done: ✅ Login, refresh token e TOTP funzionanti ✅ make dev porta in piedi tutto lo stack locale ✅ CI configurata
This commit is contained in:
@@ -0,0 +1,61 @@
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# PecFlow – Variabili d'ambiente
|
||||
# Copia questo file in .env e personalizza i valori
|
||||
# NON committare mai il file .env con valori reali
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
# ── Applicazione ─────────────────────────────────────────────────────────────
|
||||
APP_ENV=development # development | staging | production
|
||||
APP_DEBUG=true
|
||||
APP_HOST=0.0.0.0
|
||||
APP_PORT=8000
|
||||
APP_BASE_URL=http://localhost:8000
|
||||
|
||||
# ── Sicurezza ─────────────────────────────────────────────────────────────────
|
||||
# Genera con: python -c "import secrets; print(secrets.token_hex(32))"
|
||||
SECRET_KEY=change-me-generate-a-random-64-char-hex-string-here-00000000000000
|
||||
ALGORITHM=HS256
|
||||
ACCESS_TOKEN_EXPIRE_MINUTES=15
|
||||
REFRESH_TOKEN_EXPIRE_DAYS=30
|
||||
|
||||
# Chiave AES-256-GCM per cifratura credenziali IMAP/SMTP (32 bytes = 64 hex chars)
|
||||
# Genera con: python -c "import secrets; print(secrets.token_hex(32))"
|
||||
ENCRYPTION_KEY=change-me-generate-a-random-64-char-hex-string-here-11111111111
|
||||
|
||||
# ── Database PostgreSQL ───────────────────────────────────────────────────────
|
||||
POSTGRES_HOST=db
|
||||
POSTGRES_PORT=5432
|
||||
POSTGRES_DB=pecflow
|
||||
POSTGRES_USER=pecflow
|
||||
POSTGRES_PASSWORD=pecflow_dev_password
|
||||
|
||||
DATABASE_URL=postgresql+asyncpg://pecflow:pecflow_dev_password@db:5432/pecflow
|
||||
DATABASE_URL_SYNC=postgresql://pecflow:pecflow_dev_password@db:5432/pecflow
|
||||
|
||||
# ── Redis ─────────────────────────────────────────────────────────────────────
|
||||
REDIS_URL=redis://redis:6379/0
|
||||
|
||||
# ── MinIO (Object Storage) ────────────────────────────────────────────────────
|
||||
MINIO_ENDPOINT=minio:9000
|
||||
MINIO_ACCESS_KEY=minioadmin
|
||||
MINIO_SECRET_KEY=minioadmin
|
||||
MINIO_BUCKET=pecflow
|
||||
MINIO_USE_SSL=false
|
||||
|
||||
# ── CORS ──────────────────────────────────────────────────────────────────────
|
||||
CORS_ORIGINS=http://localhost:3000,http://localhost:5173
|
||||
|
||||
# ── Rate Limiting ─────────────────────────────────────────────────────────────
|
||||
RATE_LIMIT_AUTH=10/minute # max 10 tentativi di login al minuto per IP
|
||||
RATE_LIMIT_DEFAULT=100/minute
|
||||
|
||||
# ── Logging ───────────────────────────────────────────────────────────────────
|
||||
LOG_LEVEL=INFO # DEBUG | INFO | WARNING | ERROR | CRITICAL
|
||||
LOG_JSON=false # true in produzione per log strutturati JSON
|
||||
|
||||
# ── Email SMTP (per notifiche di sistema, NON caselle PEC) ───────────────────
|
||||
SYSTEM_SMTP_HOST=
|
||||
SYSTEM_SMTP_PORT=587
|
||||
SYSTEM_SMTP_USER=
|
||||
SYSTEM_SMTP_PASSWORD=
|
||||
SYSTEM_SMTP_FROM=noreply@pecflow.it
|
||||
@@ -0,0 +1,188 @@
|
||||
name: CI – Lint, Test, Build
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main, develop]
|
||||
pull_request:
|
||||
branches: [main, develop]
|
||||
|
||||
env:
|
||||
PYTHON_VERSION: "3.12"
|
||||
ENCRYPTION_KEY: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"
|
||||
SECRET_KEY: "ci-test-secret-key-for-github-actions-only-not-for-production"
|
||||
DATABASE_URL: "postgresql+asyncpg://pecflow:pecflow_ci@localhost:5432/pecflow_test"
|
||||
DATABASE_URL_SYNC: "postgresql://pecflow:pecflow_ci@localhost:5432/pecflow_test"
|
||||
REDIS_URL: "redis://localhost:6379/0"
|
||||
|
||||
jobs:
|
||||
# ── Lint Backend ─────────────────────────────────────────────────────────────
|
||||
lint-backend:
|
||||
name: Lint Backend (ruff + mypy)
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Python ${{ env.PYTHON_VERSION }}
|
||||
uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: ${{ env.PYTHON_VERSION }}
|
||||
cache: pip
|
||||
|
||||
- name: Install dependencies
|
||||
working-directory: backend
|
||||
run: |
|
||||
pip install --upgrade pip
|
||||
pip install -e ".[dev]"
|
||||
|
||||
- name: Run ruff (lint)
|
||||
working-directory: backend
|
||||
run: ruff check app tests --output-format=github
|
||||
|
||||
- name: Run ruff (format check)
|
||||
working-directory: backend
|
||||
run: ruff format --check app tests
|
||||
|
||||
- name: Run mypy (type check)
|
||||
working-directory: backend
|
||||
env:
|
||||
ENCRYPTION_KEY: ${{ env.ENCRYPTION_KEY }}
|
||||
SECRET_KEY: ${{ env.SECRET_KEY }}
|
||||
DATABASE_URL: ${{ env.DATABASE_URL }}
|
||||
DATABASE_URL_SYNC: ${{ env.DATABASE_URL_SYNC }}
|
||||
run: mypy app --ignore-missing-imports --no-strict-optional
|
||||
|
||||
# ── Test Backend ─────────────────────────────────────────────────────────────
|
||||
test-backend:
|
||||
name: Test Backend (pytest)
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
services:
|
||||
postgres:
|
||||
image: postgres:16-alpine
|
||||
env:
|
||||
POSTGRES_DB: pecflow_test
|
||||
POSTGRES_USER: pecflow
|
||||
POSTGRES_PASSWORD: pecflow_ci
|
||||
ports:
|
||||
- 5432:5432
|
||||
options: >-
|
||||
--health-cmd pg_isready
|
||||
--health-interval 5s
|
||||
--health-timeout 5s
|
||||
--health-retries 10
|
||||
|
||||
redis:
|
||||
image: redis:7-alpine
|
||||
ports:
|
||||
- 6379:6379
|
||||
options: >-
|
||||
--health-cmd "redis-cli ping"
|
||||
--health-interval 5s
|
||||
--health-timeout 3s
|
||||
--health-retries 10
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Python ${{ env.PYTHON_VERSION }}
|
||||
uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: ${{ env.PYTHON_VERSION }}
|
||||
cache: pip
|
||||
|
||||
- name: Install dependencies
|
||||
working-directory: backend
|
||||
run: |
|
||||
pip install --upgrade pip
|
||||
pip install -e ".[dev]"
|
||||
pip install aiosqlite # per test integration con SQLite
|
||||
|
||||
- name: Run unit tests
|
||||
working-directory: backend
|
||||
env:
|
||||
ENCRYPTION_KEY: ${{ env.ENCRYPTION_KEY }}
|
||||
SECRET_KEY: ${{ env.SECRET_KEY }}
|
||||
DATABASE_URL: ${{ env.DATABASE_URL }}
|
||||
DATABASE_URL_SYNC: ${{ env.DATABASE_URL_SYNC }}
|
||||
run: pytest tests/unit -v --tb=short
|
||||
|
||||
- name: Run integration tests
|
||||
working-directory: backend
|
||||
env:
|
||||
ENCRYPTION_KEY: ${{ env.ENCRYPTION_KEY }}
|
||||
SECRET_KEY: ${{ env.SECRET_KEY }}
|
||||
DATABASE_URL: sqlite+aiosqlite:///./test_ci.db
|
||||
DATABASE_URL_SYNC: sqlite:///./test_ci.db
|
||||
run: pytest tests/integration -v --tb=short
|
||||
|
||||
- name: Run all tests with coverage
|
||||
working-directory: backend
|
||||
env:
|
||||
ENCRYPTION_KEY: ${{ env.ENCRYPTION_KEY }}
|
||||
SECRET_KEY: ${{ env.SECRET_KEY }}
|
||||
DATABASE_URL: sqlite+aiosqlite:///./test_ci_cov.db
|
||||
DATABASE_URL_SYNC: sqlite:///./test_ci_cov.db
|
||||
run: |
|
||||
pytest tests/ \
|
||||
--cov=app \
|
||||
--cov-report=xml \
|
||||
--cov-report=term-missing \
|
||||
-v --tb=short
|
||||
continue-on-error: true # non blocca la CI se coverage < target
|
||||
|
||||
- name: Upload coverage to GitHub
|
||||
uses: codecov/codecov-action@v4
|
||||
with:
|
||||
file: backend/coverage.xml
|
||||
flags: backend
|
||||
continue-on-error: true
|
||||
|
||||
# ── Build Docker ─────────────────────────────────────────────────────────────
|
||||
build-docker:
|
||||
name: Build Docker Image
|
||||
runs-on: ubuntu-latest
|
||||
needs: [lint-backend, test-backend]
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
- name: Build backend image
|
||||
uses: docker/build-push-action@v5
|
||||
with:
|
||||
context: ./backend
|
||||
file: ./backend/Dockerfile
|
||||
push: false
|
||||
tags: pecflow-backend:${{ github.sha }}
|
||||
cache-from: type=gha
|
||||
cache-to: type=gha,mode=max
|
||||
|
||||
# ── Security Scan ─────────────────────────────────────────────────────────────
|
||||
security:
|
||||
name: Security Scan
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Python
|
||||
uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: ${{ env.PYTHON_VERSION }}
|
||||
|
||||
- name: Install pip-audit
|
||||
run: pip install pip-audit
|
||||
|
||||
- name: Audit Python dependencies
|
||||
working-directory: backend
|
||||
run: pip-audit -r <(pip install -e ".[dev]" --dry-run 2>/dev/null || echo "") || true
|
||||
continue-on-error: true
|
||||
|
||||
- name: Scan for secrets (Gitleaks)
|
||||
uses: gitleaks/gitleaks-action@v2
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
continue-on-error: true
|
||||
+1749
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,43 @@
|
||||
Stiamo lavorando a un PEC Manager SaaS
|
||||
|
||||
Effettua tutti i test in locale
|
||||
|
||||
Ho docker installato, compose v2 (docker cmpose senza trattino)
|
||||
|
||||
Queste le caselle PEC e i loro parametri IMAP/SMTP che puoi usare per test, non effettuare invii per adesso
|
||||
|
||||
Casella: gmgspa@pec.it
|
||||
Password: Gmgbaccio1960!26
|
||||
Server IMAP: imaps.pec.mail-certificata.eu
|
||||
Porta:993
|
||||
SSL: Sì
|
||||
Server SMTP: smtps.pec.mail-certificata.eu
|
||||
Porta: 465
|
||||
SSL: Sì
|
||||
|
||||
Casella: akron@pec.akronservices.it
|
||||
Password: Akron@PEC2026!
|
||||
Server IMAP: imap.pec-email.com
|
||||
Porta:993
|
||||
SSL: Sì
|
||||
Server SMTP: smtp.pec-email.com
|
||||
Porta: 465
|
||||
SSL: Sì
|
||||
|
||||
Casella: birindelliauto@pec.it
|
||||
Password: PECBirindelli2026@
|
||||
Server IMAP: imaps.pec.mail-certificata.eu
|
||||
Porta:993
|
||||
SSL: Sì
|
||||
Server SMTP: smtps.pec.mail-certificata.eu
|
||||
Porta: 465
|
||||
SSL: Sì
|
||||
|
||||
Casella: quattrocarsrl@pec.it
|
||||
Password: 4Car2026GMG!
|
||||
Server IMAP: imaps.pec.mail-certificata.eu
|
||||
Porta:993
|
||||
SSL: Sì
|
||||
Server SMTP: smtps.pec.mail-certificata.eu
|
||||
Porta: 465
|
||||
SSL: Sì
|
||||
@@ -0,0 +1,106 @@
|
||||
## PecFlow – Developer Commands
|
||||
|
||||
.PHONY: dev down build test migrate seed lint format clean logs ps help
|
||||
|
||||
# Variabili
|
||||
COMPOSE = docker compose
|
||||
BACKEND = $(COMPOSE) exec backend
|
||||
PYTEST = $(BACKEND) python -m pytest
|
||||
|
||||
help: ## Mostra questo help
|
||||
@grep -E '^[a-zA-Z_-]+:.*?## .*$$' $(MAKEFILE_LIST) | \
|
||||
awk 'BEGIN {FS = ":.*?## "}; {printf " \033[36m%-20s\033[0m %s\n", $$1, $$2}'
|
||||
|
||||
# ─── Stack locale ────────────────────────────────────────────────────────────
|
||||
|
||||
dev: ## Avvia l'intero stack in background
|
||||
$(COMPOSE) up -d --build
|
||||
@echo ""
|
||||
@echo " ✅ Stack avviato:"
|
||||
@echo " 📡 API: http://localhost:8000"
|
||||
@echo " 📖 Docs: http://localhost:8000/docs"
|
||||
@echo " 🗄️ MinIO: http://localhost:9001 (admin/password)"
|
||||
@echo " 📊 PgAdmin: http://localhost:5050 (admin@pecflow.it / admin)"
|
||||
@echo ""
|
||||
|
||||
down: ## Ferma e rimuove i container (preserva i volumi)
|
||||
$(COMPOSE) down
|
||||
|
||||
down-v: ## Ferma e rimuove TUTTO inclusi i volumi (reset completo)
|
||||
$(COMPOSE) down -v
|
||||
|
||||
build: ## Rebuilda le immagini senza usare la cache
|
||||
$(COMPOSE) build --no-cache
|
||||
|
||||
logs: ## Segui i log di tutti i servizi
|
||||
$(COMPOSE) logs -f
|
||||
|
||||
logs-backend: ## Segui i log del backend
|
||||
$(COMPOSE) logs -f backend
|
||||
|
||||
ps: ## Stato dei container
|
||||
$(COMPOSE) ps
|
||||
|
||||
# ─── Database ────────────────────────────────────────────────────────────────
|
||||
|
||||
migrate: ## Esegui le migrazioni Alembic pendenti
|
||||
$(BACKEND) alembic upgrade head
|
||||
|
||||
migrate-down: ## Rollback dell'ultima migrazione
|
||||
$(BACKEND) alembic downgrade -1
|
||||
|
||||
migrate-status: ## Stato migrazioni
|
||||
$(BACKEND) alembic current
|
||||
|
||||
makemigration: ## Genera una nuova migrazione (usa: make makemigration MSG="descrizione")
|
||||
$(BACKEND) alembic revision --autogenerate -m "$(MSG)"
|
||||
|
||||
seed: ## Esegui seed dati di sviluppo (tenant demo + admin)
|
||||
$(COMPOSE) exec db psql -U pecflow -d pecflow -f /docker-entrypoint-initdb.d/seeds/dev_tenant.sql
|
||||
@echo " ✅ Seed completato"
|
||||
|
||||
reset-db: ## Reset completo DB (down-v + dev + migrate + seed)
|
||||
$(MAKE) down-v
|
||||
$(MAKE) dev
|
||||
@sleep 5
|
||||
$(MAKE) migrate
|
||||
$(MAKE) seed
|
||||
|
||||
# ─── Test ────────────────────────────────────────────────────────────────────
|
||||
|
||||
test: ## Esegui tutti i test (unit + integration)
|
||||
$(PYTEST) -v --tb=short
|
||||
|
||||
test-unit: ## Solo unit test
|
||||
$(PYTEST) backend/tests/unit -v
|
||||
|
||||
test-integration: ## Solo integration test
|
||||
$(PYTEST) backend/tests/integration -v
|
||||
|
||||
test-cov: ## Test con coverage report
|
||||
$(PYTEST) --cov=app --cov-report=term-missing --cov-report=html:/app/htmlcov -v
|
||||
|
||||
# ─── Code quality ─────────────────────────────────────────────────────────────
|
||||
|
||||
lint: ## Esegui linting (ruff + mypy)
|
||||
$(BACKEND) ruff check app tests
|
||||
$(BACKEND) mypy app --ignore-missing-imports
|
||||
|
||||
format: ## Formatta il codice con ruff
|
||||
$(BACKEND) ruff format app tests
|
||||
$(BACKEND) ruff check --fix app tests
|
||||
|
||||
# ─── Utility ─────────────────────────────────────────────────────────────────
|
||||
|
||||
shell-backend: ## Shell nel container backend
|
||||
$(BACKEND) bash
|
||||
|
||||
shell-db: ## psql nel container database
|
||||
$(COMPOSE) exec db psql -U pecflow -d pecflow
|
||||
|
||||
clean: ## Rimuovi file temporanei Python
|
||||
find . -type d -name __pycache__ -exec rm -rf {} + 2>/dev/null || true
|
||||
find . -name "*.pyc" -delete 2>/dev/null || true
|
||||
find . -name ".pytest_cache" -exec rm -rf {} + 2>/dev/null || true
|
||||
find . -name ".mypy_cache" -exec rm -rf {} + 2>/dev/null || true
|
||||
find . -name "htmlcov" -exec rm -rf {} + 2>/dev/null || true
|
||||
@@ -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"]
|
||||
@@ -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
|
||||
@@ -0,0 +1 @@
|
||||
# Alembic migrations
|
||||
@@ -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}")
|
||||
@@ -0,0 +1 @@
|
||||
# PecFlow Backend
|
||||
@@ -0,0 +1 @@
|
||||
# API routers
|
||||
@@ -0,0 +1 @@
|
||||
# API v1 routers
|
||||
@@ -0,0 +1,173 @@
|
||||
"""
|
||||
Router autenticazione – login, refresh, logout, 2FA TOTP.
|
||||
|
||||
Endpoint:
|
||||
POST /api/v1/auth/login → access + refresh token
|
||||
POST /api/v1/auth/refresh → rinnova token
|
||||
POST /api/v1/auth/logout → revoca refresh token
|
||||
GET /api/v1/auth/me → utente corrente
|
||||
POST /api/v1/auth/totp/setup → genera segreto TOTP + QR
|
||||
POST /api/v1/auth/totp/verify → verifica e attiva TOTP
|
||||
POST /api/v1/auth/totp/disable → disabilita TOTP
|
||||
POST /api/v1/auth/change-password → cambio password
|
||||
"""
|
||||
|
||||
from typing import Annotated
|
||||
|
||||
from fastapi import APIRouter, Depends, Request
|
||||
from slowapi import Limiter
|
||||
from slowapi.util import get_remote_address
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.config import get_settings
|
||||
from app.core.exceptions import InvalidCredentialsError
|
||||
from app.database import get_db
|
||||
from app.dependencies import CurrentUser, DB
|
||||
from app.schemas.auth import (
|
||||
LoginRequest,
|
||||
PasswordChangeRequest,
|
||||
RefreshRequest,
|
||||
TOTPSetupResponse,
|
||||
TOTPStatusResponse,
|
||||
TOTPVerifyRequest,
|
||||
TokenResponse,
|
||||
)
|
||||
from app.schemas.user import UserResponse
|
||||
from app.services.auth_service import AuthService
|
||||
|
||||
settings = get_settings()
|
||||
router = APIRouter(prefix="/auth", tags=["Autenticazione"])
|
||||
limiter = Limiter(key_func=get_remote_address)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/login",
|
||||
response_model=TokenResponse,
|
||||
summary="Login con email e password",
|
||||
description="Autentica l'utente. Se 2FA è attivo, richiede anche il codice TOTP.",
|
||||
)
|
||||
async def login(
|
||||
request: Request,
|
||||
body: LoginRequest,
|
||||
db: DB,
|
||||
) -> TokenResponse:
|
||||
ip = request.client.host if request.client else None
|
||||
ua = request.headers.get("user-agent")
|
||||
|
||||
service = AuthService(db)
|
||||
access_token, refresh_token = await service.login(
|
||||
email=body.email,
|
||||
password=body.password,
|
||||
totp_code=body.totp_code,
|
||||
ip_address=ip,
|
||||
user_agent=ua,
|
||||
)
|
||||
|
||||
return TokenResponse(
|
||||
access_token=access_token,
|
||||
refresh_token=refresh_token,
|
||||
expires_in=settings.access_token_expire_minutes * 60,
|
||||
)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/refresh",
|
||||
response_model=TokenResponse,
|
||||
summary="Rinnova access token",
|
||||
)
|
||||
async def refresh_tokens(
|
||||
body: RefreshRequest,
|
||||
db: DB,
|
||||
) -> TokenResponse:
|
||||
service = AuthService(db)
|
||||
access_token, refresh_token = await service.refresh_tokens(body.refresh_token)
|
||||
|
||||
return TokenResponse(
|
||||
access_token=access_token,
|
||||
refresh_token=refresh_token,
|
||||
expires_in=settings.access_token_expire_minutes * 60,
|
||||
)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/logout",
|
||||
status_code=204,
|
||||
summary="Logout – revoca refresh token",
|
||||
)
|
||||
async def logout(
|
||||
body: RefreshRequest,
|
||||
db: DB,
|
||||
) -> None:
|
||||
service = AuthService(db)
|
||||
await service.logout(body.refresh_token)
|
||||
|
||||
|
||||
@router.get(
|
||||
"/me",
|
||||
response_model=UserResponse,
|
||||
summary="Utente corrente autenticato",
|
||||
)
|
||||
async def me(current_user: CurrentUser) -> UserResponse:
|
||||
return UserResponse.model_validate(current_user)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/totp/setup",
|
||||
response_model=TOTPSetupResponse,
|
||||
summary="Avvia setup 2FA TOTP",
|
||||
description="Genera segreto TOTP e QR code. Il 2FA viene attivato solo dopo la verifica.",
|
||||
)
|
||||
async def totp_setup(
|
||||
current_user: CurrentUser,
|
||||
db: DB,
|
||||
) -> TOTPSetupResponse:
|
||||
service = AuthService(db)
|
||||
data = await service.setup_totp(current_user)
|
||||
return TOTPSetupResponse(**data)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/totp/verify",
|
||||
response_model=TOTPStatusResponse,
|
||||
summary="Verifica codice TOTP e attiva 2FA",
|
||||
)
|
||||
async def totp_verify(
|
||||
body: TOTPVerifyRequest,
|
||||
current_user: CurrentUser,
|
||||
db: DB,
|
||||
) -> TOTPStatusResponse:
|
||||
service = AuthService(db)
|
||||
await service.verify_and_enable_totp(current_user, body.totp_code)
|
||||
return TOTPStatusResponse(totp_enabled=True)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/totp/disable",
|
||||
response_model=TOTPStatusResponse,
|
||||
summary="Disabilita 2FA TOTP",
|
||||
)
|
||||
async def totp_disable(
|
||||
current_user: CurrentUser,
|
||||
db: DB,
|
||||
) -> TOTPStatusResponse:
|
||||
service = AuthService(db)
|
||||
await service.disable_totp(current_user)
|
||||
return TOTPStatusResponse(totp_enabled=False)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/change-password",
|
||||
status_code=204,
|
||||
summary="Cambio password utente corrente",
|
||||
)
|
||||
async def change_password(
|
||||
body: PasswordChangeRequest,
|
||||
current_user: CurrentUser,
|
||||
db: DB,
|
||||
) -> None:
|
||||
from app.core.security import verify_password, hash_password
|
||||
|
||||
if not verify_password(body.current_password, current_user.password_hash):
|
||||
raise InvalidCredentialsError()
|
||||
|
||||
current_user.password_hash = hash_password(body.new_password)
|
||||
@@ -0,0 +1,112 @@
|
||||
"""
|
||||
Router permessi granulari casella (Fase 1-A).
|
||||
|
||||
Endpoint:
|
||||
POST /api/v1/permissions/mailboxes/{mailbox_id}/users/{user_id} → assegna permesso
|
||||
DELETE /api/v1/permissions/mailboxes/{mailbox_id}/users/{user_id} → revoca permesso
|
||||
GET /api/v1/permissions/mailboxes/{mailbox_id}/users → utenti con accesso
|
||||
GET /api/v1/permissions/users/{user_id}/mailboxes → caselle accessibili
|
||||
"""
|
||||
|
||||
import uuid
|
||||
|
||||
from fastapi import APIRouter
|
||||
|
||||
from app.dependencies import AdminUser, CurrentUser, DB
|
||||
from app.schemas.permission import (
|
||||
MailboxUserPermissionResponse,
|
||||
PermissionGrantRequest,
|
||||
PermissionResponse,
|
||||
UserMailboxPermissionResponse,
|
||||
)
|
||||
from app.services.permission_service import PermissionService
|
||||
|
||||
router = APIRouter(prefix="/permissions", tags=["Permessi casella"])
|
||||
|
||||
|
||||
@router.post(
|
||||
"/mailboxes/{mailbox_id}/users/{user_id}",
|
||||
response_model=PermissionResponse,
|
||||
status_code=201,
|
||||
summary="Assegna permesso utente su casella",
|
||||
description="Crea o aggiorna i permessi di un utente su una specifica casella PEC.",
|
||||
)
|
||||
async def grant_permission(
|
||||
mailbox_id: uuid.UUID,
|
||||
user_id: uuid.UUID,
|
||||
body: PermissionGrantRequest,
|
||||
current_user: AdminUser,
|
||||
db: DB,
|
||||
) -> PermissionResponse:
|
||||
service = PermissionService(db)
|
||||
perm = await service.grant_permission(
|
||||
tenant_id=current_user.tenant_id,
|
||||
mailbox_id=mailbox_id,
|
||||
user_id=user_id,
|
||||
data=body,
|
||||
granted_by=current_user,
|
||||
)
|
||||
return PermissionResponse.model_validate(perm)
|
||||
|
||||
|
||||
@router.delete(
|
||||
"/mailboxes/{mailbox_id}/users/{user_id}",
|
||||
status_code=204,
|
||||
summary="Revoca permesso utente su casella",
|
||||
)
|
||||
async def revoke_permission(
|
||||
mailbox_id: uuid.UUID,
|
||||
user_id: uuid.UUID,
|
||||
current_user: AdminUser,
|
||||
db: DB,
|
||||
) -> None:
|
||||
service = PermissionService(db)
|
||||
await service.revoke_permission(
|
||||
mailbox_id=mailbox_id,
|
||||
user_id=user_id,
|
||||
revoked_by=current_user,
|
||||
)
|
||||
|
||||
|
||||
@router.get(
|
||||
"/mailboxes/{mailbox_id}/users",
|
||||
response_model=list[MailboxUserPermissionResponse],
|
||||
summary="Utenti con accesso a una casella",
|
||||
)
|
||||
async def list_mailbox_users(
|
||||
mailbox_id: uuid.UUID,
|
||||
current_user: AdminUser,
|
||||
db: DB,
|
||||
) -> list[MailboxUserPermissionResponse]:
|
||||
service = PermissionService(db)
|
||||
rows = await service.list_mailbox_users(mailbox_id, current_user.tenant_id)
|
||||
return [MailboxUserPermissionResponse(**row) for row in rows]
|
||||
|
||||
|
||||
@router.get(
|
||||
"/users/{user_id}/mailboxes",
|
||||
response_model=list[UserMailboxPermissionResponse],
|
||||
summary="Caselle accessibili a un utente",
|
||||
)
|
||||
async def list_user_mailboxes(
|
||||
user_id: uuid.UUID,
|
||||
current_user: AdminUser,
|
||||
db: DB,
|
||||
) -> list[UserMailboxPermissionResponse]:
|
||||
service = PermissionService(db)
|
||||
rows = await service.list_user_mailboxes(user_id, current_user.tenant_id)
|
||||
return [UserMailboxPermissionResponse(**row) for row in rows]
|
||||
|
||||
|
||||
@router.get(
|
||||
"/my/mailboxes",
|
||||
response_model=list[UserMailboxPermissionResponse],
|
||||
summary="Caselle accessibili all'utente corrente",
|
||||
)
|
||||
async def my_mailboxes(
|
||||
current_user: CurrentUser,
|
||||
db: DB,
|
||||
) -> list[UserMailboxPermissionResponse]:
|
||||
service = PermissionService(db)
|
||||
rows = await service.list_user_mailboxes(current_user.id, current_user.tenant_id)
|
||||
return [UserMailboxPermissionResponse(**row) for row in rows]
|
||||
@@ -0,0 +1,80 @@
|
||||
"""
|
||||
Router tenant – gestione organizzazioni (solo super_admin).
|
||||
|
||||
Endpoint:
|
||||
GET /api/v1/tenants → lista tenant
|
||||
POST /api/v1/tenants → crea tenant + admin
|
||||
GET /api/v1/tenants/{id} → dettaglio tenant
|
||||
PATCH /api/v1/tenants/{id} → modifica tenant
|
||||
"""
|
||||
|
||||
import uuid
|
||||
|
||||
from fastapi import APIRouter
|
||||
|
||||
from app.dependencies import SuperAdminUser, DB
|
||||
from app.schemas.tenant import TenantCreateRequest, TenantResponse, TenantUpdateRequest
|
||||
from app.services.tenant_service import TenantService
|
||||
|
||||
router = APIRouter(prefix="/tenants", tags=["Tenant (super-admin)"])
|
||||
|
||||
|
||||
@router.get(
|
||||
"",
|
||||
response_model=list[TenantResponse],
|
||||
summary="Lista tutti i tenant",
|
||||
)
|
||||
async def list_tenants(
|
||||
_: SuperAdminUser,
|
||||
db: DB,
|
||||
) -> list[TenantResponse]:
|
||||
service = TenantService(db)
|
||||
tenants = await service.list_tenants()
|
||||
return [TenantResponse.model_validate(t) for t in tenants]
|
||||
|
||||
|
||||
@router.post(
|
||||
"",
|
||||
response_model=TenantResponse,
|
||||
status_code=201,
|
||||
summary="Crea nuovo tenant con admin iniziale",
|
||||
)
|
||||
async def create_tenant(
|
||||
body: TenantCreateRequest,
|
||||
_: SuperAdminUser,
|
||||
db: DB,
|
||||
) -> TenantResponse:
|
||||
service = TenantService(db)
|
||||
tenant, _ = await service.create_tenant(body)
|
||||
return TenantResponse.model_validate(tenant)
|
||||
|
||||
|
||||
@router.get(
|
||||
"/{tenant_id}",
|
||||
response_model=TenantResponse,
|
||||
summary="Dettaglio tenant",
|
||||
)
|
||||
async def get_tenant(
|
||||
tenant_id: uuid.UUID,
|
||||
_: SuperAdminUser,
|
||||
db: DB,
|
||||
) -> TenantResponse:
|
||||
service = TenantService(db)
|
||||
tenant = await service.get_tenant(tenant_id)
|
||||
return TenantResponse.model_validate(tenant)
|
||||
|
||||
|
||||
@router.patch(
|
||||
"/{tenant_id}",
|
||||
response_model=TenantResponse,
|
||||
summary="Modifica tenant",
|
||||
)
|
||||
async def update_tenant(
|
||||
tenant_id: uuid.UUID,
|
||||
body: TenantUpdateRequest,
|
||||
_: SuperAdminUser,
|
||||
db: DB,
|
||||
) -> TenantResponse:
|
||||
service = TenantService(db)
|
||||
tenant = await service.update_tenant(tenant_id, body)
|
||||
return TenantResponse.model_validate(tenant)
|
||||
@@ -0,0 +1,147 @@
|
||||
"""
|
||||
Router utenti – CRUD per admin del tenant.
|
||||
|
||||
Endpoint:
|
||||
GET /api/v1/users → lista utenti (admin)
|
||||
POST /api/v1/users → crea utente (admin)
|
||||
GET /api/v1/users/{id} → dettaglio utente (admin)
|
||||
PATCH /api/v1/users/{id} → modifica utente (admin)
|
||||
DELETE /api/v1/users/{id} → disabilita utente (admin)
|
||||
POST /api/v1/users/{id}/reset-password → reset password (admin)
|
||||
"""
|
||||
|
||||
import uuid
|
||||
|
||||
from fastapi import APIRouter, Query
|
||||
|
||||
from app.dependencies import AdminUser, DB
|
||||
from app.schemas.user import (
|
||||
UserCreateRequest,
|
||||
UserListResponse,
|
||||
UserPasswordResetRequest,
|
||||
UserResponse,
|
||||
UserUpdateRequest,
|
||||
)
|
||||
from app.services.user_service import UserService
|
||||
|
||||
router = APIRouter(prefix="/users", tags=["Utenti"])
|
||||
|
||||
|
||||
@router.get(
|
||||
"",
|
||||
response_model=UserListResponse,
|
||||
summary="Lista utenti del tenant",
|
||||
)
|
||||
async def list_users(
|
||||
current_user: AdminUser,
|
||||
db: DB,
|
||||
page: int = Query(default=1, ge=1),
|
||||
page_size: int = Query(default=25, ge=1, le=100),
|
||||
) -> UserListResponse:
|
||||
service = UserService(db)
|
||||
users, total = await service.list_users(
|
||||
tenant_id=current_user.tenant_id,
|
||||
page=page,
|
||||
page_size=page_size,
|
||||
)
|
||||
import math
|
||||
return UserListResponse(
|
||||
items=[UserResponse.model_validate(u) for u in users],
|
||||
total=total,
|
||||
page=page,
|
||||
page_size=page_size,
|
||||
pages=math.ceil(total / page_size) if page_size else 0,
|
||||
)
|
||||
|
||||
|
||||
@router.post(
|
||||
"",
|
||||
response_model=UserResponse,
|
||||
status_code=201,
|
||||
summary="Crea nuovo utente nel tenant",
|
||||
)
|
||||
async def create_user(
|
||||
body: UserCreateRequest,
|
||||
current_user: AdminUser,
|
||||
db: DB,
|
||||
) -> UserResponse:
|
||||
service = UserService(db)
|
||||
user = await service.create_user(
|
||||
tenant_id=current_user.tenant_id,
|
||||
data=body,
|
||||
created_by=current_user,
|
||||
)
|
||||
return UserResponse.model_validate(user)
|
||||
|
||||
|
||||
@router.get(
|
||||
"/{user_id}",
|
||||
response_model=UserResponse,
|
||||
summary="Dettaglio utente",
|
||||
)
|
||||
async def get_user(
|
||||
user_id: uuid.UUID,
|
||||
current_user: AdminUser,
|
||||
db: DB,
|
||||
) -> UserResponse:
|
||||
service = UserService(db)
|
||||
user = await service.get_user(user_id, current_user.tenant_id)
|
||||
return UserResponse.model_validate(user)
|
||||
|
||||
|
||||
@router.patch(
|
||||
"/{user_id}",
|
||||
response_model=UserResponse,
|
||||
summary="Modifica utente",
|
||||
)
|
||||
async def update_user(
|
||||
user_id: uuid.UUID,
|
||||
body: UserUpdateRequest,
|
||||
current_user: AdminUser,
|
||||
db: DB,
|
||||
) -> UserResponse:
|
||||
service = UserService(db)
|
||||
user = await service.update_user(
|
||||
user_id=user_id,
|
||||
tenant_id=current_user.tenant_id,
|
||||
data=body,
|
||||
updated_by=current_user,
|
||||
)
|
||||
return UserResponse.model_validate(user)
|
||||
|
||||
|
||||
@router.delete(
|
||||
"/{user_id}",
|
||||
status_code=204,
|
||||
summary="Disabilita utente (soft delete)",
|
||||
)
|
||||
async def delete_user(
|
||||
user_id: uuid.UUID,
|
||||
current_user: AdminUser,
|
||||
db: DB,
|
||||
) -> None:
|
||||
service = UserService(db)
|
||||
await service.delete_user(
|
||||
user_id=user_id,
|
||||
tenant_id=current_user.tenant_id,
|
||||
deleted_by=current_user,
|
||||
)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/{user_id}/reset-password",
|
||||
status_code=204,
|
||||
summary="Reset password utente (admin)",
|
||||
)
|
||||
async def reset_password(
|
||||
user_id: uuid.UUID,
|
||||
body: UserPasswordResetRequest,
|
||||
current_user: AdminUser,
|
||||
db: DB,
|
||||
) -> None:
|
||||
service = UserService(db)
|
||||
await service.reset_password(
|
||||
user_id=user_id,
|
||||
tenant_id=current_user.tenant_id,
|
||||
new_password=body.new_password,
|
||||
)
|
||||
@@ -0,0 +1,90 @@
|
||||
"""
|
||||
Configurazione applicazione – legge variabili d'ambiente tramite pydantic-settings.
|
||||
"""
|
||||
|
||||
from functools import lru_cache
|
||||
from typing import Literal
|
||||
|
||||
from pydantic import field_validator
|
||||
from pydantic_settings import BaseSettings, SettingsConfigDict
|
||||
|
||||
|
||||
class Settings(BaseSettings):
|
||||
model_config = SettingsConfigDict(
|
||||
env_file=".env",
|
||||
env_file_encoding="utf-8",
|
||||
case_sensitive=False,
|
||||
extra="ignore",
|
||||
)
|
||||
|
||||
# ── Applicazione ──────────────────────────────────────────────────────────
|
||||
app_env: Literal["development", "staging", "production"] = "development"
|
||||
app_debug: bool = True
|
||||
app_host: str = "0.0.0.0"
|
||||
app_port: int = 8000
|
||||
app_base_url: str = "http://localhost:8000"
|
||||
|
||||
# ── Sicurezza / JWT ───────────────────────────────────────────────────────
|
||||
secret_key: str = "change-me-in-production"
|
||||
algorithm: str = "HS256"
|
||||
access_token_expire_minutes: int = 15
|
||||
refresh_token_expire_days: int = 30
|
||||
|
||||
# Chiave AES-256-GCM per cifratura credenziali IMAP/SMTP (hex 64 chars = 32 bytes)
|
||||
encryption_key: str = "0" * 64
|
||||
|
||||
# ── Database ──────────────────────────────────────────────────────────────
|
||||
database_url: str = "postgresql+asyncpg://pecflow:pecflow_dev_password@db:5432/pecflow"
|
||||
database_url_sync: str = "postgresql://pecflow:pecflow_dev_password@db:5432/pecflow"
|
||||
|
||||
# ── Redis ─────────────────────────────────────────────────────────────────
|
||||
redis_url: str = "redis://redis:6379/0"
|
||||
|
||||
# ── MinIO ─────────────────────────────────────────────────────────────────
|
||||
minio_endpoint: str = "minio:9000"
|
||||
minio_access_key: str = "minioadmin"
|
||||
minio_secret_key: str = "minioadmin"
|
||||
minio_bucket: str = "pecflow"
|
||||
minio_use_ssl: bool = False
|
||||
|
||||
# ── CORS ──────────────────────────────────────────────────────────────────
|
||||
cors_origins: str = "http://localhost:3000,http://localhost:5173"
|
||||
|
||||
# ── Rate Limiting ─────────────────────────────────────────────────────────
|
||||
rate_limit_auth: str = "10/minute"
|
||||
rate_limit_default: str = "100/minute"
|
||||
|
||||
# ── Logging ───────────────────────────────────────────────────────────────
|
||||
log_level: str = "INFO"
|
||||
log_json: bool = False
|
||||
|
||||
@field_validator("encryption_key")
|
||||
@classmethod
|
||||
def validate_encryption_key(cls, v: str) -> str:
|
||||
if len(v) != 64:
|
||||
raise ValueError(
|
||||
"ENCRYPTION_KEY deve essere una stringa hex di 64 caratteri (32 bytes)"
|
||||
)
|
||||
try:
|
||||
bytes.fromhex(v)
|
||||
except ValueError:
|
||||
raise ValueError("ENCRYPTION_KEY deve essere una stringa esadecimale valida")
|
||||
return v
|
||||
|
||||
@property
|
||||
def cors_origins_list(self) -> list[str]:
|
||||
return [origin.strip() for origin in self.cors_origins.split(",")]
|
||||
|
||||
@property
|
||||
def is_production(self) -> bool:
|
||||
return self.app_env == "production"
|
||||
|
||||
@property
|
||||
def encryption_key_bytes(self) -> bytes:
|
||||
return bytes.fromhex(self.encryption_key)
|
||||
|
||||
|
||||
@lru_cache
|
||||
def get_settings() -> Settings:
|
||||
"""Restituisce istanza singleton delle impostazioni (cachata)."""
|
||||
return Settings()
|
||||
@@ -0,0 +1 @@
|
||||
# Core utilities
|
||||
@@ -0,0 +1,123 @@
|
||||
"""
|
||||
Eccezioni applicative custom per PecFlow.
|
||||
"""
|
||||
|
||||
from fastapi import HTTPException, status
|
||||
|
||||
|
||||
# ─── Autenticazione ───────────────────────────────────────────────────────────
|
||||
|
||||
class InvalidCredentialsError(HTTPException):
|
||||
def __init__(self) -> None:
|
||||
super().__init__(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Credenziali non valide",
|
||||
headers={"WWW-Authenticate": "Bearer"},
|
||||
)
|
||||
|
||||
|
||||
class TokenExpiredError(HTTPException):
|
||||
def __init__(self) -> None:
|
||||
super().__init__(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Token scaduto",
|
||||
headers={"WWW-Authenticate": "Bearer"},
|
||||
)
|
||||
|
||||
|
||||
class TokenInvalidError(HTTPException):
|
||||
def __init__(self) -> None:
|
||||
super().__init__(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Token non valido",
|
||||
headers={"WWW-Authenticate": "Bearer"},
|
||||
)
|
||||
|
||||
|
||||
class AccountLockedError(HTTPException):
|
||||
def __init__(self, locked_until: str = "") -> None:
|
||||
detail = "Account temporaneamente bloccato per troppi tentativi falliti"
|
||||
if locked_until:
|
||||
detail += f" fino a {locked_until}"
|
||||
super().__init__(
|
||||
status_code=status.HTTP_423_LOCKED,
|
||||
detail=detail,
|
||||
)
|
||||
|
||||
|
||||
class AccountDisabledError(HTTPException):
|
||||
def __init__(self) -> None:
|
||||
super().__init__(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="Account disabilitato",
|
||||
)
|
||||
|
||||
|
||||
class TOTPRequiredError(HTTPException):
|
||||
def __init__(self) -> None:
|
||||
super().__init__(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="Autenticazione a due fattori richiesta",
|
||||
)
|
||||
|
||||
|
||||
class TOTPInvalidError(HTTPException):
|
||||
def __init__(self) -> None:
|
||||
super().__init__(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="Codice TOTP non valido o scaduto",
|
||||
)
|
||||
|
||||
|
||||
# ─── Autorizzazione ───────────────────────────────────────────────────────────
|
||||
|
||||
class ForbiddenError(HTTPException):
|
||||
def __init__(self, detail: str = "Accesso non autorizzato") -> None:
|
||||
super().__init__(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail=detail,
|
||||
)
|
||||
|
||||
|
||||
class PermissionDeniedError(HTTPException):
|
||||
def __init__(self, resource: str = "risorsa") -> None:
|
||||
super().__init__(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail=f"Permessi insufficienti per accedere a questa {resource}",
|
||||
)
|
||||
|
||||
|
||||
# ─── Risorse ──────────────────────────────────────────────────────────────────
|
||||
|
||||
class NotFoundError(HTTPException):
|
||||
def __init__(self, resource: str = "risorsa") -> None:
|
||||
super().__init__(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=f"{resource.capitalize()} non trovata",
|
||||
)
|
||||
|
||||
|
||||
class ConflictError(HTTPException):
|
||||
def __init__(self, detail: str = "Conflitto: risorsa già esistente") -> None:
|
||||
super().__init__(
|
||||
status_code=status.HTTP_409_CONFLICT,
|
||||
detail=detail,
|
||||
)
|
||||
|
||||
|
||||
class TenantLimitExceededError(HTTPException):
|
||||
def __init__(self, resource: str, limit: int) -> None:
|
||||
super().__init__(
|
||||
status_code=status.HTTP_402_PAYMENT_REQUIRED,
|
||||
detail=f"Limite del piano raggiunto: massimo {limit} {resource} per questo tenant",
|
||||
)
|
||||
|
||||
|
||||
# ─── Validazione ──────────────────────────────────────────────────────────────
|
||||
|
||||
class ValidationError(HTTPException):
|
||||
def __init__(self, detail: str) -> None:
|
||||
super().__init__(
|
||||
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
||||
detail=detail,
|
||||
)
|
||||
@@ -0,0 +1,65 @@
|
||||
"""
|
||||
Structured logging per PecFlow.
|
||||
In produzione (LOG_JSON=true) emette log JSON per aggregatori (Loki, ELK).
|
||||
In sviluppo emette log leggibili colorati.
|
||||
"""
|
||||
|
||||
import logging
|
||||
import sys
|
||||
from typing import Any
|
||||
|
||||
from app.config import get_settings
|
||||
|
||||
settings = get_settings()
|
||||
|
||||
|
||||
def _build_handler() -> logging.Handler:
|
||||
handler = logging.StreamHandler(sys.stdout)
|
||||
|
||||
if settings.log_json:
|
||||
try:
|
||||
import json
|
||||
|
||||
class JsonFormatter(logging.Formatter):
|
||||
def format(self, record: logging.LogRecord) -> str:
|
||||
log_entry: dict[str, Any] = {
|
||||
"timestamp": self.formatTime(record, "%Y-%m-%dT%H:%M:%S"),
|
||||
"level": record.levelname,
|
||||
"logger": record.name,
|
||||
"message": record.getMessage(),
|
||||
}
|
||||
if record.exc_info:
|
||||
log_entry["exception"] = self.formatException(record.exc_info)
|
||||
return json.dumps(log_entry, ensure_ascii=False)
|
||||
|
||||
handler.setFormatter(JsonFormatter())
|
||||
except Exception:
|
||||
pass
|
||||
else:
|
||||
fmt = "%(asctime)s %(levelname)-8s %(name)s – %(message)s"
|
||||
handler.setFormatter(logging.Formatter(fmt, datefmt="%H:%M:%S"))
|
||||
|
||||
return handler
|
||||
|
||||
|
||||
def setup_logging() -> None:
|
||||
"""Configura il logging applicativo. Da chiamare all'avvio dell'app."""
|
||||
level = getattr(logging, settings.log_level.upper(), logging.INFO)
|
||||
|
||||
root_logger = logging.getLogger()
|
||||
root_logger.setLevel(level)
|
||||
|
||||
# Rimuovi handler esistenti per evitare duplicati
|
||||
root_logger.handlers.clear()
|
||||
root_logger.addHandler(_build_handler())
|
||||
|
||||
# Riduci verbosità librerie rumorose
|
||||
logging.getLogger("uvicorn.access").setLevel(logging.WARNING)
|
||||
logging.getLogger("sqlalchemy.engine").setLevel(
|
||||
logging.INFO if settings.app_debug else logging.WARNING
|
||||
)
|
||||
|
||||
|
||||
def get_logger(name: str) -> logging.Logger:
|
||||
"""Restituisce un logger con il nome specificato."""
|
||||
return logging.getLogger(name)
|
||||
@@ -0,0 +1,56 @@
|
||||
"""
|
||||
Utility per paginazione standardizzata nelle API.
|
||||
"""
|
||||
|
||||
from typing import Generic, TypeVar
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
T = TypeVar("T")
|
||||
|
||||
DEFAULT_PAGE_SIZE = 25
|
||||
MAX_PAGE_SIZE = 100
|
||||
|
||||
|
||||
class PaginationParams(BaseModel):
|
||||
page: int = Field(default=1, ge=1, description="Numero di pagina (1-based)")
|
||||
page_size: int = Field(
|
||||
default=DEFAULT_PAGE_SIZE,
|
||||
ge=1,
|
||||
le=MAX_PAGE_SIZE,
|
||||
description=f"Elementi per pagina (max {MAX_PAGE_SIZE})",
|
||||
)
|
||||
|
||||
@property
|
||||
def offset(self) -> int:
|
||||
return (self.page - 1) * self.page_size
|
||||
|
||||
@property
|
||||
def limit(self) -> int:
|
||||
return self.page_size
|
||||
|
||||
|
||||
class PaginatedResponse(BaseModel, Generic[T]):
|
||||
"""Risposta paginata generica."""
|
||||
items: list[T]
|
||||
total: int
|
||||
page: int
|
||||
page_size: int
|
||||
pages: int
|
||||
|
||||
@classmethod
|
||||
def create(
|
||||
cls,
|
||||
items: list[T],
|
||||
total: int,
|
||||
params: PaginationParams,
|
||||
) -> "PaginatedResponse[T]":
|
||||
import math
|
||||
pages = math.ceil(total / params.page_size) if params.page_size > 0 else 0
|
||||
return cls(
|
||||
items=items,
|
||||
total=total,
|
||||
page=params.page,
|
||||
page_size=params.page_size,
|
||||
pages=pages,
|
||||
)
|
||||
@@ -0,0 +1,158 @@
|
||||
"""
|
||||
Modulo sicurezza – cifratura AES-256-GCM, hashing password, JWT utilities.
|
||||
|
||||
ADR-002: Le credenziali IMAP/SMTP vengono cifrate con AES-256-GCM prima di
|
||||
essere scritte in DB. La chiave è in variabile d'ambiente (ENCRYPTION_KEY).
|
||||
Formato storage: base64(nonce_12byte || ciphertext || tag_16byte)
|
||||
"""
|
||||
|
||||
import base64
|
||||
import os
|
||||
from datetime import UTC, datetime, timedelta
|
||||
from typing import Any
|
||||
from uuid import UUID
|
||||
|
||||
import bcrypt as _bcrypt
|
||||
from cryptography.hazmat.primitives.ciphers.aead import AESGCM
|
||||
from jose import JWTError, jwt
|
||||
|
||||
from app.config import get_settings
|
||||
|
||||
settings = get_settings()
|
||||
|
||||
# ─── Password hashing (bcrypt diretto, compatibile con bcrypt 4.x/5.x) ───────
|
||||
_BCRYPT_ROUNDS = 12
|
||||
|
||||
|
||||
def hash_password(password: str) -> str:
|
||||
"""Genera hash bcrypt della password (work factor 12)."""
|
||||
pwd_bytes = password.encode("utf-8")
|
||||
salt = _bcrypt.gensalt(rounds=_BCRYPT_ROUNDS)
|
||||
return _bcrypt.hashpw(pwd_bytes, salt).decode("utf-8")
|
||||
|
||||
|
||||
def verify_password(plain_password: str, hashed_password: str) -> bool:
|
||||
"""Verifica password contro il suo hash."""
|
||||
try:
|
||||
return _bcrypt.checkpw(
|
||||
plain_password.encode("utf-8"),
|
||||
hashed_password.encode("utf-8"),
|
||||
)
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
|
||||
# ─── JWT ──────────────────────────────────────────────────────────────────────
|
||||
def create_access_token(
|
||||
subject: str | UUID,
|
||||
tenant_id: str | UUID,
|
||||
role: str,
|
||||
extra_claims: dict[str, Any] | None = None,
|
||||
) -> str:
|
||||
"""
|
||||
Crea un JWT access token con scadenza configurabile.
|
||||
|
||||
Claims standard:
|
||||
- sub: user_id (string)
|
||||
- tid: tenant_id
|
||||
- role: ruolo utente
|
||||
- exp: scadenza
|
||||
- iat: emesso a
|
||||
"""
|
||||
now = datetime.now(UTC)
|
||||
expire = now + timedelta(minutes=settings.access_token_expire_minutes)
|
||||
|
||||
payload: dict[str, Any] = {
|
||||
"sub": str(subject),
|
||||
"tid": str(tenant_id),
|
||||
"role": role,
|
||||
"iat": now,
|
||||
"exp": expire,
|
||||
"type": "access",
|
||||
}
|
||||
if extra_claims:
|
||||
payload.update(extra_claims)
|
||||
|
||||
return jwt.encode(payload, settings.secret_key, algorithm=settings.algorithm)
|
||||
|
||||
|
||||
def create_refresh_token(subject: str | UUID, tenant_id: str | UUID) -> str:
|
||||
"""
|
||||
Crea un JWT refresh token con scadenza lunga (30 giorni default).
|
||||
Non contiene il ruolo – viene rivalutato a ogni refresh.
|
||||
"""
|
||||
now = datetime.now(UTC)
|
||||
expire = now + timedelta(days=settings.refresh_token_expire_days)
|
||||
|
||||
payload: dict[str, Any] = {
|
||||
"sub": str(subject),
|
||||
"tid": str(tenant_id),
|
||||
"iat": now,
|
||||
"exp": expire,
|
||||
"type": "refresh",
|
||||
}
|
||||
return jwt.encode(payload, settings.secret_key, algorithm=settings.algorithm)
|
||||
|
||||
|
||||
def decode_token(token: str) -> dict[str, Any]:
|
||||
"""
|
||||
Decodifica e valida un JWT token.
|
||||
Solleva JWTError se il token è invalido o scaduto.
|
||||
"""
|
||||
return jwt.decode(token, settings.secret_key, algorithms=[settings.algorithm])
|
||||
|
||||
|
||||
def is_token_valid(token: str, expected_type: str = "access") -> bool:
|
||||
"""Verifica rapidamente la validità del token senza sollevare eccezioni."""
|
||||
try:
|
||||
payload = decode_token(token)
|
||||
return payload.get("type") == expected_type
|
||||
except JWTError:
|
||||
return False
|
||||
|
||||
|
||||
# ─── AES-256-GCM cifratura credenziali (ADR-002) ─────────────────────────────
|
||||
def encrypt_credential(plaintext: str) -> str:
|
||||
"""
|
||||
Cifra una stringa con AES-256-GCM usando la chiave applicativa.
|
||||
|
||||
Formato output: base64(nonce_12byte || ciphertext || tag_16byte)
|
||||
Il tag GCM (16 byte) è automaticamente concatenato al ciphertext da AESGCM.
|
||||
"""
|
||||
key = settings.encryption_key_bytes
|
||||
aesgcm = AESGCM(key)
|
||||
nonce = os.urandom(12) # 12 byte nonce raccomandato per GCM
|
||||
|
||||
# AESGCM.encrypt() restituisce ciphertext + tag concatenati
|
||||
ciphertext_with_tag = aesgcm.encrypt(nonce, plaintext.encode("utf-8"), None)
|
||||
|
||||
# Concatena nonce + ciphertext_with_tag e codifica in base64
|
||||
raw = nonce + ciphertext_with_tag
|
||||
return base64.b64encode(raw).decode("ascii")
|
||||
|
||||
|
||||
def decrypt_credential(encrypted: str) -> str:
|
||||
"""
|
||||
Decifra una stringa cifrata con encrypt_credential().
|
||||
Solleva ValueError se la decifratura fallisce (chiave errata o dati corrotti).
|
||||
"""
|
||||
key = settings.encryption_key_bytes
|
||||
aesgcm = AESGCM(key)
|
||||
|
||||
try:
|
||||
raw = base64.b64decode(encrypted.encode("ascii"))
|
||||
nonce = raw[:12]
|
||||
ciphertext_with_tag = raw[12:]
|
||||
plaintext_bytes = aesgcm.decrypt(nonce, ciphertext_with_tag, None)
|
||||
return plaintext_bytes.decode("utf-8")
|
||||
except Exception as e:
|
||||
raise ValueError(f"Decifratura fallita: {e}") from e
|
||||
|
||||
|
||||
# ─── Hash sicuro per refresh token storage ────────────────────────────────────
|
||||
import hashlib
|
||||
|
||||
|
||||
def hash_token(token: str) -> str:
|
||||
"""SHA-256 del token raw per storage sicuro in DB."""
|
||||
return hashlib.sha256(token.encode("utf-8")).hexdigest()
|
||||
@@ -0,0 +1,52 @@
|
||||
"""
|
||||
Configurazione database – engine SQLAlchemy async e session factory.
|
||||
"""
|
||||
|
||||
from collections.abc import AsyncGenerator
|
||||
|
||||
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
|
||||
from sqlalchemy.orm import DeclarativeBase
|
||||
|
||||
from app.config import get_settings
|
||||
|
||||
settings = get_settings()
|
||||
|
||||
# Engine async con pool connection
|
||||
engine = create_async_engine(
|
||||
settings.database_url,
|
||||
echo=settings.app_debug, # log SQL in development
|
||||
pool_size=10,
|
||||
max_overflow=20,
|
||||
pool_pre_ping=True, # verifica connessione prima di usarla
|
||||
pool_recycle=3600, # ricicla connessioni ogni ora
|
||||
)
|
||||
|
||||
# Session factory
|
||||
AsyncSessionLocal = async_sessionmaker(
|
||||
bind=engine,
|
||||
class_=AsyncSession,
|
||||
expire_on_commit=False,
|
||||
autocommit=False,
|
||||
autoflush=False,
|
||||
)
|
||||
|
||||
|
||||
class Base(DeclarativeBase):
|
||||
"""Base class per tutti i modelli SQLAlchemy."""
|
||||
pass
|
||||
|
||||
|
||||
async def get_db() -> AsyncGenerator[AsyncSession, None]:
|
||||
"""
|
||||
Dependency FastAPI: restituisce una sessione DB per ogni request.
|
||||
La sessione viene chiusa automaticamente al termine del request.
|
||||
"""
|
||||
async with AsyncSessionLocal() as session:
|
||||
try:
|
||||
yield session
|
||||
await session.commit()
|
||||
except Exception:
|
||||
await session.rollback()
|
||||
raise
|
||||
finally:
|
||||
await session.close()
|
||||
@@ -0,0 +1,117 @@
|
||||
"""
|
||||
Dependency FastAPI – get_db, get_current_user, require_admin, RLS middleware.
|
||||
"""
|
||||
|
||||
import uuid
|
||||
from typing import Annotated
|
||||
|
||||
from fastapi import Depends, Request
|
||||
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
|
||||
from jose import JWTError
|
||||
from sqlalchemy import text
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.core.exceptions import ForbiddenError, TokenInvalidError
|
||||
from app.core.security import decode_token
|
||||
from app.database import get_db
|
||||
from app.models.user import User
|
||||
from sqlalchemy import select
|
||||
|
||||
security = HTTPBearer()
|
||||
|
||||
# ─── Database con RLS ─────────────────────────────────────────────────────────
|
||||
|
||||
async def get_db_with_rls(
|
||||
tenant_id: uuid.UUID,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
) -> AsyncSession:
|
||||
"""
|
||||
Imposta la variabile di sessione PostgreSQL per RLS.
|
||||
Da usare dopo aver estratto il tenant_id dall'utente autenticato.
|
||||
"""
|
||||
await db.execute(
|
||||
text("SET LOCAL app.current_tenant_id = :tenant_id"),
|
||||
{"tenant_id": str(tenant_id)},
|
||||
)
|
||||
return db
|
||||
|
||||
|
||||
# ─── Utente corrente ──────────────────────────────────────────────────────────
|
||||
|
||||
async def get_current_user(
|
||||
credentials: Annotated[HTTPAuthorizationCredentials, Depends(security)],
|
||||
db: AsyncSession = Depends(get_db),
|
||||
) -> User:
|
||||
"""
|
||||
Estrae e valida il JWT dall'header Authorization: Bearer <token>.
|
||||
Carica l'utente dal DB e imposta RLS.
|
||||
"""
|
||||
token = credentials.credentials
|
||||
|
||||
try:
|
||||
payload = decode_token(token)
|
||||
except JWTError:
|
||||
raise TokenInvalidError()
|
||||
|
||||
if payload.get("type") != "access":
|
||||
raise TokenInvalidError()
|
||||
|
||||
user_id_str = payload.get("sub")
|
||||
tenant_id_str = payload.get("tid")
|
||||
|
||||
if not user_id_str or not tenant_id_str:
|
||||
raise TokenInvalidError()
|
||||
|
||||
try:
|
||||
user_id = uuid.UUID(user_id_str)
|
||||
tenant_id = uuid.UUID(tenant_id_str)
|
||||
except ValueError:
|
||||
raise TokenInvalidError()
|
||||
|
||||
# Imposta RLS per questo tenant
|
||||
# SET LOCAL non supporta parametri $1, usiamo text() con valore inline
|
||||
await db.execute(
|
||||
text(f"SET LOCAL app.current_tenant_id = '{tenant_id!s}'")
|
||||
)
|
||||
|
||||
# Carica utente
|
||||
result = await db.execute(
|
||||
select(User).where(User.id == user_id, User.tenant_id == tenant_id)
|
||||
)
|
||||
user = result.scalar_one_or_none()
|
||||
|
||||
if not user:
|
||||
raise TokenInvalidError()
|
||||
|
||||
if not user.is_active:
|
||||
from app.core.exceptions import AccountDisabledError
|
||||
raise AccountDisabledError()
|
||||
|
||||
return user
|
||||
|
||||
|
||||
# ─── Role guards ──────────────────────────────────────────────────────────────
|
||||
|
||||
async def require_admin(
|
||||
current_user: Annotated[User, Depends(get_current_user)],
|
||||
) -> User:
|
||||
"""Richiede ruolo admin o super_admin."""
|
||||
if not current_user.is_admin:
|
||||
raise ForbiddenError("Richiesto ruolo amministratore")
|
||||
return current_user
|
||||
|
||||
|
||||
async def require_super_admin(
|
||||
current_user: Annotated[User, Depends(get_current_user)],
|
||||
) -> User:
|
||||
"""Richiede ruolo super_admin."""
|
||||
if not current_user.is_super_admin:
|
||||
raise ForbiddenError("Richiesto ruolo super_admin")
|
||||
return current_user
|
||||
|
||||
|
||||
# ─── Tipo annotato per ridurre boilerplate negli endpoint ─────────────────────
|
||||
CurrentUser = Annotated[User, Depends(get_current_user)]
|
||||
AdminUser = Annotated[User, Depends(require_admin)]
|
||||
SuperAdminUser = Annotated[User, Depends(require_super_admin)]
|
||||
DB = Annotated[AsyncSession, Depends(get_db)]
|
||||
@@ -0,0 +1,108 @@
|
||||
"""
|
||||
Entrypoint FastAPI – registra router, middleware, startup/shutdown.
|
||||
"""
|
||||
|
||||
from contextlib import asynccontextmanager
|
||||
from typing import AsyncGenerator
|
||||
|
||||
from fastapi import FastAPI, Request, status
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
from fastapi.responses import JSONResponse
|
||||
from slowapi import Limiter, _rate_limit_exceeded_handler
|
||||
from slowapi.errors import RateLimitExceeded
|
||||
from slowapi.middleware import SlowAPIMiddleware
|
||||
from slowapi.util import get_remote_address
|
||||
|
||||
from app.api.v1 import auth, permissions, tenants, users
|
||||
from app.config import get_settings
|
||||
from app.core.logging import get_logger, setup_logging
|
||||
from app.database import engine
|
||||
|
||||
settings = get_settings()
|
||||
logger = get_logger(__name__)
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def lifespan(app: FastAPI) -> AsyncGenerator[None, None]:
|
||||
"""Gestione ciclo di vita dell'applicazione."""
|
||||
setup_logging()
|
||||
logger.info(
|
||||
"🚀 PecFlow Backend avviato",
|
||||
extra={"env": settings.app_env, "debug": settings.app_debug},
|
||||
)
|
||||
yield
|
||||
# Cleanup: chiudi connessioni DB
|
||||
await engine.dispose()
|
||||
logger.info("🛑 PecFlow Backend fermato")
|
||||
|
||||
|
||||
# ─── Applicazione FastAPI ─────────────────────────────────────────────────────
|
||||
limiter = Limiter(key_func=get_remote_address, default_limits=["200/minute"])
|
||||
|
||||
app = FastAPI(
|
||||
title="PecFlow API",
|
||||
description="API per la gestione PEC SaaS multi-tenant",
|
||||
version="1.0.0",
|
||||
docs_url="/docs" if not settings.is_production else None,
|
||||
redoc_url="/redoc" if not settings.is_production else None,
|
||||
lifespan=lifespan,
|
||||
)
|
||||
|
||||
# ─── Middleware ───────────────────────────────────────────────────────────────
|
||||
app.state.limiter = limiter
|
||||
app.add_exception_handler(RateLimitExceeded, _rate_limit_exceeded_handler)
|
||||
app.add_middleware(SlowAPIMiddleware)
|
||||
|
||||
app.add_middleware(
|
||||
CORSMiddleware,
|
||||
allow_origins=settings.cors_origins_list,
|
||||
allow_credentials=True,
|
||||
allow_methods=["*"],
|
||||
allow_headers=["*"],
|
||||
)
|
||||
|
||||
# ─── Router ───────────────────────────────────────────────────────────────────
|
||||
API_PREFIX = "/api/v1"
|
||||
|
||||
app.include_router(auth.router, prefix=API_PREFIX)
|
||||
app.include_router(users.router, prefix=API_PREFIX)
|
||||
app.include_router(tenants.router, prefix=API_PREFIX)
|
||||
app.include_router(permissions.router, prefix=API_PREFIX)
|
||||
|
||||
|
||||
# ─── Health check ─────────────────────────────────────────────────────────────
|
||||
@app.get("/health", tags=["Health"], include_in_schema=False)
|
||||
async def health_check() -> dict:
|
||||
"""Endpoint di health check per Docker/Kubernetes."""
|
||||
return {
|
||||
"status": "ok",
|
||||
"version": "1.0.0",
|
||||
"env": settings.app_env,
|
||||
}
|
||||
|
||||
|
||||
@app.get("/health/db", tags=["Health"], include_in_schema=False)
|
||||
async def health_db() -> dict:
|
||||
"""Verifica connessione al database."""
|
||||
from sqlalchemy import text
|
||||
from app.database import AsyncSessionLocal
|
||||
|
||||
try:
|
||||
async with AsyncSessionLocal() as session:
|
||||
await session.execute(text("SELECT 1"))
|
||||
return {"status": "ok", "database": "connected"}
|
||||
except Exception as e:
|
||||
return JSONResponse(
|
||||
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
|
||||
content={"status": "error", "database": str(e)},
|
||||
)
|
||||
|
||||
|
||||
# ─── Error handler globale ────────────────────────────────────────────────────
|
||||
@app.exception_handler(Exception)
|
||||
async def global_exception_handler(request: Request, exc: Exception) -> JSONResponse:
|
||||
logger.exception(f"Errore non gestito: {exc}")
|
||||
return JSONResponse(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
content={"detail": "Errore interno del server"},
|
||||
)
|
||||
@@ -0,0 +1,9 @@
|
||||
# Importa tutti i modelli per permettere ad Alembic di rilevarli
|
||||
from app.models.tenant import Tenant # noqa: F401
|
||||
from app.models.user import User, RefreshToken # noqa: F401
|
||||
from app.models.mailbox import Mailbox # noqa: F401
|
||||
from app.models.message import Message, Attachment, SendJob # noqa: F401
|
||||
from app.models.archival import ArchivalBatch, ArchivalBatchMessage, ArchivalDip # noqa: F401
|
||||
from app.models.audit_log import AuditLog # noqa: F401
|
||||
from app.models.label import Label, MessageLabel # noqa: F401
|
||||
from app.models.permission import MailboxPermission # noqa: F401
|
||||
@@ -0,0 +1,108 @@
|
||||
"""
|
||||
Modelli Archival – versamenti verso conservatore AgID.
|
||||
"""
|
||||
|
||||
import uuid
|
||||
from datetime import date, datetime
|
||||
|
||||
from sqlalchemy import (
|
||||
CHAR,
|
||||
DateTime,
|
||||
Enum,
|
||||
ForeignKey,
|
||||
Index,
|
||||
Integer,
|
||||
String,
|
||||
Text,
|
||||
func,
|
||||
)
|
||||
from sqlalchemy.dialects.postgresql import UUID
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from app.database import Base
|
||||
|
||||
ArchivalStatus = Enum(
|
||||
"pending", "building_sip", "uploading", "uploaded",
|
||||
"confirmed", "rejected", "failed",
|
||||
name="archival_status",
|
||||
create_type=False,
|
||||
)
|
||||
|
||||
|
||||
class ArchivalBatch(Base):
|
||||
__tablename__ = "archival_batches"
|
||||
|
||||
id: Mapped[uuid.UUID] = mapped_column(
|
||||
UUID(as_uuid=True), primary_key=True, default=uuid.uuid4
|
||||
)
|
||||
tenant_id: Mapped[uuid.UUID] = mapped_column(
|
||||
UUID(as_uuid=True), ForeignKey("tenants.id", ondelete="CASCADE"), nullable=False
|
||||
)
|
||||
conservatore_id: Mapped[str] = mapped_column(String(100), nullable=False)
|
||||
status: Mapped[str] = mapped_column(ArchivalStatus, nullable=False, default="pending")
|
||||
|
||||
sip_path: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
sip_checksum: Mapped[str | None] = mapped_column(CHAR(64), nullable=True)
|
||||
|
||||
versamento_id: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
rdv_received_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||
rdv_path: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
rdv_checksum: Mapped[str | None] = mapped_column(CHAR(64), nullable=True)
|
||||
|
||||
attempt_count: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
|
||||
max_attempts: Mapped[int] = mapped_column(Integer, nullable=False, default=3)
|
||||
next_retry_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||
last_error: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
|
||||
period_from: Mapped[date] = mapped_column(nullable=False)
|
||||
period_to: Mapped[date] = mapped_column(nullable=False)
|
||||
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), nullable=False, server_default=func.now()
|
||||
)
|
||||
updated_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), nullable=False, server_default=func.now(), onupdate=func.now()
|
||||
)
|
||||
|
||||
__table_args__ = (
|
||||
Index("idx_archival_tenant", "tenant_id"),
|
||||
Index("idx_archival_status", "status", "next_retry_at"),
|
||||
)
|
||||
|
||||
|
||||
class ArchivalBatchMessage(Base):
|
||||
__tablename__ = "archival_batch_messages"
|
||||
|
||||
batch_id: Mapped[uuid.UUID] = mapped_column(
|
||||
UUID(as_uuid=True),
|
||||
ForeignKey("archival_batches.id", ondelete="CASCADE"),
|
||||
primary_key=True,
|
||||
)
|
||||
message_id: Mapped[uuid.UUID] = mapped_column(
|
||||
UUID(as_uuid=True),
|
||||
ForeignKey("messages.id", ondelete="CASCADE"),
|
||||
primary_key=True,
|
||||
)
|
||||
|
||||
|
||||
class ArchivalDip(Base):
|
||||
__tablename__ = "archival_dips"
|
||||
|
||||
id: Mapped[uuid.UUID] = mapped_column(
|
||||
UUID(as_uuid=True), primary_key=True, default=uuid.uuid4
|
||||
)
|
||||
tenant_id: Mapped[uuid.UUID] = mapped_column(
|
||||
UUID(as_uuid=True), ForeignKey("tenants.id", ondelete="CASCADE"), nullable=False
|
||||
)
|
||||
batch_id: Mapped[uuid.UUID | None] = mapped_column(
|
||||
UUID(as_uuid=True), ForeignKey("archival_batches.id"), nullable=True
|
||||
)
|
||||
requested_by: Mapped[uuid.UUID | None] = mapped_column(
|
||||
UUID(as_uuid=True), ForeignKey("users.id"), nullable=True
|
||||
)
|
||||
dip_path: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
status: Mapped[str] = mapped_column(String(50), nullable=False, default="requested")
|
||||
requested_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), nullable=False, server_default=func.now()
|
||||
)
|
||||
received_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||
@@ -0,0 +1,51 @@
|
||||
"""
|
||||
Modello AuditLog – immutabile per compliance e tracciabilità.
|
||||
"""
|
||||
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
|
||||
from sqlalchemy import (
|
||||
BigInteger,
|
||||
DateTime,
|
||||
ForeignKey,
|
||||
Index,
|
||||
String,
|
||||
Text,
|
||||
func,
|
||||
)
|
||||
from sqlalchemy.dialects.postgresql import INET, JSONB, UUID
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from app.database import Base
|
||||
|
||||
|
||||
class AuditLog(Base):
|
||||
__tablename__ = "audit_log"
|
||||
|
||||
id: Mapped[int] = mapped_column(BigInteger, primary_key=True, autoincrement=True)
|
||||
tenant_id: Mapped[uuid.UUID | None] = mapped_column(
|
||||
UUID(as_uuid=True), ForeignKey("tenants.id"), nullable=True
|
||||
)
|
||||
user_id: Mapped[uuid.UUID | None] = mapped_column(
|
||||
UUID(as_uuid=True), ForeignKey("users.id"), nullable=True
|
||||
)
|
||||
action: Mapped[str] = mapped_column(String(100), nullable=False)
|
||||
resource_type: Mapped[str | None] = mapped_column(String(100), nullable=True)
|
||||
resource_id: Mapped[uuid.UUID | None] = mapped_column(UUID(as_uuid=True), nullable=True)
|
||||
ip_address: Mapped[str | None] = mapped_column(INET, nullable=True)
|
||||
user_agent: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
payload: Mapped[dict | None] = mapped_column(JSONB, nullable=True)
|
||||
outcome: Mapped[str] = mapped_column(String(20), nullable=False, default="success")
|
||||
occurred_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), nullable=False, server_default=func.now()
|
||||
)
|
||||
|
||||
__table_args__ = (
|
||||
Index("idx_audit_tenant_date", "tenant_id", "occurred_at"),
|
||||
Index("idx_audit_user", "user_id", "occurred_at"),
|
||||
Index("idx_audit_action", "action"),
|
||||
)
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return f"<AuditLog {self.action!r} user={self.user_id} outcome={self.outcome!r}>"
|
||||
@@ -0,0 +1,46 @@
|
||||
"""
|
||||
Modelli Label e MessageLabel – tagging messaggi.
|
||||
"""
|
||||
|
||||
import uuid
|
||||
|
||||
from sqlalchemy import CHAR, ForeignKey, Index, String, UniqueConstraint
|
||||
from sqlalchemy.dialects.postgresql import UUID
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from app.database import Base
|
||||
|
||||
|
||||
class Label(Base):
|
||||
__tablename__ = "labels"
|
||||
|
||||
id: Mapped[uuid.UUID] = mapped_column(
|
||||
UUID(as_uuid=True), primary_key=True, default=uuid.uuid4
|
||||
)
|
||||
tenant_id: Mapped[uuid.UUID] = mapped_column(
|
||||
UUID(as_uuid=True), ForeignKey("tenants.id", ondelete="CASCADE"), nullable=False
|
||||
)
|
||||
name: Mapped[str] = mapped_column(String(100), nullable=False)
|
||||
color: Mapped[str | None] = mapped_column(CHAR(7), nullable=True) # hex #RRGGBB
|
||||
|
||||
__table_args__ = (
|
||||
UniqueConstraint("tenant_id", "name", name="uq_label_name_tenant"),
|
||||
)
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return f"<Label {self.name!r}>"
|
||||
|
||||
|
||||
class MessageLabel(Base):
|
||||
__tablename__ = "message_labels"
|
||||
|
||||
message_id: Mapped[uuid.UUID] = mapped_column(
|
||||
UUID(as_uuid=True),
|
||||
ForeignKey("messages.id", ondelete="CASCADE"),
|
||||
primary_key=True,
|
||||
)
|
||||
label_id: Mapped[uuid.UUID] = mapped_column(
|
||||
UUID(as_uuid=True),
|
||||
ForeignKey("labels.id", ondelete="CASCADE"),
|
||||
primary_key=True,
|
||||
)
|
||||
@@ -0,0 +1,94 @@
|
||||
"""
|
||||
Modello Mailbox – casella PEC con credenziali IMAP/SMTP cifrate.
|
||||
"""
|
||||
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
|
||||
from sqlalchemy import (
|
||||
BigInteger,
|
||||
Boolean,
|
||||
DateTime,
|
||||
Enum,
|
||||
ForeignKey,
|
||||
Index,
|
||||
Integer,
|
||||
String,
|
||||
Text,
|
||||
UniqueConstraint,
|
||||
func,
|
||||
)
|
||||
from sqlalchemy.dialects.postgresql import UUID
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
|
||||
from app.database import Base
|
||||
|
||||
MailboxStatus = Enum(
|
||||
"active", "paused", "error", "deleted",
|
||||
name="mailbox_status",
|
||||
create_type=False,
|
||||
)
|
||||
|
||||
|
||||
class Mailbox(Base):
|
||||
__tablename__ = "mailboxes"
|
||||
|
||||
id: Mapped[uuid.UUID] = mapped_column(
|
||||
UUID(as_uuid=True), primary_key=True, default=uuid.uuid4
|
||||
)
|
||||
tenant_id: Mapped[uuid.UUID] = mapped_column(
|
||||
UUID(as_uuid=True), ForeignKey("tenants.id", ondelete="CASCADE"), nullable=False
|
||||
)
|
||||
email_address: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||
display_name: Mapped[str | None] = mapped_column(String(255), nullable=True)
|
||||
provider: Mapped[str | None] = mapped_column(String(100), nullable=True)
|
||||
|
||||
# Credenziali IMAP cifrate (AES-256-GCM)
|
||||
imap_host_enc: Mapped[str] = mapped_column(Text, nullable=False)
|
||||
imap_port_enc: Mapped[str] = mapped_column(Text, nullable=False)
|
||||
imap_user_enc: Mapped[str] = mapped_column(Text, nullable=False)
|
||||
imap_pass_enc: Mapped[str] = mapped_column(Text, nullable=False)
|
||||
imap_use_ssl: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True)
|
||||
|
||||
# Credenziali SMTP cifrate (AES-256-GCM)
|
||||
smtp_host_enc: Mapped[str] = mapped_column(Text, nullable=False)
|
||||
smtp_port_enc: Mapped[str] = mapped_column(Text, nullable=False)
|
||||
smtp_user_enc: Mapped[str] = mapped_column(Text, nullable=False)
|
||||
smtp_pass_enc: Mapped[str] = mapped_column(Text, nullable=False)
|
||||
smtp_use_tls: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True)
|
||||
|
||||
# Stato sincronizzazione
|
||||
status: Mapped[str] = mapped_column(MailboxStatus, nullable=False, default="active")
|
||||
last_sync_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||
last_sync_uid: Mapped[int | None] = mapped_column(BigInteger, nullable=True)
|
||||
sync_error_msg: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
sync_error_count: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
|
||||
|
||||
created_by: Mapped[uuid.UUID | None] = mapped_column(
|
||||
UUID(as_uuid=True), ForeignKey("users.id"), nullable=True
|
||||
)
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), nullable=False, server_default=func.now()
|
||||
)
|
||||
updated_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), nullable=False, server_default=func.now(), onupdate=func.now()
|
||||
)
|
||||
|
||||
# Relazioni
|
||||
tenant: Mapped["Tenant"] = relationship("Tenant", back_populates="mailboxes") # noqa: F821
|
||||
permissions: Mapped[list["MailboxPermission"]] = relationship( # noqa: F821
|
||||
"MailboxPermission", back_populates="mailbox", cascade="all, delete-orphan"
|
||||
)
|
||||
|
||||
__table_args__ = (
|
||||
UniqueConstraint("tenant_id", "email_address", name="uq_mailbox_email_tenant"),
|
||||
Index("idx_mailboxes_tenant", "tenant_id"),
|
||||
Index(
|
||||
"idx_mailboxes_status",
|
||||
"status",
|
||||
postgresql_where="status = 'active'",
|
||||
),
|
||||
)
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return f"<Mailbox {self.email_address!r} status={self.status!r}>"
|
||||
@@ -0,0 +1,191 @@
|
||||
"""
|
||||
Modelli Message, Attachment, SendJob.
|
||||
"""
|
||||
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
|
||||
from sqlalchemy import (
|
||||
ARRAY,
|
||||
BigInteger,
|
||||
Boolean,
|
||||
DateTime,
|
||||
Enum,
|
||||
ForeignKey,
|
||||
Index,
|
||||
Integer,
|
||||
String,
|
||||
Text,
|
||||
func,
|
||||
)
|
||||
from sqlalchemy.dialects.postgresql import UUID
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
|
||||
from app.database import Base
|
||||
|
||||
PecDirection = Enum("inbound", "outbound", name="pec_direction", create_type=False)
|
||||
PecState = Enum(
|
||||
"draft", "queued", "sent", "accepted", "delivered", "anomaly", "failed", "received",
|
||||
name="pec_state",
|
||||
create_type=False,
|
||||
)
|
||||
PecMsgType = Enum(
|
||||
"posta_certificata", "accettazione", "non_accettazione", "presa_in_carico",
|
||||
"avvenuta_consegna", "mancata_consegna", "errore_consegna",
|
||||
"preavviso_mancata_consegna", "rilevazione_virus", "unknown",
|
||||
name="pec_msg_type",
|
||||
create_type=False,
|
||||
)
|
||||
SendJobStatus = Enum(
|
||||
"pending", "sending", "sent", "failed", "retrying",
|
||||
name="send_job_status",
|
||||
create_type=False,
|
||||
)
|
||||
|
||||
|
||||
class Message(Base):
|
||||
__tablename__ = "messages"
|
||||
|
||||
id: Mapped[uuid.UUID] = mapped_column(
|
||||
UUID(as_uuid=True), primary_key=True, default=uuid.uuid4
|
||||
)
|
||||
tenant_id: Mapped[uuid.UUID] = mapped_column(
|
||||
UUID(as_uuid=True), ForeignKey("tenants.id", ondelete="CASCADE"), nullable=False
|
||||
)
|
||||
mailbox_id: Mapped[uuid.UUID] = mapped_column(
|
||||
UUID(as_uuid=True), ForeignKey("mailboxes.id", ondelete="CASCADE"), nullable=False
|
||||
)
|
||||
|
||||
# Identificatori
|
||||
message_id_header: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
imap_uid: Mapped[int | None] = mapped_column(BigInteger, nullable=True)
|
||||
imap_folder: Mapped[str] = mapped_column(String(255), nullable=False, default="INBOX")
|
||||
|
||||
direction: Mapped[str] = mapped_column(PecDirection, nullable=False)
|
||||
pec_type: Mapped[str] = mapped_column(
|
||||
PecMsgType, nullable=False, default="posta_certificata"
|
||||
)
|
||||
state: Mapped[str] = mapped_column(PecState, nullable=False)
|
||||
|
||||
# Busta PEC
|
||||
subject: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
from_address: Mapped[str | None] = mapped_column(String(255), nullable=True)
|
||||
to_addresses: Mapped[list[str] | None] = mapped_column(ARRAY(Text), nullable=True)
|
||||
cc_addresses: Mapped[list[str] | None] = mapped_column(ARRAY(Text), nullable=True)
|
||||
sent_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||
received_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||
size_bytes: Mapped[int | None] = mapped_column(BigInteger, nullable=True)
|
||||
|
||||
# Corpo
|
||||
body_text: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
body_html: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
has_attachments: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
|
||||
|
||||
# Collegamento ricevute
|
||||
parent_message_id: Mapped[uuid.UUID | None] = mapped_column(
|
||||
UUID(as_uuid=True), ForeignKey("messages.id"), nullable=True
|
||||
)
|
||||
|
||||
# Flag operativi
|
||||
is_read: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
|
||||
is_starred: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
|
||||
is_archived: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
|
||||
archived_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||
|
||||
raw_eml_path: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), nullable=False, server_default=func.now()
|
||||
)
|
||||
updated_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), nullable=False, server_default=func.now(), onupdate=func.now()
|
||||
)
|
||||
|
||||
# Relazioni
|
||||
attachments: Mapped[list["Attachment"]] = relationship(
|
||||
"Attachment", back_populates="message", cascade="all, delete-orphan"
|
||||
)
|
||||
children: Mapped[list["Message"]] = relationship(
|
||||
"Message", foreign_keys=[parent_message_id]
|
||||
)
|
||||
|
||||
__table_args__ = (
|
||||
Index("idx_messages_tenant", "tenant_id"),
|
||||
Index("idx_messages_mailbox", "mailbox_id"),
|
||||
Index("idx_messages_state", "state"),
|
||||
Index("idx_messages_received_at", "received_at", postgresql_ops={"received_at": "DESC"}),
|
||||
Index(
|
||||
"idx_messages_parent", "parent_message_id",
|
||||
postgresql_where="parent_message_id IS NOT NULL",
|
||||
),
|
||||
Index("idx_messages_imap_uid", "mailbox_id", "imap_uid"),
|
||||
)
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return f"<Message {self.id} {self.pec_type!r} {self.state!r}>"
|
||||
|
||||
|
||||
class Attachment(Base):
|
||||
__tablename__ = "attachments"
|
||||
|
||||
id: Mapped[uuid.UUID] = mapped_column(
|
||||
UUID(as_uuid=True), primary_key=True, default=uuid.uuid4
|
||||
)
|
||||
tenant_id: Mapped[uuid.UUID] = mapped_column(
|
||||
UUID(as_uuid=True), ForeignKey("tenants.id", ondelete="CASCADE"), nullable=False
|
||||
)
|
||||
message_id: Mapped[uuid.UUID] = mapped_column(
|
||||
UUID(as_uuid=True), ForeignKey("messages.id", ondelete="CASCADE"), nullable=False
|
||||
)
|
||||
filename: Mapped[str] = mapped_column(String(512), nullable=False)
|
||||
content_type: Mapped[str | None] = mapped_column(String(255), nullable=True)
|
||||
size_bytes: Mapped[int | None] = mapped_column(BigInteger, nullable=True)
|
||||
storage_path: Mapped[str] = mapped_column(Text, nullable=False)
|
||||
checksum_sha256: Mapped[str | None] = mapped_column(String(64), nullable=True)
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), nullable=False, server_default=func.now()
|
||||
)
|
||||
|
||||
# Relazioni
|
||||
message: Mapped["Message"] = relationship("Message", back_populates="attachments")
|
||||
|
||||
__table_args__ = (
|
||||
Index("idx_attachments_message", "message_id"),
|
||||
)
|
||||
|
||||
|
||||
class SendJob(Base):
|
||||
__tablename__ = "send_jobs"
|
||||
|
||||
id: Mapped[uuid.UUID] = mapped_column(
|
||||
UUID(as_uuid=True), primary_key=True, default=uuid.uuid4
|
||||
)
|
||||
tenant_id: Mapped[uuid.UUID] = mapped_column(
|
||||
UUID(as_uuid=True), ForeignKey("tenants.id", ondelete="CASCADE"), nullable=False
|
||||
)
|
||||
mailbox_id: Mapped[uuid.UUID] = mapped_column(
|
||||
UUID(as_uuid=True), ForeignKey("mailboxes.id"), nullable=False
|
||||
)
|
||||
message_id: Mapped[uuid.UUID | None] = mapped_column(
|
||||
UUID(as_uuid=True), ForeignKey("messages.id"), nullable=True
|
||||
)
|
||||
status: Mapped[str] = mapped_column(SendJobStatus, nullable=False, default="pending")
|
||||
attempt_count: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
|
||||
max_attempts: Mapped[int] = mapped_column(Integer, nullable=False, default=5)
|
||||
next_retry_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||
last_error: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
queued_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), nullable=False, server_default=func.now()
|
||||
)
|
||||
sent_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||
created_by: Mapped[uuid.UUID | None] = mapped_column(
|
||||
UUID(as_uuid=True), ForeignKey("users.id"), nullable=True
|
||||
)
|
||||
|
||||
__table_args__ = (
|
||||
Index("idx_sendjobs_tenant", "tenant_id"),
|
||||
Index(
|
||||
"idx_sendjobs_status", "status", "next_retry_at",
|
||||
postgresql_where="status IN ('pending', 'retrying')",
|
||||
),
|
||||
)
|
||||
@@ -0,0 +1,71 @@
|
||||
"""
|
||||
Modello MailboxPermission – matrice permessi utente × casella (Fase 1-A).
|
||||
|
||||
ADR permessi granulari: admin ha accesso implicito a tutto.
|
||||
Gli operator/readonly/supervisor devono avere un record esplicito.
|
||||
"""
|
||||
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
|
||||
from sqlalchemy import (
|
||||
Boolean,
|
||||
DateTime,
|
||||
ForeignKey,
|
||||
Index,
|
||||
UniqueConstraint,
|
||||
func,
|
||||
)
|
||||
from sqlalchemy.dialects.postgresql import UUID
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
|
||||
from app.database import Base
|
||||
|
||||
|
||||
class MailboxPermission(Base):
|
||||
__tablename__ = "mailbox_permissions"
|
||||
|
||||
id: Mapped[uuid.UUID] = mapped_column(
|
||||
UUID(as_uuid=True), primary_key=True, default=uuid.uuid4
|
||||
)
|
||||
tenant_id: Mapped[uuid.UUID] = mapped_column(
|
||||
UUID(as_uuid=True), ForeignKey("tenants.id", ondelete="CASCADE"), nullable=False
|
||||
)
|
||||
user_id: Mapped[uuid.UUID] = mapped_column(
|
||||
UUID(as_uuid=True), ForeignKey("users.id", ondelete="CASCADE"), nullable=False
|
||||
)
|
||||
mailbox_id: Mapped[uuid.UUID] = mapped_column(
|
||||
UUID(as_uuid=True), ForeignKey("mailboxes.id", ondelete="CASCADE"), nullable=False
|
||||
)
|
||||
|
||||
can_read: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True)
|
||||
can_send: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
|
||||
can_manage: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
|
||||
|
||||
granted_by: Mapped[uuid.UUID | None] = mapped_column(
|
||||
UUID(as_uuid=True), ForeignKey("users.id"), nullable=True
|
||||
)
|
||||
granted_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), nullable=False, server_default=func.now()
|
||||
)
|
||||
|
||||
# Relazioni
|
||||
user: Mapped["User"] = relationship( # noqa: F821
|
||||
"User", back_populates="mailbox_permissions", foreign_keys=[user_id]
|
||||
)
|
||||
mailbox: Mapped["Mailbox"] = relationship( # noqa: F821
|
||||
"Mailbox", back_populates="permissions"
|
||||
)
|
||||
|
||||
__table_args__ = (
|
||||
UniqueConstraint("user_id", "mailbox_id", name="uq_perm_user_mailbox"),
|
||||
Index("idx_mbperm_user", "user_id"),
|
||||
Index("idx_mbperm_mailbox", "mailbox_id"),
|
||||
Index("idx_mbperm_tenant", "tenant_id"),
|
||||
)
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return (
|
||||
f"<MailboxPermission user={self.user_id} mailbox={self.mailbox_id} "
|
||||
f"read={self.can_read} send={self.can_send}>"
|
||||
)
|
||||
@@ -0,0 +1,47 @@
|
||||
"""
|
||||
Modello Tenant – ogni organizzazione cliente del SaaS.
|
||||
"""
|
||||
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
|
||||
from sqlalchemy import Boolean, DateTime, Index, Integer, String, func
|
||||
from sqlalchemy.dialects.postgresql import UUID
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
|
||||
from app.database import Base
|
||||
|
||||
|
||||
class Tenant(Base):
|
||||
__tablename__ = "tenants"
|
||||
|
||||
id: Mapped[uuid.UUID] = mapped_column(
|
||||
UUID(as_uuid=True), primary_key=True, default=uuid.uuid4
|
||||
)
|
||||
slug: Mapped[str] = mapped_column(String(63), nullable=False, unique=True)
|
||||
name: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||
plan: Mapped[str] = mapped_column(String(50), nullable=False, default="starter")
|
||||
is_active: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True)
|
||||
max_mailboxes: Mapped[int] = mapped_column(Integer, nullable=False, default=5)
|
||||
max_users: Mapped[int] = mapped_column(Integer, nullable=False, default=10)
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), nullable=False, server_default=func.now()
|
||||
)
|
||||
updated_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), nullable=False, server_default=func.now(), onupdate=func.now()
|
||||
)
|
||||
|
||||
# Relazioni
|
||||
users: Mapped[list["User"]] = relationship( # noqa: F821
|
||||
"User", back_populates="tenant", cascade="all, delete-orphan"
|
||||
)
|
||||
mailboxes: Mapped[list["Mailbox"]] = relationship( # noqa: F821
|
||||
"Mailbox", back_populates="tenant", cascade="all, delete-orphan"
|
||||
)
|
||||
|
||||
__table_args__ = (
|
||||
Index("idx_tenants_slug", "slug"),
|
||||
)
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return f"<Tenant {self.slug!r} ({self.plan})>"
|
||||
@@ -0,0 +1,129 @@
|
||||
"""
|
||||
Modelli User e RefreshToken.
|
||||
"""
|
||||
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
|
||||
from sqlalchemy import (
|
||||
Boolean,
|
||||
DateTime,
|
||||
Enum,
|
||||
ForeignKey,
|
||||
Index,
|
||||
Integer,
|
||||
String,
|
||||
Text,
|
||||
UniqueConstraint,
|
||||
func,
|
||||
)
|
||||
from sqlalchemy.dialects.postgresql import INET, UUID
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
|
||||
from app.database import Base
|
||||
|
||||
UserRole = Enum(
|
||||
"super_admin", "admin", "supervisor", "operator", "readonly",
|
||||
name="user_role",
|
||||
create_type=False, # creato dalla migrazione Alembic
|
||||
)
|
||||
|
||||
|
||||
class User(Base):
|
||||
__tablename__ = "users"
|
||||
|
||||
id: Mapped[uuid.UUID] = mapped_column(
|
||||
UUID(as_uuid=True), primary_key=True, default=uuid.uuid4
|
||||
)
|
||||
tenant_id: Mapped[uuid.UUID] = mapped_column(
|
||||
UUID(as_uuid=True), ForeignKey("tenants.id", ondelete="CASCADE"), nullable=False
|
||||
)
|
||||
email: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||
password_hash: Mapped[str] = mapped_column(Text, nullable=False)
|
||||
full_name: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||
role: Mapped[str] = mapped_column(UserRole, nullable=False, default="operator")
|
||||
is_active: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True)
|
||||
|
||||
# 2FA TOTP
|
||||
totp_secret: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
totp_enabled: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
|
||||
|
||||
# Sicurezza accesso
|
||||
last_login_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||
failed_login_count: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
|
||||
locked_until: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), nullable=False, server_default=func.now()
|
||||
)
|
||||
updated_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), nullable=False, server_default=func.now(), onupdate=func.now()
|
||||
)
|
||||
|
||||
# Relazioni
|
||||
tenant: Mapped["Tenant"] = relationship("Tenant", back_populates="users") # noqa: F821
|
||||
refresh_tokens: Mapped[list["RefreshToken"]] = relationship(
|
||||
"RefreshToken", back_populates="user", cascade="all, delete-orphan"
|
||||
)
|
||||
mailbox_permissions: Mapped[list["MailboxPermission"]] = relationship( # noqa: F821
|
||||
"MailboxPermission",
|
||||
back_populates="user",
|
||||
foreign_keys="[MailboxPermission.user_id]",
|
||||
cascade="all, delete-orphan",
|
||||
)
|
||||
|
||||
__table_args__ = (
|
||||
UniqueConstraint("tenant_id", "email", name="uq_user_email_tenant"),
|
||||
Index("idx_users_tenant", "tenant_id"),
|
||||
Index("idx_users_email", "email"),
|
||||
)
|
||||
|
||||
@property
|
||||
def is_admin(self) -> bool:
|
||||
return self.role in ("super_admin", "admin")
|
||||
|
||||
@property
|
||||
def is_super_admin(self) -> bool:
|
||||
return self.role == "super_admin"
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return f"<User {self.email!r} role={self.role!r}>"
|
||||
|
||||
|
||||
class RefreshToken(Base):
|
||||
__tablename__ = "refresh_tokens"
|
||||
|
||||
id: Mapped[uuid.UUID] = mapped_column(
|
||||
UUID(as_uuid=True), primary_key=True, default=uuid.uuid4
|
||||
)
|
||||
user_id: Mapped[uuid.UUID] = mapped_column(
|
||||
UUID(as_uuid=True), ForeignKey("users.id", ondelete="CASCADE"), nullable=False
|
||||
)
|
||||
token_hash: Mapped[str] = mapped_column(Text, nullable=False, unique=True)
|
||||
issued_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), nullable=False, server_default=func.now()
|
||||
)
|
||||
expires_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False)
|
||||
revoked_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||
user_agent: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
ip_address: Mapped[str | None] = mapped_column(INET, nullable=True)
|
||||
|
||||
# Relazioni
|
||||
user: Mapped["User"] = relationship("User", back_populates="refresh_tokens")
|
||||
|
||||
__table_args__ = (
|
||||
Index("idx_rt_user", "user_id"),
|
||||
Index(
|
||||
"idx_rt_expires",
|
||||
"expires_at",
|
||||
postgresql_where="revoked_at IS NULL",
|
||||
),
|
||||
)
|
||||
|
||||
@property
|
||||
def is_valid(self) -> bool:
|
||||
from datetime import UTC
|
||||
return self.revoked_at is None and self.expires_at > datetime.now(UTC)
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return f"<RefreshToken user_id={self.user_id!r}>"
|
||||
@@ -0,0 +1 @@
|
||||
# Schemas Pydantic
|
||||
@@ -0,0 +1,68 @@
|
||||
"""
|
||||
Schema Pydantic per autenticazione e autorizzazione.
|
||||
"""
|
||||
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
|
||||
from pydantic import BaseModel, EmailStr, Field, field_validator
|
||||
|
||||
|
||||
# ─── Request ──────────────────────────────────────────────────────────────────
|
||||
|
||||
class LoginRequest(BaseModel):
|
||||
email: EmailStr
|
||||
password: str = Field(min_length=1)
|
||||
totp_code: str | None = Field(default=None, description="Codice TOTP 6 cifre (se 2FA attivo)")
|
||||
|
||||
|
||||
class RefreshRequest(BaseModel):
|
||||
refresh_token: str
|
||||
|
||||
|
||||
class TOTPVerifyRequest(BaseModel):
|
||||
totp_code: str = Field(min_length=6, max_length=6, description="Codice TOTP 6 cifre")
|
||||
|
||||
|
||||
class PasswordChangeRequest(BaseModel):
|
||||
current_password: str
|
||||
new_password: str = Field(min_length=8)
|
||||
|
||||
@field_validator("new_password")
|
||||
@classmethod
|
||||
def validate_password_strength(cls, v: str) -> str:
|
||||
if len(v) < 8:
|
||||
raise ValueError("La password deve essere almeno 8 caratteri")
|
||||
if not any(c.isupper() for c in v):
|
||||
raise ValueError("La password deve contenere almeno una lettera maiuscola")
|
||||
if not any(c.isdigit() for c in v):
|
||||
raise ValueError("La password deve contenere almeno un numero")
|
||||
return v
|
||||
|
||||
|
||||
# ─── Response ─────────────────────────────────────────────────────────────────
|
||||
|
||||
class TokenResponse(BaseModel):
|
||||
access_token: str
|
||||
refresh_token: str
|
||||
token_type: str = "bearer"
|
||||
expires_in: int = Field(description="Scadenza access token in secondi")
|
||||
|
||||
|
||||
class TOTPSetupResponse(BaseModel):
|
||||
"""Dati per configurare TOTP sull'authenticator app."""
|
||||
secret: str = Field(description="Segreto TOTP base32 (da inserire manualmente)")
|
||||
qr_uri: str = Field(description="URI otpauth:// per il QR code")
|
||||
qr_image_base64: str = Field(description="QR code come immagine PNG base64")
|
||||
|
||||
|
||||
class TOTPStatusResponse(BaseModel):
|
||||
totp_enabled: bool
|
||||
|
||||
|
||||
class UserTokenData(BaseModel):
|
||||
"""Dati estratti dal JWT per l'utente corrente."""
|
||||
user_id: uuid.UUID
|
||||
tenant_id: uuid.UUID
|
||||
role: str
|
||||
email: str | None = None
|
||||
@@ -0,0 +1,50 @@
|
||||
"""
|
||||
Schema Pydantic per permessi granulari casella (Fase 1-A).
|
||||
"""
|
||||
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
|
||||
from pydantic import BaseModel
|
||||
|
||||
|
||||
class PermissionGrantRequest(BaseModel):
|
||||
can_read: bool = True
|
||||
can_send: bool = False
|
||||
can_manage: bool = False
|
||||
|
||||
|
||||
class PermissionResponse(BaseModel):
|
||||
id: uuid.UUID
|
||||
tenant_id: uuid.UUID
|
||||
user_id: uuid.UUID
|
||||
mailbox_id: uuid.UUID
|
||||
can_read: bool
|
||||
can_send: bool
|
||||
can_manage: bool
|
||||
granted_by: uuid.UUID | None
|
||||
granted_at: datetime
|
||||
|
||||
model_config = {"from_attributes": True}
|
||||
|
||||
|
||||
class UserMailboxPermissionResponse(BaseModel):
|
||||
"""Vista utente: caselle accessibili con relativi permessi."""
|
||||
mailbox_id: uuid.UUID
|
||||
mailbox_email: str
|
||||
mailbox_display_name: str | None
|
||||
can_read: bool
|
||||
can_send: bool
|
||||
can_manage: bool
|
||||
|
||||
|
||||
class MailboxUserPermissionResponse(BaseModel):
|
||||
"""Vista casella: utenti con accesso."""
|
||||
user_id: uuid.UUID
|
||||
user_email: str
|
||||
user_full_name: str
|
||||
user_role: str
|
||||
can_read: bool
|
||||
can_send: bool
|
||||
can_manage: bool
|
||||
granted_at: datetime
|
||||
@@ -0,0 +1,54 @@
|
||||
"""
|
||||
Schema Pydantic per tenant (super-admin).
|
||||
"""
|
||||
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
from typing import Literal
|
||||
|
||||
from pydantic import BaseModel, Field, field_validator
|
||||
|
||||
TenantPlanType = Literal["starter", "pro", "enterprise"]
|
||||
|
||||
|
||||
class TenantCreateRequest(BaseModel):
|
||||
slug: str = Field(min_length=3, max_length=63, pattern=r"^[a-z0-9-]+$")
|
||||
name: str = Field(min_length=2, max_length=255)
|
||||
plan: TenantPlanType = "starter"
|
||||
max_mailboxes: int = Field(default=5, ge=1, le=1000)
|
||||
max_users: int = Field(default=10, ge=1, le=1000)
|
||||
|
||||
# Utente admin iniziale
|
||||
admin_email: str
|
||||
admin_password: str = Field(min_length=8)
|
||||
admin_full_name: str = Field(min_length=2, max_length=255)
|
||||
|
||||
@field_validator("slug")
|
||||
@classmethod
|
||||
def validate_slug(cls, v: str) -> str:
|
||||
reserved = {"api", "admin", "www", "mail", "smtp", "imap", "pecflow", "app"}
|
||||
if v in reserved:
|
||||
raise ValueError(f"Slug '{v}' riservato")
|
||||
return v.lower()
|
||||
|
||||
|
||||
class TenantUpdateRequest(BaseModel):
|
||||
name: str | None = Field(default=None, min_length=2, max_length=255)
|
||||
plan: TenantPlanType | None = None
|
||||
is_active: bool | None = None
|
||||
max_mailboxes: int | None = Field(default=None, ge=1, le=1000)
|
||||
max_users: int | None = Field(default=None, ge=1, le=1000)
|
||||
|
||||
|
||||
class TenantResponse(BaseModel):
|
||||
id: uuid.UUID
|
||||
slug: str
|
||||
name: str
|
||||
plan: str
|
||||
is_active: bool
|
||||
max_mailboxes: int
|
||||
max_users: int
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
|
||||
model_config = {"from_attributes": True}
|
||||
@@ -0,0 +1,89 @@
|
||||
"""
|
||||
Schema Pydantic per utenti.
|
||||
"""
|
||||
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
from typing import Literal
|
||||
|
||||
from pydantic import BaseModel, EmailStr, Field, field_validator
|
||||
|
||||
UserRoleType = Literal["super_admin", "admin", "supervisor", "operator", "readonly"]
|
||||
|
||||
|
||||
# ─── Request ──────────────────────────────────────────────────────────────────
|
||||
|
||||
class UserCreateRequest(BaseModel):
|
||||
email: EmailStr
|
||||
password: str = Field(min_length=8)
|
||||
full_name: str = Field(min_length=2, max_length=255)
|
||||
role: UserRoleType = "operator"
|
||||
|
||||
@field_validator("password")
|
||||
@classmethod
|
||||
def validate_password(cls, v: str) -> str:
|
||||
if len(v) < 8:
|
||||
raise ValueError("La password deve essere almeno 8 caratteri")
|
||||
if not any(c.isupper() for c in v):
|
||||
raise ValueError("Almeno una lettera maiuscola richiesta")
|
||||
if not any(c.isdigit() for c in v):
|
||||
raise ValueError("Almeno un numero richiesto")
|
||||
return v
|
||||
|
||||
@field_validator("role")
|
||||
@classmethod
|
||||
def validate_role_not_superadmin(cls, v: str) -> str:
|
||||
if v == "super_admin":
|
||||
raise ValueError("Non è possibile creare utenti super_admin via API")
|
||||
return v
|
||||
|
||||
|
||||
class UserUpdateRequest(BaseModel):
|
||||
full_name: str | None = Field(default=None, min_length=2, max_length=255)
|
||||
role: UserRoleType | None = None
|
||||
is_active: bool | None = None
|
||||
|
||||
@field_validator("role")
|
||||
@classmethod
|
||||
def validate_role_not_superadmin(cls, v: str | None) -> str | None:
|
||||
if v == "super_admin":
|
||||
raise ValueError("Non è possibile assegnare il ruolo super_admin via API")
|
||||
return v
|
||||
|
||||
|
||||
class UserPasswordResetRequest(BaseModel):
|
||||
new_password: str = Field(min_length=8)
|
||||
|
||||
@field_validator("new_password")
|
||||
@classmethod
|
||||
def validate_password(cls, v: str) -> str:
|
||||
if not any(c.isupper() for c in v):
|
||||
raise ValueError("Almeno una lettera maiuscola richiesta")
|
||||
if not any(c.isdigit() for c in v):
|
||||
raise ValueError("Almeno un numero richiesto")
|
||||
return v
|
||||
|
||||
|
||||
# ─── Response ─────────────────────────────────────────────────────────────────
|
||||
|
||||
class UserResponse(BaseModel):
|
||||
id: uuid.UUID
|
||||
tenant_id: uuid.UUID
|
||||
email: str
|
||||
full_name: str
|
||||
role: str
|
||||
is_active: bool
|
||||
totp_enabled: bool
|
||||
last_login_at: datetime | None
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
|
||||
model_config = {"from_attributes": True}
|
||||
|
||||
|
||||
class UserListResponse(BaseModel):
|
||||
items: list[UserResponse]
|
||||
total: int
|
||||
page: int
|
||||
page_size: int
|
||||
pages: int
|
||||
@@ -0,0 +1 @@
|
||||
# Services
|
||||
@@ -0,0 +1,302 @@
|
||||
"""
|
||||
Servizio autenticazione – login, JWT, TOTP 2FA, refresh token.
|
||||
"""
|
||||
|
||||
import base64
|
||||
import io
|
||||
import uuid
|
||||
from datetime import UTC, datetime, timedelta
|
||||
|
||||
import pyotp
|
||||
import qrcode
|
||||
from jose import JWTError
|
||||
from sqlalchemy import select, update
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.config import get_settings
|
||||
from app.core.exceptions import (
|
||||
AccountDisabledError,
|
||||
AccountLockedError,
|
||||
InvalidCredentialsError,
|
||||
TOTPInvalidError,
|
||||
TOTPRequiredError,
|
||||
TokenInvalidError,
|
||||
)
|
||||
from app.core.security import (
|
||||
create_access_token,
|
||||
create_refresh_token,
|
||||
decode_token,
|
||||
encrypt_credential,
|
||||
decrypt_credential,
|
||||
hash_password,
|
||||
hash_token,
|
||||
verify_password,
|
||||
)
|
||||
from app.models.audit_log import AuditLog
|
||||
from app.models.user import RefreshToken, User
|
||||
|
||||
settings = get_settings()
|
||||
|
||||
# Numero massimo di tentativi falliti prima del blocco
|
||||
MAX_FAILED_ATTEMPTS = 5
|
||||
LOCK_DURATION_MINUTES = 15
|
||||
|
||||
|
||||
class AuthService:
|
||||
def __init__(self, db: AsyncSession) -> None:
|
||||
self.db = db
|
||||
|
||||
async def login(
|
||||
self,
|
||||
email: str,
|
||||
password: str,
|
||||
totp_code: str | None,
|
||||
ip_address: str | None = None,
|
||||
user_agent: str | None = None,
|
||||
) -> tuple[str, str]:
|
||||
"""
|
||||
Autentica l'utente con email + password (+ TOTP se abilitato).
|
||||
Restituisce (access_token, refresh_token).
|
||||
"""
|
||||
# 1. Trova utente per email (non filtrare per tenant: l'email è unica globalmente
|
||||
# per ora, ma in futuro si potrebbe filtrare per subdomain)
|
||||
user = await self._get_user_by_email(email)
|
||||
|
||||
if not user:
|
||||
await self._log_audit(None, None, "auth.login", "failure", ip_address, {"reason": "user_not_found"})
|
||||
raise InvalidCredentialsError()
|
||||
|
||||
# 2. Verifica account attivo
|
||||
if not user.is_active:
|
||||
raise AccountDisabledError()
|
||||
|
||||
# 3. Verifica blocco temporaneo
|
||||
if user.locked_until and user.locked_until > datetime.now(UTC):
|
||||
locked_str = user.locked_until.strftime("%H:%M")
|
||||
raise AccountLockedError(locked_until=locked_str)
|
||||
|
||||
# 4. Verifica password
|
||||
if not verify_password(password, user.password_hash):
|
||||
await self._handle_failed_login(user)
|
||||
await self._log_audit(user.tenant_id, user.id, "auth.login", "failure", ip_address, {"reason": "wrong_password"})
|
||||
raise InvalidCredentialsError()
|
||||
|
||||
# 5. Verifica TOTP (se abilitato)
|
||||
if user.totp_enabled:
|
||||
if not totp_code:
|
||||
raise TOTPRequiredError()
|
||||
if not self._verify_totp(user, totp_code):
|
||||
await self._log_audit(user.tenant_id, user.id, "auth.login", "failure", ip_address, {"reason": "invalid_totp"})
|
||||
raise TOTPInvalidError()
|
||||
|
||||
# 6. Reset contatori falliti
|
||||
await self._reset_failed_login(user)
|
||||
|
||||
# 7. Genera token
|
||||
access_token = create_access_token(
|
||||
subject=user.id,
|
||||
tenant_id=user.tenant_id,
|
||||
role=user.role,
|
||||
)
|
||||
refresh_token_raw = create_refresh_token(
|
||||
subject=user.id,
|
||||
tenant_id=user.tenant_id,
|
||||
)
|
||||
|
||||
# 8. Salva refresh token in DB (hash)
|
||||
rt = RefreshToken(
|
||||
user_id=user.id,
|
||||
token_hash=hash_token(refresh_token_raw),
|
||||
expires_at=datetime.now(UTC) + timedelta(days=settings.refresh_token_expire_days),
|
||||
user_agent=user_agent,
|
||||
ip_address=ip_address,
|
||||
)
|
||||
self.db.add(rt)
|
||||
|
||||
# 9. Aggiorna last_login_at
|
||||
await self.db.execute(
|
||||
update(User)
|
||||
.where(User.id == user.id)
|
||||
.values(last_login_at=datetime.now(UTC))
|
||||
)
|
||||
|
||||
await self._log_audit(user.tenant_id, user.id, "auth.login", "success", ip_address, {})
|
||||
|
||||
return access_token, refresh_token_raw
|
||||
|
||||
async def refresh_tokens(self, refresh_token_raw: str) -> tuple[str, str]:
|
||||
"""
|
||||
Valida il refresh token e restituisce nuova coppia di token.
|
||||
Implementa rotation: il vecchio refresh token viene revocato.
|
||||
"""
|
||||
# Valida struttura JWT
|
||||
try:
|
||||
payload = decode_token(refresh_token_raw)
|
||||
except JWTError:
|
||||
raise TokenInvalidError()
|
||||
|
||||
if payload.get("type") != "refresh":
|
||||
raise TokenInvalidError()
|
||||
|
||||
# Cerca il token in DB
|
||||
token_hash = hash_token(refresh_token_raw)
|
||||
result = await self.db.execute(
|
||||
select(RefreshToken).where(RefreshToken.token_hash == token_hash)
|
||||
)
|
||||
rt = result.scalar_one_or_none()
|
||||
|
||||
if not rt or not rt.is_valid:
|
||||
raise TokenInvalidError()
|
||||
|
||||
# Carica l'utente
|
||||
user_result = await self.db.execute(
|
||||
select(User).where(User.id == rt.user_id)
|
||||
)
|
||||
user = user_result.scalar_one_or_none()
|
||||
|
||||
if not user or not user.is_active:
|
||||
raise TokenInvalidError()
|
||||
|
||||
# Revoca il vecchio refresh token (rotation)
|
||||
rt.revoked_at = datetime.now(UTC)
|
||||
|
||||
# Genera nuovi token
|
||||
new_access = create_access_token(
|
||||
subject=user.id,
|
||||
tenant_id=user.tenant_id,
|
||||
role=user.role,
|
||||
)
|
||||
new_refresh_raw = create_refresh_token(
|
||||
subject=user.id,
|
||||
tenant_id=user.tenant_id,
|
||||
)
|
||||
|
||||
# Salva nuovo refresh token
|
||||
new_rt = RefreshToken(
|
||||
user_id=user.id,
|
||||
token_hash=hash_token(new_refresh_raw),
|
||||
expires_at=datetime.now(UTC) + timedelta(days=settings.refresh_token_expire_days),
|
||||
ip_address=rt.ip_address,
|
||||
)
|
||||
self.db.add(new_rt)
|
||||
|
||||
return new_access, new_refresh_raw
|
||||
|
||||
async def logout(self, refresh_token_raw: str) -> None:
|
||||
"""Revoca il refresh token (logout)."""
|
||||
token_hash = hash_token(refresh_token_raw)
|
||||
result = await self.db.execute(
|
||||
select(RefreshToken).where(RefreshToken.token_hash == token_hash)
|
||||
)
|
||||
rt = result.scalar_one_or_none()
|
||||
if rt:
|
||||
rt.revoked_at = datetime.now(UTC)
|
||||
|
||||
async def setup_totp(self, user: User) -> dict:
|
||||
"""
|
||||
Genera segreto TOTP e QR code per l'utente.
|
||||
Il segreto viene cifrato e salvato in DB ma TOTP non è ancora attivo
|
||||
(richiede verifica con totp_verify).
|
||||
"""
|
||||
# Genera segreto base32
|
||||
secret = pyotp.random_base32()
|
||||
|
||||
# Cifra il segreto prima di salvarlo
|
||||
encrypted_secret = encrypt_credential(secret)
|
||||
user.totp_secret = encrypted_secret
|
||||
# Non attivare ancora: richiede verifica
|
||||
user.totp_enabled = False
|
||||
|
||||
# Genera URI otpauth://
|
||||
totp = pyotp.TOTP(secret)
|
||||
uri = totp.provisioning_uri(name=user.email, issuer_name="PecFlow")
|
||||
|
||||
# Genera QR code
|
||||
qr = qrcode.QRCode(version=1, box_size=6, border=4)
|
||||
qr.add_data(uri)
|
||||
qr.make(fit=True)
|
||||
img = qr.make_image(fill_color="black", back_color="white")
|
||||
buffered = io.BytesIO()
|
||||
img.save(buffered, format="PNG")
|
||||
qr_b64 = base64.b64encode(buffered.getvalue()).decode("ascii")
|
||||
|
||||
return {
|
||||
"secret": secret,
|
||||
"qr_uri": uri,
|
||||
"qr_image_base64": f"data:image/png;base64,{qr_b64}",
|
||||
}
|
||||
|
||||
async def verify_and_enable_totp(self, user: User, totp_code: str) -> bool:
|
||||
"""
|
||||
Verifica il codice TOTP e attiva il 2FA se corretto.
|
||||
"""
|
||||
if not user.totp_secret:
|
||||
return False
|
||||
|
||||
if not self._verify_totp(user, totp_code):
|
||||
raise TOTPInvalidError()
|
||||
|
||||
user.totp_enabled = True
|
||||
return True
|
||||
|
||||
async def disable_totp(self, user: User) -> None:
|
||||
"""Disabilita il 2FA per l'utente."""
|
||||
user.totp_secret = None
|
||||
user.totp_enabled = False
|
||||
|
||||
# ─── Private helpers ──────────────────────────────────────────────────────
|
||||
|
||||
async def _get_user_by_email(self, email: str) -> User | None:
|
||||
result = await self.db.execute(
|
||||
select(User).where(User.email == email.lower())
|
||||
)
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
def _verify_totp(self, user: User, code: str) -> bool:
|
||||
"""Verifica il codice TOTP (accetta ±1 intervallo per clock skew)."""
|
||||
if not user.totp_secret:
|
||||
return False
|
||||
try:
|
||||
secret = decrypt_credential(user.totp_secret)
|
||||
totp = pyotp.TOTP(secret)
|
||||
return totp.verify(code, valid_window=1)
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
async def _handle_failed_login(self, user: User) -> None:
|
||||
"""Incrementa contatore fallimenti, blocca se necessario."""
|
||||
new_count = user.failed_login_count + 1
|
||||
updates: dict = {"failed_login_count": new_count}
|
||||
|
||||
if new_count >= MAX_FAILED_ATTEMPTS:
|
||||
updates["locked_until"] = datetime.now(UTC) + timedelta(minutes=LOCK_DURATION_MINUTES)
|
||||
|
||||
await self.db.execute(
|
||||
update(User).where(User.id == user.id).values(**updates)
|
||||
)
|
||||
|
||||
async def _reset_failed_login(self, user: User) -> None:
|
||||
await self.db.execute(
|
||||
update(User)
|
||||
.where(User.id == user.id)
|
||||
.values(failed_login_count=0, locked_until=None)
|
||||
)
|
||||
|
||||
async def _log_audit(
|
||||
self,
|
||||
tenant_id: uuid.UUID | None,
|
||||
user_id: uuid.UUID | None,
|
||||
action: str,
|
||||
outcome: str,
|
||||
ip_address: str | None,
|
||||
payload: dict,
|
||||
) -> None:
|
||||
log = AuditLog(
|
||||
tenant_id=tenant_id,
|
||||
user_id=user_id,
|
||||
action=action,
|
||||
outcome=outcome,
|
||||
ip_address=ip_address,
|
||||
payload=payload,
|
||||
)
|
||||
self.db.add(log)
|
||||
@@ -0,0 +1,236 @@
|
||||
"""
|
||||
Servizio permessi granulari – gestione accessi utente × casella (Fase 1-A).
|
||||
|
||||
Gerarchia:
|
||||
super_admin / admin → accesso implicito a tutto (no record in mailbox_permissions)
|
||||
supervisor / operator / readonly → richiedono record esplicito
|
||||
"""
|
||||
|
||||
import uuid
|
||||
|
||||
from sqlalchemy import delete, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.core.exceptions import ConflictError, ForbiddenError, NotFoundError, PermissionDeniedError
|
||||
from app.models.mailbox import Mailbox
|
||||
from app.models.permission import MailboxPermission
|
||||
from app.models.user import User
|
||||
from app.schemas.permission import PermissionGrantRequest
|
||||
|
||||
|
||||
class PermissionService:
|
||||
def __init__(self, db: AsyncSession) -> None:
|
||||
self.db = db
|
||||
|
||||
# ─── Verifica accessi ─────────────────────────────────────────────────────
|
||||
|
||||
async def get_visible_mailboxes(
|
||||
self, user: User
|
||||
) -> list[uuid.UUID]:
|
||||
"""Restituisce gli UUID delle caselle visibili all'utente."""
|
||||
if user.role in ("super_admin", "admin"):
|
||||
# Admin vede tutte le caselle del tenant
|
||||
result = await self.db.execute(
|
||||
select(Mailbox.id).where(
|
||||
Mailbox.tenant_id == user.tenant_id,
|
||||
Mailbox.status != "deleted",
|
||||
)
|
||||
)
|
||||
return [row[0] for row in result.all()]
|
||||
|
||||
# Operatori: solo caselle con can_read=True
|
||||
result = await self.db.execute(
|
||||
select(MailboxPermission.mailbox_id).where(
|
||||
MailboxPermission.user_id == user.id,
|
||||
MailboxPermission.can_read == True,
|
||||
)
|
||||
)
|
||||
return [row[0] for row in result.all()]
|
||||
|
||||
async def check_can_read(
|
||||
self, user: User, mailbox_id: uuid.UUID
|
||||
) -> bool:
|
||||
"""Verifica se l'utente può leggere i messaggi della casella."""
|
||||
if user.role in ("super_admin", "admin"):
|
||||
# Verifica solo che la casella appartenga al tenant
|
||||
return await self._mailbox_belongs_to_tenant(mailbox_id, user.tenant_id)
|
||||
|
||||
perm = await self._get_permission(user.id, mailbox_id)
|
||||
return perm is not None and perm.can_read
|
||||
|
||||
async def check_can_send(
|
||||
self, user: User, mailbox_id: uuid.UUID
|
||||
) -> bool:
|
||||
"""Verifica se l'utente può inviare dalla casella."""
|
||||
if user.role in ("super_admin", "admin"):
|
||||
return await self._mailbox_belongs_to_tenant(mailbox_id, user.tenant_id)
|
||||
|
||||
perm = await self._get_permission(user.id, mailbox_id)
|
||||
return perm is not None and perm.can_send
|
||||
|
||||
async def check_can_manage(
|
||||
self, user: User, mailbox_id: uuid.UUID
|
||||
) -> bool:
|
||||
"""Verifica se l'utente può gestire la configurazione della casella."""
|
||||
if user.role in ("super_admin", "admin"):
|
||||
return await self._mailbox_belongs_to_tenant(mailbox_id, user.tenant_id)
|
||||
|
||||
perm = await self._get_permission(user.id, mailbox_id)
|
||||
return perm is not None and perm.can_manage
|
||||
|
||||
async def require_can_read(self, user: User, mailbox_id: uuid.UUID) -> None:
|
||||
"""Solleva 403 se l'utente non può leggere."""
|
||||
if not await self.check_can_read(user, mailbox_id):
|
||||
raise PermissionDeniedError("casella")
|
||||
|
||||
async def require_can_send(self, user: User, mailbox_id: uuid.UUID) -> None:
|
||||
if not await self.check_can_send(user, mailbox_id):
|
||||
raise PermissionDeniedError("casella (invio)")
|
||||
|
||||
# ─── CRUD permessi ────────────────────────────────────────────────────────
|
||||
|
||||
async def grant_permission(
|
||||
self,
|
||||
tenant_id: uuid.UUID,
|
||||
mailbox_id: uuid.UUID,
|
||||
user_id: uuid.UUID,
|
||||
data: PermissionGrantRequest,
|
||||
granted_by: User,
|
||||
) -> MailboxPermission:
|
||||
"""
|
||||
Crea o aggiorna un permesso utente su una casella.
|
||||
Solo admin può gestire i permessi.
|
||||
"""
|
||||
if not granted_by.is_admin:
|
||||
raise ForbiddenError("Solo gli amministratori possono gestire i permessi")
|
||||
|
||||
# Verifica che casella e utente appartengano al tenant
|
||||
mailbox = await self.db.get(Mailbox, mailbox_id)
|
||||
if not mailbox or mailbox.tenant_id != tenant_id:
|
||||
raise NotFoundError("casella")
|
||||
|
||||
target_user = await self.db.get(User, user_id)
|
||||
if not target_user or target_user.tenant_id != tenant_id:
|
||||
raise NotFoundError("utente")
|
||||
|
||||
# Non serve permesso esplicito per admin
|
||||
if target_user.role in ("super_admin", "admin"):
|
||||
raise ForbiddenError("Gli admin hanno già accesso implicito a tutte le caselle")
|
||||
|
||||
# Cerca permesso esistente (upsert)
|
||||
existing = await self._get_permission(user_id, mailbox_id)
|
||||
if existing:
|
||||
existing.can_read = data.can_read
|
||||
existing.can_send = data.can_send
|
||||
existing.can_manage = data.can_manage
|
||||
existing.granted_by = granted_by.id
|
||||
return existing
|
||||
|
||||
perm = MailboxPermission(
|
||||
tenant_id=tenant_id,
|
||||
user_id=user_id,
|
||||
mailbox_id=mailbox_id,
|
||||
can_read=data.can_read,
|
||||
can_send=data.can_send,
|
||||
can_manage=data.can_manage,
|
||||
granted_by=granted_by.id,
|
||||
)
|
||||
self.db.add(perm)
|
||||
await self.db.flush()
|
||||
return perm
|
||||
|
||||
async def revoke_permission(
|
||||
self,
|
||||
mailbox_id: uuid.UUID,
|
||||
user_id: uuid.UUID,
|
||||
revoked_by: User,
|
||||
) -> None:
|
||||
if not revoked_by.is_admin:
|
||||
raise ForbiddenError("Solo gli amministratori possono revocare i permessi")
|
||||
|
||||
result = await self.db.execute(
|
||||
delete(MailboxPermission).where(
|
||||
MailboxPermission.mailbox_id == mailbox_id,
|
||||
MailboxPermission.user_id == user_id,
|
||||
)
|
||||
)
|
||||
if result.rowcount == 0:
|
||||
raise NotFoundError("permesso")
|
||||
|
||||
async def list_mailbox_users(
|
||||
self, mailbox_id: uuid.UUID, tenant_id: uuid.UUID
|
||||
) -> list[dict]:
|
||||
"""Ritorna tutti gli utenti con permesso esplicito su questa casella."""
|
||||
result = await self.db.execute(
|
||||
select(MailboxPermission, User)
|
||||
.join(User, MailboxPermission.user_id == User.id)
|
||||
.where(
|
||||
MailboxPermission.mailbox_id == mailbox_id,
|
||||
MailboxPermission.tenant_id == tenant_id,
|
||||
)
|
||||
)
|
||||
rows = result.all()
|
||||
return [
|
||||
{
|
||||
"user_id": perm.user_id,
|
||||
"user_email": user.email,
|
||||
"user_full_name": user.full_name,
|
||||
"user_role": user.role,
|
||||
"can_read": perm.can_read,
|
||||
"can_send": perm.can_send,
|
||||
"can_manage": perm.can_manage,
|
||||
"granted_at": perm.granted_at,
|
||||
}
|
||||
for perm, user in rows
|
||||
]
|
||||
|
||||
async def list_user_mailboxes(
|
||||
self, user_id: uuid.UUID, tenant_id: uuid.UUID
|
||||
) -> list[dict]:
|
||||
"""Ritorna tutte le caselle accessibili a un utente (permessi espliciti)."""
|
||||
result = await self.db.execute(
|
||||
select(MailboxPermission, Mailbox)
|
||||
.join(Mailbox, MailboxPermission.mailbox_id == Mailbox.id)
|
||||
.where(
|
||||
MailboxPermission.user_id == user_id,
|
||||
MailboxPermission.tenant_id == tenant_id,
|
||||
MailboxPermission.can_read == True,
|
||||
)
|
||||
)
|
||||
rows = result.all()
|
||||
return [
|
||||
{
|
||||
"mailbox_id": perm.mailbox_id,
|
||||
"mailbox_email": mailbox.email_address,
|
||||
"mailbox_display_name": mailbox.display_name,
|
||||
"can_read": perm.can_read,
|
||||
"can_send": perm.can_send,
|
||||
"can_manage": perm.can_manage,
|
||||
}
|
||||
for perm, mailbox in rows
|
||||
]
|
||||
|
||||
# ─── Private ──────────────────────────────────────────────────────────────
|
||||
|
||||
async def _get_permission(
|
||||
self, user_id: uuid.UUID, mailbox_id: uuid.UUID
|
||||
) -> MailboxPermission | None:
|
||||
result = await self.db.execute(
|
||||
select(MailboxPermission).where(
|
||||
MailboxPermission.user_id == user_id,
|
||||
MailboxPermission.mailbox_id == mailbox_id,
|
||||
)
|
||||
)
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
async def _mailbox_belongs_to_tenant(
|
||||
self, mailbox_id: uuid.UUID, tenant_id: uuid.UUID
|
||||
) -> bool:
|
||||
result = await self.db.execute(
|
||||
select(Mailbox.id).where(
|
||||
Mailbox.id == mailbox_id,
|
||||
Mailbox.tenant_id == tenant_id,
|
||||
Mailbox.status != "deleted",
|
||||
)
|
||||
)
|
||||
return result.scalar_one_or_none() is not None
|
||||
@@ -0,0 +1,81 @@
|
||||
"""
|
||||
Servizio tenant – gestione organizzazioni (solo super_admin).
|
||||
"""
|
||||
|
||||
import uuid
|
||||
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.core.exceptions import ConflictError, NotFoundError
|
||||
from app.core.security import hash_password
|
||||
from app.models.tenant import Tenant
|
||||
from app.models.user import User
|
||||
from app.schemas.tenant import TenantCreateRequest, TenantUpdateRequest
|
||||
|
||||
|
||||
class TenantService:
|
||||
def __init__(self, db: AsyncSession) -> None:
|
||||
self.db = db
|
||||
|
||||
async def create_tenant(self, data: TenantCreateRequest) -> tuple[Tenant, User]:
|
||||
"""Crea un nuovo tenant con il suo utente admin iniziale."""
|
||||
# Verifica slug univoco
|
||||
existing = await self.db.execute(
|
||||
select(Tenant).where(Tenant.slug == data.slug)
|
||||
)
|
||||
if existing.scalar_one_or_none():
|
||||
raise ConflictError(f"Slug '{data.slug}' già in uso")
|
||||
|
||||
tenant = Tenant(
|
||||
slug=data.slug,
|
||||
name=data.name,
|
||||
plan=data.plan,
|
||||
max_mailboxes=data.max_mailboxes,
|
||||
max_users=data.max_users,
|
||||
)
|
||||
self.db.add(tenant)
|
||||
await self.db.flush() # ottieni tenant.id
|
||||
|
||||
# Crea utente admin iniziale
|
||||
admin = User(
|
||||
tenant_id=tenant.id,
|
||||
email=data.admin_email.lower(),
|
||||
password_hash=hash_password(data.admin_password),
|
||||
full_name=data.admin_full_name,
|
||||
role="admin",
|
||||
)
|
||||
self.db.add(admin)
|
||||
await self.db.flush()
|
||||
|
||||
return tenant, admin
|
||||
|
||||
async def get_tenant(self, tenant_id: uuid.UUID) -> Tenant:
|
||||
tenant = await self.db.get(Tenant, tenant_id)
|
||||
if not tenant:
|
||||
raise NotFoundError("tenant")
|
||||
return tenant
|
||||
|
||||
async def list_tenants(self) -> list[Tenant]:
|
||||
result = await self.db.execute(
|
||||
select(Tenant).order_by(Tenant.created_at.desc())
|
||||
)
|
||||
return list(result.scalars().all())
|
||||
|
||||
async def update_tenant(
|
||||
self, tenant_id: uuid.UUID, data: TenantUpdateRequest
|
||||
) -> Tenant:
|
||||
tenant = await self.get_tenant(tenant_id)
|
||||
|
||||
if data.name is not None:
|
||||
tenant.name = data.name
|
||||
if data.plan is not None:
|
||||
tenant.plan = data.plan
|
||||
if data.is_active is not None:
|
||||
tenant.is_active = data.is_active
|
||||
if data.max_mailboxes is not None:
|
||||
tenant.max_mailboxes = data.max_mailboxes
|
||||
if data.max_users is not None:
|
||||
tenant.max_users = data.max_users
|
||||
|
||||
return tenant
|
||||
@@ -0,0 +1,145 @@
|
||||
"""
|
||||
Servizio utenti – CRUD utenti per admin del tenant.
|
||||
"""
|
||||
|
||||
import math
|
||||
import uuid
|
||||
|
||||
from sqlalchemy import func, select, update
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.core.exceptions import ConflictError, ForbiddenError, NotFoundError, TenantLimitExceededError
|
||||
from app.core.security import hash_password
|
||||
from app.models.tenant import Tenant
|
||||
from app.models.user import User
|
||||
from app.schemas.user import UserCreateRequest, UserUpdateRequest
|
||||
|
||||
|
||||
class UserService:
|
||||
def __init__(self, db: AsyncSession) -> None:
|
||||
self.db = db
|
||||
|
||||
async def create_user(
|
||||
self,
|
||||
tenant_id: uuid.UUID,
|
||||
data: UserCreateRequest,
|
||||
created_by: User,
|
||||
) -> User:
|
||||
"""Crea un nuovo utente nel tenant. Solo admin può farlo."""
|
||||
# Verifica limite utenti del piano
|
||||
tenant = await self.db.get(Tenant, tenant_id)
|
||||
if not tenant:
|
||||
raise NotFoundError("tenant")
|
||||
|
||||
user_count_result = await self.db.execute(
|
||||
select(func.count()).where(User.tenant_id == tenant_id, User.is_active == True)
|
||||
)
|
||||
count = user_count_result.scalar_one()
|
||||
if count >= tenant.max_users:
|
||||
raise TenantLimitExceededError("utenti", tenant.max_users)
|
||||
|
||||
# Verifica email univoca nel tenant
|
||||
existing = await self.db.execute(
|
||||
select(User).where(
|
||||
User.tenant_id == tenant_id,
|
||||
User.email == data.email.lower(),
|
||||
)
|
||||
)
|
||||
if existing.scalar_one_or_none():
|
||||
raise ConflictError(f"Email '{data.email}' già registrata in questo tenant")
|
||||
|
||||
# Un admin non può creare un super_admin
|
||||
if data.role == "super_admin" and not created_by.is_super_admin:
|
||||
raise ForbiddenError("Non puoi creare utenti super_admin")
|
||||
|
||||
user = User(
|
||||
tenant_id=tenant_id,
|
||||
email=data.email.lower(),
|
||||
password_hash=hash_password(data.password),
|
||||
full_name=data.full_name,
|
||||
role=data.role,
|
||||
)
|
||||
self.db.add(user)
|
||||
await self.db.flush() # ottieni l'ID
|
||||
return user
|
||||
|
||||
async def get_user(self, user_id: uuid.UUID, tenant_id: uuid.UUID) -> User:
|
||||
result = await self.db.execute(
|
||||
select(User).where(User.id == user_id, User.tenant_id == tenant_id)
|
||||
)
|
||||
user = result.scalar_one_or_none()
|
||||
if not user:
|
||||
raise NotFoundError("utente")
|
||||
return user
|
||||
|
||||
async def list_users(
|
||||
self,
|
||||
tenant_id: uuid.UUID,
|
||||
page: int = 1,
|
||||
page_size: int = 25,
|
||||
) -> tuple[list[User], int]:
|
||||
"""Restituisce lista utenti paginata + totale."""
|
||||
offset = (page - 1) * page_size
|
||||
|
||||
total_result = await self.db.execute(
|
||||
select(func.count()).where(User.tenant_id == tenant_id)
|
||||
)
|
||||
total = total_result.scalar_one()
|
||||
|
||||
users_result = await self.db.execute(
|
||||
select(User)
|
||||
.where(User.tenant_id == tenant_id)
|
||||
.order_by(User.created_at.desc())
|
||||
.offset(offset)
|
||||
.limit(page_size)
|
||||
)
|
||||
users = list(users_result.scalars().all())
|
||||
|
||||
return users, total
|
||||
|
||||
async def update_user(
|
||||
self,
|
||||
user_id: uuid.UUID,
|
||||
tenant_id: uuid.UUID,
|
||||
data: UserUpdateRequest,
|
||||
updated_by: User,
|
||||
) -> User:
|
||||
user = await self.get_user(user_id, tenant_id)
|
||||
|
||||
# Non si può modificare un super_admin
|
||||
if user.is_super_admin and not updated_by.is_super_admin:
|
||||
raise ForbiddenError("Non puoi modificare un super_admin")
|
||||
|
||||
if data.full_name is not None:
|
||||
user.full_name = data.full_name
|
||||
if data.role is not None:
|
||||
user.role = data.role
|
||||
if data.is_active is not None:
|
||||
user.is_active = data.is_active
|
||||
|
||||
return user
|
||||
|
||||
async def reset_password(
|
||||
self,
|
||||
user_id: uuid.UUID,
|
||||
tenant_id: uuid.UUID,
|
||||
new_password: str,
|
||||
) -> None:
|
||||
user = await self.get_user(user_id, tenant_id)
|
||||
user.password_hash = hash_password(new_password)
|
||||
|
||||
async def delete_user(
|
||||
self,
|
||||
user_id: uuid.UUID,
|
||||
tenant_id: uuid.UUID,
|
||||
deleted_by: User,
|
||||
) -> None:
|
||||
user = await self.get_user(user_id, tenant_id)
|
||||
|
||||
if user.id == deleted_by.id:
|
||||
raise ForbiddenError("Non puoi eliminare il tuo stesso account")
|
||||
if user.is_super_admin:
|
||||
raise ForbiddenError("Non puoi eliminare un super_admin")
|
||||
|
||||
# Soft delete (disabilita invece di eliminare)
|
||||
user.is_active = False
|
||||
@@ -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
|
||||
@@ -0,0 +1 @@
|
||||
# Tests
|
||||
@@ -0,0 +1 @@
|
||||
# Integration tests
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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"
|
||||
@@ -0,0 +1 @@
|
||||
# Unit tests
|
||||
@@ -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)
|
||||
@@ -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
|
||||
@@ -0,0 +1,8 @@
|
||||
-- Estensioni PostgreSQL richieste da PecFlow
|
||||
-- Questo script viene eseguito automaticamente da Docker al primo avvio
|
||||
|
||||
CREATE EXTENSION IF NOT EXISTS "uuid-ossp";
|
||||
CREATE EXTENSION IF NOT EXISTS "pgcrypto";
|
||||
|
||||
-- Permette SET LOCAL per RLS (app.current_tenant_id)
|
||||
ALTER DATABASE pecflow SET "app.current_tenant_id" TO '';
|
||||
@@ -0,0 +1,80 @@
|
||||
-- ============================================================
|
||||
-- SEED: Tenant demo + utenti per sviluppo locale
|
||||
--
|
||||
-- Credenziali:
|
||||
-- Admin: admin@demo.pecflow.it / Demo@PecFlow2026!
|
||||
-- Operator: operator@demo.pecflow.it / Oper@PecFlow2026!
|
||||
--
|
||||
-- Esegui con: make seed
|
||||
-- ============================================================
|
||||
|
||||
-- Disabilita RLS temporaneamente per il seed
|
||||
SET session_replication_role = replica;
|
||||
|
||||
-- Tenant demo
|
||||
INSERT INTO tenants (id, slug, name, plan, is_active, max_mailboxes, max_users)
|
||||
VALUES (
|
||||
'11111111-1111-1111-1111-111111111111',
|
||||
'demo',
|
||||
'Demo Azienda SRL',
|
||||
'pro',
|
||||
TRUE,
|
||||
10,
|
||||
20
|
||||
)
|
||||
ON CONFLICT (slug) DO NOTHING;
|
||||
|
||||
-- Utente super_admin (global, senza tenant specifico usa il tenant demo)
|
||||
-- Password: SuperAdmin@PecFlow2026! (bcrypt hash)
|
||||
INSERT INTO users (id, tenant_id, email, password_hash, full_name, role, is_active)
|
||||
VALUES (
|
||||
'00000000-0000-0000-0000-000000000001',
|
||||
'11111111-1111-1111-1111-111111111111',
|
||||
'superadmin@pecflow.it',
|
||||
'$2b$12$y2yq6X2f3dZi22wqWZd1aumP03IU6OWrrevRMFj9054aGnUms116W', -- SuperAdmin@PecFlow2026!
|
||||
'Super Admin PecFlow',
|
||||
'super_admin',
|
||||
TRUE
|
||||
)
|
||||
ON CONFLICT (tenant_id, email) DO NOTHING;
|
||||
|
||||
-- Utente admin del tenant demo
|
||||
-- Password: Demo@PecFlow2026! (bcrypt hash)
|
||||
INSERT INTO users (id, tenant_id, email, password_hash, full_name, role, is_active)
|
||||
VALUES (
|
||||
'11111111-0000-0000-0000-000000000001',
|
||||
'11111111-1111-1111-1111-111111111111',
|
||||
'admin@demo.pecflow.it',
|
||||
'$2b$12$PmyaJvF0i7ACFR39k6hfMO2.6U.FVPYma.7OyXyrGuGuokiJOfX8y', -- Demo@PecFlow2026!
|
||||
'Admin Demo',
|
||||
'admin',
|
||||
TRUE
|
||||
)
|
||||
ON CONFLICT (tenant_id, email) DO NOTHING;
|
||||
|
||||
-- Utente operator del tenant demo
|
||||
-- Password: Oper@PecFlow2026! (bcrypt hash)
|
||||
INSERT INTO users (id, tenant_id, email, password_hash, full_name, role, is_active)
|
||||
VALUES (
|
||||
'11111111-0000-0000-0000-000000000002',
|
||||
'11111111-1111-1111-1111-111111111111',
|
||||
'operator@demo.pecflow.it',
|
||||
'$2b$12$Z0REc7flPCD3Sb8fZHsuW.Uk2X4JiJO7HhTajNSuPiQgzppkCDmLu', -- Oper@PecFlow2026!
|
||||
'Operatore Demo',
|
||||
'operator',
|
||||
TRUE
|
||||
)
|
||||
ON CONFLICT (tenant_id, email) DO NOTHING;
|
||||
|
||||
-- Ripristina RLS
|
||||
SET session_replication_role = DEFAULT;
|
||||
|
||||
-- Verifica
|
||||
DO $$
|
||||
BEGIN
|
||||
RAISE NOTICE '✅ Seed completato!';
|
||||
RAISE NOTICE ' Tenant demo: 11111111-1111-1111-1111-111111111111';
|
||||
RAISE NOTICE ' Admin: admin@demo.pecflow.it / Demo@PecFlow2026!';
|
||||
RAISE NOTICE ' Operator: operator@demo.pecflow.it / Oper@PecFlow2026!';
|
||||
END
|
||||
$$;
|
||||
@@ -0,0 +1,153 @@
|
||||
name: pecflow
|
||||
|
||||
services:
|
||||
|
||||
# ─── PostgreSQL 16 ──────────────────────────────────────────────────────────
|
||||
db:
|
||||
image: postgres:16-alpine
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
POSTGRES_DB: pecflow
|
||||
POSTGRES_USER: pecflow
|
||||
POSTGRES_PASSWORD: pecflow_dev_password
|
||||
ports:
|
||||
- "5432:5432"
|
||||
volumes:
|
||||
- postgres_data:/var/lib/postgresql/data
|
||||
- ./database/init:/docker-entrypoint-initdb.d/init:ro
|
||||
- ./database/seeds:/docker-entrypoint-initdb.d/seeds:ro
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pg_isready -U pecflow -d pecflow"]
|
||||
interval: 5s
|
||||
timeout: 5s
|
||||
retries: 10
|
||||
networks:
|
||||
- pecflow_net
|
||||
|
||||
# ─── Redis 7 ────────────────────────────────────────────────────────────────
|
||||
redis:
|
||||
image: redis:7-alpine
|
||||
restart: unless-stopped
|
||||
command: redis-server /usr/local/etc/redis/redis.conf
|
||||
ports:
|
||||
- "6379:6379"
|
||||
volumes:
|
||||
- redis_data:/data
|
||||
- ./infra/redis/redis.conf:/usr/local/etc/redis/redis.conf:ro
|
||||
healthcheck:
|
||||
test: ["CMD", "redis-cli", "ping"]
|
||||
interval: 5s
|
||||
timeout: 3s
|
||||
retries: 10
|
||||
networks:
|
||||
- pecflow_net
|
||||
|
||||
# ─── MinIO (Object Storage S3-compatible) ───────────────────────────────────
|
||||
minio:
|
||||
image: minio/minio:latest
|
||||
restart: unless-stopped
|
||||
command: server /data --console-address ":9001"
|
||||
environment:
|
||||
MINIO_ROOT_USER: minioadmin
|
||||
MINIO_ROOT_PASSWORD: minioadmin
|
||||
ports:
|
||||
- "9000:9000" # API S3
|
||||
- "9001:9001" # Console web
|
||||
volumes:
|
||||
- minio_data:/data
|
||||
healthcheck:
|
||||
test: ["CMD", "mc", "ready", "local"]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
networks:
|
||||
- pecflow_net
|
||||
|
||||
# ─── MinIO bucket initializer ───────────────────────────────────────────────
|
||||
minio-init:
|
||||
image: minio/mc:latest
|
||||
depends_on:
|
||||
minio:
|
||||
condition: service_healthy
|
||||
entrypoint: >
|
||||
/bin/sh -c "
|
||||
mc alias set local http://minio:9000 minioadmin minioadmin &&
|
||||
mc mb --ignore-existing local/pecflow &&
|
||||
mc anonymous set none local/pecflow &&
|
||||
echo 'MinIO bucket pecflow creato'
|
||||
"
|
||||
networks:
|
||||
- pecflow_net
|
||||
|
||||
# ─── Backend FastAPI ─────────────────────────────────────────────────────────
|
||||
backend:
|
||||
build:
|
||||
context: ./backend
|
||||
dockerfile: Dockerfile
|
||||
restart: unless-stopped
|
||||
env_file: .env
|
||||
environment:
|
||||
DATABASE_URL: postgresql+asyncpg://pecflow:pecflow_dev_password@db:5432/pecflow
|
||||
DATABASE_URL_SYNC: postgresql://pecflow:pecflow_dev_password@db:5432/pecflow
|
||||
REDIS_URL: redis://redis:6379/0
|
||||
MINIO_ENDPOINT: minio:9000
|
||||
ports:
|
||||
- "8000:8000"
|
||||
volumes:
|
||||
- ./backend:/app # hot-reload in development
|
||||
depends_on:
|
||||
db:
|
||||
condition: service_healthy
|
||||
redis:
|
||||
condition: service_healthy
|
||||
command: uvicorn app.main:app --host 0.0.0.0 --port 8000 --reload
|
||||
healthcheck:
|
||||
test: ["CMD", "curl", "-f", "http://localhost:8000/health"]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
networks:
|
||||
- pecflow_net
|
||||
|
||||
# ─── Nginx reverse proxy ─────────────────────────────────────────────────────
|
||||
nginx:
|
||||
image: nginx:alpine
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "80:80"
|
||||
volumes:
|
||||
- ./infra/nginx/nginx.conf:/etc/nginx/nginx.conf:ro
|
||||
- ./infra/nginx/conf.d:/etc/nginx/conf.d:ro
|
||||
depends_on:
|
||||
- backend
|
||||
networks:
|
||||
- pecflow_net
|
||||
|
||||
# ─── PgAdmin (solo dev) ──────────────────────────────────────────────────────
|
||||
pgadmin:
|
||||
image: dpage/pgadmin4:latest
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
PGADMIN_DEFAULT_EMAIL: admin@pecflow.it
|
||||
PGADMIN_DEFAULT_PASSWORD: admin
|
||||
PGADMIN_CONFIG_SERVER_MODE: "False"
|
||||
ports:
|
||||
- "5050:80"
|
||||
volumes:
|
||||
- pgadmin_data:/var/lib/pgadmin
|
||||
depends_on:
|
||||
- db
|
||||
profiles:
|
||||
- tools
|
||||
networks:
|
||||
- pecflow_net
|
||||
|
||||
volumes:
|
||||
postgres_data:
|
||||
redis_data:
|
||||
minio_data:
|
||||
pgadmin_data:
|
||||
|
||||
networks:
|
||||
pecflow_net:
|
||||
driver: bridge
|
||||
@@ -0,0 +1,72 @@
|
||||
server {
|
||||
listen 80;
|
||||
server_name localhost;
|
||||
|
||||
# Redirect HTTP → HTTPS in produzione (commentato per dev)
|
||||
# return 301 https://$host$request_uri;
|
||||
|
||||
# ── API Backend ───────────────────────────────────────────────────────────
|
||||
location /api/ {
|
||||
limit_req zone=api burst=20 nodelay;
|
||||
|
||||
proxy_pass http://backend:8000;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_set_header Connection "";
|
||||
|
||||
# Timeout generosi per operazioni lunghe (es. generazione QR)
|
||||
proxy_connect_timeout 30s;
|
||||
proxy_send_timeout 60s;
|
||||
proxy_read_timeout 60s;
|
||||
|
||||
# Upload allegati fino a 50MB
|
||||
client_max_body_size 50m;
|
||||
}
|
||||
|
||||
# ── Auth endpoint con rate limiting più stretto ────────────────────────────
|
||||
location /api/v1/auth/login {
|
||||
limit_req zone=auth burst=5 nodelay;
|
||||
|
||||
proxy_pass http://backend:8000;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
}
|
||||
|
||||
# ── Health check ──────────────────────────────────────────────────────────
|
||||
location /health {
|
||||
proxy_pass http://backend:8000;
|
||||
access_log off;
|
||||
}
|
||||
|
||||
# ── Swagger UI (solo dev) ─────────────────────────────────────────────────
|
||||
location /docs {
|
||||
proxy_pass http://backend:8000;
|
||||
}
|
||||
location /redoc {
|
||||
proxy_pass http://backend:8000;
|
||||
}
|
||||
location /openapi.json {
|
||||
proxy_pass http://backend:8000;
|
||||
}
|
||||
|
||||
# ── WebSocket ─────────────────────────────────────────────────────────────
|
||||
location /ws/ {
|
||||
proxy_pass http://backend:8000;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection "upgrade";
|
||||
proxy_set_header Host $host;
|
||||
proxy_read_timeout 3600s;
|
||||
}
|
||||
|
||||
# ── Frontend (sarà aggiunto in Fase 5) ────────────────────────────────────
|
||||
# location / {
|
||||
# proxy_pass http://frontend:3000;
|
||||
# }
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
user nginx;
|
||||
worker_processes auto;
|
||||
error_log /var/log/nginx/error.log warn;
|
||||
pid /var/run/nginx.pid;
|
||||
|
||||
events {
|
||||
worker_connections 1024;
|
||||
use epoll;
|
||||
multi_accept on;
|
||||
}
|
||||
|
||||
http {
|
||||
include /etc/nginx/mime.types;
|
||||
default_type application/octet-stream;
|
||||
|
||||
# Logging format
|
||||
log_format main '$remote_addr - $remote_user [$time_local] "$request" '
|
||||
'$status $body_bytes_sent "$http_referer" '
|
||||
'"$http_user_agent" "$http_x_forwarded_for"';
|
||||
access_log /var/log/nginx/access.log main;
|
||||
|
||||
# Performance
|
||||
sendfile on;
|
||||
tcp_nopush on;
|
||||
tcp_nodelay on;
|
||||
keepalive_timeout 65;
|
||||
gzip on;
|
||||
gzip_types text/plain text/css application/json application/javascript
|
||||
text/xml application/xml application/xml+rss text/javascript;
|
||||
|
||||
# Security headers
|
||||
add_header X-Content-Type-Options nosniff;
|
||||
add_header X-Frame-Options DENY;
|
||||
add_header X-XSS-Protection "1; mode=block";
|
||||
add_header Referrer-Policy "strict-origin-when-cross-origin";
|
||||
|
||||
# Hide nginx version
|
||||
server_tokens off;
|
||||
|
||||
# Rate limiting zones
|
||||
limit_req_zone $binary_remote_addr zone=auth:10m rate=10r/m;
|
||||
limit_req_zone $binary_remote_addr zone=api:10m rate=100r/m;
|
||||
|
||||
include /etc/nginx/conf.d/*.conf;
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
# Redis configuration per PecFlow
|
||||
|
||||
# ── Bind e rete ───────────────────────────────────────────────────────────────
|
||||
bind 0.0.0.0
|
||||
protected-mode no
|
||||
port 6379
|
||||
|
||||
# ── Memoria ───────────────────────────────────────────────────────────────────
|
||||
# In produzione aumentare in base ai volumi della coda
|
||||
maxmemory 256mb
|
||||
maxmemory-policy allkeys-lru
|
||||
|
||||
# ── Persistenza ───────────────────────────────────────────────────────────────
|
||||
# Salva snapshot periodici
|
||||
# Formato: save <secondi> <numero_modifiche>
|
||||
save 900 1
|
||||
save 300 10
|
||||
save 60 10000
|
||||
|
||||
# Append-only file per durabilità più alta
|
||||
appendonly yes
|
||||
appendfilename "appendonly.aof"
|
||||
appendfsync everysec
|
||||
|
||||
# ── Log ───────────────────────────────────────────────────────────────────────
|
||||
loglevel notice
|
||||
logfile ""
|
||||
|
||||
# ── Performance ───────────────────────────────────────────────────────────────
|
||||
hz 10
|
||||
dynamic-hz yes
|
||||
latency-monitor-threshold 100
|
||||
|
||||
# ── Sicurezza ─────────────────────────────────────────────────────────────────
|
||||
# In produzione aggiungere:
|
||||
# requirepass change_me_in_production
|
||||
Reference in New Issue
Block a user