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