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
+9
View File
@@ -0,0 +1,9 @@
# Importa tutti i modelli per permettere ad Alembic di rilevarli
from app.models.tenant import Tenant # noqa: F401
from app.models.user import User, RefreshToken # noqa: F401
from app.models.mailbox import Mailbox # noqa: F401
from app.models.message import Message, Attachment, SendJob # noqa: F401
from app.models.archival import ArchivalBatch, ArchivalBatchMessage, ArchivalDip # noqa: F401
from app.models.audit_log import AuditLog # noqa: F401
from app.models.label import Label, MessageLabel # noqa: F401
from app.models.permission import MailboxPermission # noqa: F401
+108
View File
@@ -0,0 +1,108 @@
"""
Modelli Archival versamenti verso conservatore AgID.
"""
import uuid
from datetime import date, datetime
from sqlalchemy import (
CHAR,
DateTime,
Enum,
ForeignKey,
Index,
Integer,
String,
Text,
func,
)
from sqlalchemy.dialects.postgresql import UUID
from sqlalchemy.orm import Mapped, mapped_column
from app.database import Base
ArchivalStatus = Enum(
"pending", "building_sip", "uploading", "uploaded",
"confirmed", "rejected", "failed",
name="archival_status",
create_type=False,
)
class ArchivalBatch(Base):
__tablename__ = "archival_batches"
id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True), primary_key=True, default=uuid.uuid4
)
tenant_id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True), ForeignKey("tenants.id", ondelete="CASCADE"), nullable=False
)
conservatore_id: Mapped[str] = mapped_column(String(100), nullable=False)
status: Mapped[str] = mapped_column(ArchivalStatus, nullable=False, default="pending")
sip_path: Mapped[str | None] = mapped_column(Text, nullable=True)
sip_checksum: Mapped[str | None] = mapped_column(CHAR(64), nullable=True)
versamento_id: Mapped[str | None] = mapped_column(Text, nullable=True)
rdv_received_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
rdv_path: Mapped[str | None] = mapped_column(Text, nullable=True)
rdv_checksum: Mapped[str | None] = mapped_column(CHAR(64), nullable=True)
attempt_count: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
max_attempts: Mapped[int] = mapped_column(Integer, nullable=False, default=3)
next_retry_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
last_error: Mapped[str | None] = mapped_column(Text, nullable=True)
period_from: Mapped[date] = mapped_column(nullable=False)
period_to: Mapped[date] = mapped_column(nullable=False)
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), nullable=False, server_default=func.now()
)
updated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), nullable=False, server_default=func.now(), onupdate=func.now()
)
__table_args__ = (
Index("idx_archival_tenant", "tenant_id"),
Index("idx_archival_status", "status", "next_retry_at"),
)
class ArchivalBatchMessage(Base):
__tablename__ = "archival_batch_messages"
batch_id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True),
ForeignKey("archival_batches.id", ondelete="CASCADE"),
primary_key=True,
)
message_id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True),
ForeignKey("messages.id", ondelete="CASCADE"),
primary_key=True,
)
class ArchivalDip(Base):
__tablename__ = "archival_dips"
id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True), primary_key=True, default=uuid.uuid4
)
tenant_id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True), ForeignKey("tenants.id", ondelete="CASCADE"), nullable=False
)
batch_id: Mapped[uuid.UUID | None] = mapped_column(
UUID(as_uuid=True), ForeignKey("archival_batches.id"), nullable=True
)
requested_by: Mapped[uuid.UUID | None] = mapped_column(
UUID(as_uuid=True), ForeignKey("users.id"), nullable=True
)
dip_path: Mapped[str | None] = mapped_column(Text, nullable=True)
status: Mapped[str] = mapped_column(String(50), nullable=False, default="requested")
requested_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), nullable=False, server_default=func.now()
)
received_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
+51
View File
@@ -0,0 +1,51 @@
"""
Modello AuditLog immutabile per compliance e tracciabilità.
"""
import uuid
from datetime import datetime
from sqlalchemy import (
BigInteger,
DateTime,
ForeignKey,
Index,
String,
Text,
func,
)
from sqlalchemy.dialects.postgresql import INET, JSONB, UUID
from sqlalchemy.orm import Mapped, mapped_column
from app.database import Base
class AuditLog(Base):
__tablename__ = "audit_log"
id: Mapped[int] = mapped_column(BigInteger, primary_key=True, autoincrement=True)
tenant_id: Mapped[uuid.UUID | None] = mapped_column(
UUID(as_uuid=True), ForeignKey("tenants.id"), nullable=True
)
user_id: Mapped[uuid.UUID | None] = mapped_column(
UUID(as_uuid=True), ForeignKey("users.id"), nullable=True
)
action: Mapped[str] = mapped_column(String(100), nullable=False)
resource_type: Mapped[str | None] = mapped_column(String(100), nullable=True)
resource_id: Mapped[uuid.UUID | None] = mapped_column(UUID(as_uuid=True), nullable=True)
ip_address: Mapped[str | None] = mapped_column(INET, nullable=True)
user_agent: Mapped[str | None] = mapped_column(Text, nullable=True)
payload: Mapped[dict | None] = mapped_column(JSONB, nullable=True)
outcome: Mapped[str] = mapped_column(String(20), nullable=False, default="success")
occurred_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), nullable=False, server_default=func.now()
)
__table_args__ = (
Index("idx_audit_tenant_date", "tenant_id", "occurred_at"),
Index("idx_audit_user", "user_id", "occurred_at"),
Index("idx_audit_action", "action"),
)
def __repr__(self) -> str:
return f"<AuditLog {self.action!r} user={self.user_id} outcome={self.outcome!r}>"
+46
View File
@@ -0,0 +1,46 @@
"""
Modelli Label e MessageLabel tagging messaggi.
"""
import uuid
from sqlalchemy import CHAR, ForeignKey, Index, String, UniqueConstraint
from sqlalchemy.dialects.postgresql import UUID
from sqlalchemy.orm import Mapped, mapped_column
from app.database import Base
class Label(Base):
__tablename__ = "labels"
id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True), primary_key=True, default=uuid.uuid4
)
tenant_id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True), ForeignKey("tenants.id", ondelete="CASCADE"), nullable=False
)
name: Mapped[str] = mapped_column(String(100), nullable=False)
color: Mapped[str | None] = mapped_column(CHAR(7), nullable=True) # hex #RRGGBB
__table_args__ = (
UniqueConstraint("tenant_id", "name", name="uq_label_name_tenant"),
)
def __repr__(self) -> str:
return f"<Label {self.name!r}>"
class MessageLabel(Base):
__tablename__ = "message_labels"
message_id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True),
ForeignKey("messages.id", ondelete="CASCADE"),
primary_key=True,
)
label_id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True),
ForeignKey("labels.id", ondelete="CASCADE"),
primary_key=True,
)
+94
View File
@@ -0,0 +1,94 @@
"""
Modello Mailbox casella PEC con credenziali IMAP/SMTP cifrate.
"""
import uuid
from datetime import datetime
from sqlalchemy import (
BigInteger,
Boolean,
DateTime,
Enum,
ForeignKey,
Index,
Integer,
String,
Text,
UniqueConstraint,
func,
)
from sqlalchemy.dialects.postgresql import UUID
from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.database import Base
MailboxStatus = Enum(
"active", "paused", "error", "deleted",
name="mailbox_status",
create_type=False,
)
class Mailbox(Base):
__tablename__ = "mailboxes"
id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True), primary_key=True, default=uuid.uuid4
)
tenant_id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True), ForeignKey("tenants.id", ondelete="CASCADE"), nullable=False
)
email_address: Mapped[str] = mapped_column(String(255), nullable=False)
display_name: Mapped[str | None] = mapped_column(String(255), nullable=True)
provider: Mapped[str | None] = mapped_column(String(100), nullable=True)
# Credenziali IMAP cifrate (AES-256-GCM)
imap_host_enc: Mapped[str] = mapped_column(Text, nullable=False)
imap_port_enc: Mapped[str] = mapped_column(Text, nullable=False)
imap_user_enc: Mapped[str] = mapped_column(Text, nullable=False)
imap_pass_enc: Mapped[str] = mapped_column(Text, nullable=False)
imap_use_ssl: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True)
# Credenziali SMTP cifrate (AES-256-GCM)
smtp_host_enc: Mapped[str] = mapped_column(Text, nullable=False)
smtp_port_enc: Mapped[str] = mapped_column(Text, nullable=False)
smtp_user_enc: Mapped[str] = mapped_column(Text, nullable=False)
smtp_pass_enc: Mapped[str] = mapped_column(Text, nullable=False)
smtp_use_tls: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True)
# Stato sincronizzazione
status: Mapped[str] = mapped_column(MailboxStatus, nullable=False, default="active")
last_sync_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
last_sync_uid: Mapped[int | None] = mapped_column(BigInteger, nullable=True)
sync_error_msg: Mapped[str | None] = mapped_column(Text, nullable=True)
sync_error_count: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
created_by: Mapped[uuid.UUID | None] = mapped_column(
UUID(as_uuid=True), ForeignKey("users.id"), nullable=True
)
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), nullable=False, server_default=func.now()
)
updated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), nullable=False, server_default=func.now(), onupdate=func.now()
)
# Relazioni
tenant: Mapped["Tenant"] = relationship("Tenant", back_populates="mailboxes") # noqa: F821
permissions: Mapped[list["MailboxPermission"]] = relationship( # noqa: F821
"MailboxPermission", back_populates="mailbox", cascade="all, delete-orphan"
)
__table_args__ = (
UniqueConstraint("tenant_id", "email_address", name="uq_mailbox_email_tenant"),
Index("idx_mailboxes_tenant", "tenant_id"),
Index(
"idx_mailboxes_status",
"status",
postgresql_where="status = 'active'",
),
)
def __repr__(self) -> str:
return f"<Mailbox {self.email_address!r} status={self.status!r}>"
+191
View File
@@ -0,0 +1,191 @@
"""
Modelli Message, Attachment, SendJob.
"""
import uuid
from datetime import datetime
from sqlalchemy import (
ARRAY,
BigInteger,
Boolean,
DateTime,
Enum,
ForeignKey,
Index,
Integer,
String,
Text,
func,
)
from sqlalchemy.dialects.postgresql import UUID
from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.database import Base
PecDirection = Enum("inbound", "outbound", name="pec_direction", create_type=False)
PecState = Enum(
"draft", "queued", "sent", "accepted", "delivered", "anomaly", "failed", "received",
name="pec_state",
create_type=False,
)
PecMsgType = Enum(
"posta_certificata", "accettazione", "non_accettazione", "presa_in_carico",
"avvenuta_consegna", "mancata_consegna", "errore_consegna",
"preavviso_mancata_consegna", "rilevazione_virus", "unknown",
name="pec_msg_type",
create_type=False,
)
SendJobStatus = Enum(
"pending", "sending", "sent", "failed", "retrying",
name="send_job_status",
create_type=False,
)
class Message(Base):
__tablename__ = "messages"
id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True), primary_key=True, default=uuid.uuid4
)
tenant_id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True), ForeignKey("tenants.id", ondelete="CASCADE"), nullable=False
)
mailbox_id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True), ForeignKey("mailboxes.id", ondelete="CASCADE"), nullable=False
)
# Identificatori
message_id_header: Mapped[str | None] = mapped_column(Text, nullable=True)
imap_uid: Mapped[int | None] = mapped_column(BigInteger, nullable=True)
imap_folder: Mapped[str] = mapped_column(String(255), nullable=False, default="INBOX")
direction: Mapped[str] = mapped_column(PecDirection, nullable=False)
pec_type: Mapped[str] = mapped_column(
PecMsgType, nullable=False, default="posta_certificata"
)
state: Mapped[str] = mapped_column(PecState, nullable=False)
# Busta PEC
subject: Mapped[str | None] = mapped_column(Text, nullable=True)
from_address: Mapped[str | None] = mapped_column(String(255), nullable=True)
to_addresses: Mapped[list[str] | None] = mapped_column(ARRAY(Text), nullable=True)
cc_addresses: Mapped[list[str] | None] = mapped_column(ARRAY(Text), nullable=True)
sent_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
received_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
size_bytes: Mapped[int | None] = mapped_column(BigInteger, nullable=True)
# Corpo
body_text: Mapped[str | None] = mapped_column(Text, nullable=True)
body_html: Mapped[str | None] = mapped_column(Text, nullable=True)
has_attachments: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
# Collegamento ricevute
parent_message_id: Mapped[uuid.UUID | None] = mapped_column(
UUID(as_uuid=True), ForeignKey("messages.id"), nullable=True
)
# Flag operativi
is_read: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
is_starred: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
is_archived: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
archived_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
raw_eml_path: Mapped[str | None] = mapped_column(Text, nullable=True)
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), nullable=False, server_default=func.now()
)
updated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), nullable=False, server_default=func.now(), onupdate=func.now()
)
# Relazioni
attachments: Mapped[list["Attachment"]] = relationship(
"Attachment", back_populates="message", cascade="all, delete-orphan"
)
children: Mapped[list["Message"]] = relationship(
"Message", foreign_keys=[parent_message_id]
)
__table_args__ = (
Index("idx_messages_tenant", "tenant_id"),
Index("idx_messages_mailbox", "mailbox_id"),
Index("idx_messages_state", "state"),
Index("idx_messages_received_at", "received_at", postgresql_ops={"received_at": "DESC"}),
Index(
"idx_messages_parent", "parent_message_id",
postgresql_where="parent_message_id IS NOT NULL",
),
Index("idx_messages_imap_uid", "mailbox_id", "imap_uid"),
)
def __repr__(self) -> str:
return f"<Message {self.id} {self.pec_type!r} {self.state!r}>"
class Attachment(Base):
__tablename__ = "attachments"
id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True), primary_key=True, default=uuid.uuid4
)
tenant_id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True), ForeignKey("tenants.id", ondelete="CASCADE"), nullable=False
)
message_id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True), ForeignKey("messages.id", ondelete="CASCADE"), nullable=False
)
filename: Mapped[str] = mapped_column(String(512), nullable=False)
content_type: Mapped[str | None] = mapped_column(String(255), nullable=True)
size_bytes: Mapped[int | None] = mapped_column(BigInteger, nullable=True)
storage_path: Mapped[str] = mapped_column(Text, nullable=False)
checksum_sha256: Mapped[str | None] = mapped_column(String(64), nullable=True)
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), nullable=False, server_default=func.now()
)
# Relazioni
message: Mapped["Message"] = relationship("Message", back_populates="attachments")
__table_args__ = (
Index("idx_attachments_message", "message_id"),
)
class SendJob(Base):
__tablename__ = "send_jobs"
id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True), primary_key=True, default=uuid.uuid4
)
tenant_id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True), ForeignKey("tenants.id", ondelete="CASCADE"), nullable=False
)
mailbox_id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True), ForeignKey("mailboxes.id"), nullable=False
)
message_id: Mapped[uuid.UUID | None] = mapped_column(
UUID(as_uuid=True), ForeignKey("messages.id"), nullable=True
)
status: Mapped[str] = mapped_column(SendJobStatus, nullable=False, default="pending")
attempt_count: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
max_attempts: Mapped[int] = mapped_column(Integer, nullable=False, default=5)
next_retry_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
last_error: Mapped[str | None] = mapped_column(Text, nullable=True)
queued_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), nullable=False, server_default=func.now()
)
sent_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
created_by: Mapped[uuid.UUID | None] = mapped_column(
UUID(as_uuid=True), ForeignKey("users.id"), nullable=True
)
__table_args__ = (
Index("idx_sendjobs_tenant", "tenant_id"),
Index(
"idx_sendjobs_status", "status", "next_retry_at",
postgresql_where="status IN ('pending', 'retrying')",
),
)
+71
View File
@@ -0,0 +1,71 @@
"""
Modello MailboxPermission matrice permessi utente × casella (Fase 1-A).
ADR permessi granulari: admin ha accesso implicito a tutto.
Gli operator/readonly/supervisor devono avere un record esplicito.
"""
import uuid
from datetime import datetime
from sqlalchemy import (
Boolean,
DateTime,
ForeignKey,
Index,
UniqueConstraint,
func,
)
from sqlalchemy.dialects.postgresql import UUID
from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.database import Base
class MailboxPermission(Base):
__tablename__ = "mailbox_permissions"
id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True), primary_key=True, default=uuid.uuid4
)
tenant_id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True), ForeignKey("tenants.id", ondelete="CASCADE"), nullable=False
)
user_id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True), ForeignKey("users.id", ondelete="CASCADE"), nullable=False
)
mailbox_id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True), ForeignKey("mailboxes.id", ondelete="CASCADE"), nullable=False
)
can_read: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True)
can_send: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
can_manage: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
granted_by: Mapped[uuid.UUID | None] = mapped_column(
UUID(as_uuid=True), ForeignKey("users.id"), nullable=True
)
granted_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), nullable=False, server_default=func.now()
)
# Relazioni
user: Mapped["User"] = relationship( # noqa: F821
"User", back_populates="mailbox_permissions", foreign_keys=[user_id]
)
mailbox: Mapped["Mailbox"] = relationship( # noqa: F821
"Mailbox", back_populates="permissions"
)
__table_args__ = (
UniqueConstraint("user_id", "mailbox_id", name="uq_perm_user_mailbox"),
Index("idx_mbperm_user", "user_id"),
Index("idx_mbperm_mailbox", "mailbox_id"),
Index("idx_mbperm_tenant", "tenant_id"),
)
def __repr__(self) -> str:
return (
f"<MailboxPermission user={self.user_id} mailbox={self.mailbox_id} "
f"read={self.can_read} send={self.can_send}>"
)
+47
View File
@@ -0,0 +1,47 @@
"""
Modello Tenant ogni organizzazione cliente del SaaS.
"""
import uuid
from datetime import datetime
from sqlalchemy import Boolean, DateTime, Index, Integer, String, func
from sqlalchemy.dialects.postgresql import UUID
from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.database import Base
class Tenant(Base):
__tablename__ = "tenants"
id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True), primary_key=True, default=uuid.uuid4
)
slug: Mapped[str] = mapped_column(String(63), nullable=False, unique=True)
name: Mapped[str] = mapped_column(String(255), nullable=False)
plan: Mapped[str] = mapped_column(String(50), nullable=False, default="starter")
is_active: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True)
max_mailboxes: Mapped[int] = mapped_column(Integer, nullable=False, default=5)
max_users: Mapped[int] = mapped_column(Integer, nullable=False, default=10)
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), nullable=False, server_default=func.now()
)
updated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), nullable=False, server_default=func.now(), onupdate=func.now()
)
# Relazioni
users: Mapped[list["User"]] = relationship( # noqa: F821
"User", back_populates="tenant", cascade="all, delete-orphan"
)
mailboxes: Mapped[list["Mailbox"]] = relationship( # noqa: F821
"Mailbox", back_populates="tenant", cascade="all, delete-orphan"
)
__table_args__ = (
Index("idx_tenants_slug", "slug"),
)
def __repr__(self) -> str:
return f"<Tenant {self.slug!r} ({self.plan})>"
+129
View File
@@ -0,0 +1,129 @@
"""
Modelli User e RefreshToken.
"""
import uuid
from datetime import datetime
from sqlalchemy import (
Boolean,
DateTime,
Enum,
ForeignKey,
Index,
Integer,
String,
Text,
UniqueConstraint,
func,
)
from sqlalchemy.dialects.postgresql import INET, UUID
from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.database import Base
UserRole = Enum(
"super_admin", "admin", "supervisor", "operator", "readonly",
name="user_role",
create_type=False, # creato dalla migrazione Alembic
)
class User(Base):
__tablename__ = "users"
id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True), primary_key=True, default=uuid.uuid4
)
tenant_id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True), ForeignKey("tenants.id", ondelete="CASCADE"), nullable=False
)
email: Mapped[str] = mapped_column(String(255), nullable=False)
password_hash: Mapped[str] = mapped_column(Text, nullable=False)
full_name: Mapped[str] = mapped_column(String(255), nullable=False)
role: Mapped[str] = mapped_column(UserRole, nullable=False, default="operator")
is_active: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True)
# 2FA TOTP
totp_secret: Mapped[str | None] = mapped_column(Text, nullable=True)
totp_enabled: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
# Sicurezza accesso
last_login_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
failed_login_count: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
locked_until: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), nullable=False, server_default=func.now()
)
updated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), nullable=False, server_default=func.now(), onupdate=func.now()
)
# Relazioni
tenant: Mapped["Tenant"] = relationship("Tenant", back_populates="users") # noqa: F821
refresh_tokens: Mapped[list["RefreshToken"]] = relationship(
"RefreshToken", back_populates="user", cascade="all, delete-orphan"
)
mailbox_permissions: Mapped[list["MailboxPermission"]] = relationship( # noqa: F821
"MailboxPermission",
back_populates="user",
foreign_keys="[MailboxPermission.user_id]",
cascade="all, delete-orphan",
)
__table_args__ = (
UniqueConstraint("tenant_id", "email", name="uq_user_email_tenant"),
Index("idx_users_tenant", "tenant_id"),
Index("idx_users_email", "email"),
)
@property
def is_admin(self) -> bool:
return self.role in ("super_admin", "admin")
@property
def is_super_admin(self) -> bool:
return self.role == "super_admin"
def __repr__(self) -> str:
return f"<User {self.email!r} role={self.role!r}>"
class RefreshToken(Base):
__tablename__ = "refresh_tokens"
id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True), primary_key=True, default=uuid.uuid4
)
user_id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True), ForeignKey("users.id", ondelete="CASCADE"), nullable=False
)
token_hash: Mapped[str] = mapped_column(Text, nullable=False, unique=True)
issued_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), nullable=False, server_default=func.now()
)
expires_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False)
revoked_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
user_agent: Mapped[str | None] = mapped_column(Text, nullable=True)
ip_address: Mapped[str | None] = mapped_column(INET, nullable=True)
# Relazioni
user: Mapped["User"] = relationship("User", back_populates="refresh_tokens")
__table_args__ = (
Index("idx_rt_user", "user_id"),
Index(
"idx_rt_expires",
"expires_at",
postgresql_where="revoked_at IS NULL",
),
)
@property
def is_valid(self) -> bool:
from datetime import UTC
return self.revoked_at is None and self.expires_at > datetime.now(UTC)
def __repr__(self) -> str:
return f"<RefreshToken user_id={self.user_id!r}>"