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

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

Definition of Done:
 Login, refresh token e TOTP funzionanti
 make dev porta in piedi tutto lo stack locale
 CI configurata
This commit is contained in:
2026-03-18 16:42:01 +01:00
parent 0251c2bbb0
commit 58a233236c
60 changed files with 6942 additions and 0 deletions
@@ -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}")