Merge remote-tracking branch 'origin/dev' into feat/data-schema

# Conflicts:
#	apps/backend/app/models/__init__.py
This commit is contained in:
Johan LEROY
2026-09-15 16:46:16 +02:00
77 changed files with 6545 additions and 117 deletions
+18 -3
View File
@@ -1,6 +1,21 @@
# Piege : tout modele absent de ce module reste invisible de `alembic revision
# --autogenerate`, qui genererait alors un drop de sa table.
# Piège : tout modèle absent de ce module reste invisible de `alembic revision
# --autogenerate`, qui générerait alors un drop de sa table.
from app.models.audit_log import AuditLog
from app.models.energy import Alert, Dataset, Prediction, Reading, Recommendation, Site
from app.models.login_attempt import LoginAttempt
from app.models.refresh_token import RefreshToken
from app.models.user import AppUser
__all__ = ["Alert", "Dataset", "Prediction", "Reading", "Recommendation", "Site"]
__all__ = [
"Alert",
"AppUser",
"AuditLog",
"Dataset",
"LoginAttempt",
"Prediction",
"Reading",
"Recommendation",
"RefreshToken",
"Site",
]
+64
View File
@@ -0,0 +1,64 @@
# Pourquoi : `actor_id` ne porte volontairement aucune clé étrangère. Une contrainte
# `ON DELETE SET NULL` déclencherait un UPDATE que le déclencheur d'ajout seul refuserait, donc
# la suppression d'un compte échouerait ; une contrainte `NO ACTION` interdirait toute
# suppression. `actor_email` et `actor_role` sont dénormalisés pour la même raison : le journal
# dit ce qui était vrai au moment de l'acte, pas ce qui est vrai aujourd'hui.
import uuid
from datetime import datetime
from enum import StrEnum
from typing import Any
from sqlalchemy import BigInteger, CheckConstraint, DateTime, Identity, Index, Text, func
from sqlalchemy.dialects.postgresql import INET, JSONB
from sqlalchemy.dialects.postgresql import UUID as PG_UUID
from sqlalchemy.orm import Mapped, mapped_column
from app.db.base import Base
class AuditOutcome(StrEnum):
SUCCES = "success"
ECHEC = "failure"
class AuditAction(StrEnum):
COMPTE_CREE = "user.created"
COMPTE_ROLE_CHANGE = "user.role_changed"
COMPTE_DESACTIVE = "user.disabled"
COMPTE_ACTIVE = "user.enabled"
COMPTE_MOT_DE_PASSE_REINITIALISE = "user.password_reset_by_admin"
COMPTE_MOT_DE_PASSE_CHANGE = "user.password_changed"
REFRESH_REUTILISE = "auth.refresh_reuse_detected"
SESSIONS_REVOQUEES = "auth.all_sessions_revoked"
LIMITE_PAR_IDENTIFIANT = "auth.identifier_throttled"
ADMIN_AMORCE = "bootstrap.admin_created"
ISSUES_AUTORISEES = ", ".join(f"'{issue.value}'" for issue in AuditOutcome)
class AuditLog(Base):
__tablename__ = "audit_log"
__table_args__ = (
CheckConstraint(f"outcome in ({ISSUES_AUTORISEES})", name="ck_audit_log_outcome"),
Index("ix_audit_log_date", "occurred_at"),
Index("ix_audit_log_action_date", "action", "occurred_at"),
)
id: Mapped[int] = mapped_column(BigInteger, Identity(always=True), primary_key=True)
occurred_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), nullable=False, server_default=func.now()
)
actor_id: Mapped[uuid.UUID | None] = mapped_column(PG_UUID(as_uuid=True), nullable=True)
actor_email: Mapped[str | None] = mapped_column(Text, nullable=True)
actor_role: Mapped[str | None] = mapped_column(Text, nullable=True)
action: Mapped[str] = mapped_column(Text, nullable=False)
target_type: Mapped[str | None] = mapped_column(Text, nullable=True)
target_id: Mapped[str | None] = mapped_column(Text, nullable=True)
outcome: Mapped[str] = mapped_column(Text, nullable=False)
client_ip: Mapped[str | None] = mapped_column(INET, nullable=True)
user_agent: Mapped[str | None] = mapped_column(Text, nullable=True)
detail: Mapped[dict[str, Any]] = mapped_column(
JSONB, nullable=False, server_default=func.jsonb_build_object()
)
+44
View File
@@ -0,0 +1,44 @@
# Pourquoi : les tentatives vivent ici et non dans `audit_log`, qui est en ajout seul. Leur
# volume est piloté par l'attaquant : une force brute y écrirait des millions de lignes
# indestructibles. Cette table-ci se purge, et c'est aussi le compteur de la limitation.
# Piège : la tentative est enregistrée même quand l'email est inconnu, sinon le 429 dirait
# qu'un compte existe.
import uuid
from datetime import datetime
from enum import StrEnum
from sqlalchemy import BigInteger, CheckConstraint, DateTime, Identity, Index, String, Text, func
from sqlalchemy.dialects.postgresql import INET
from sqlalchemy.dialects.postgresql import UUID as PG_UUID
from sqlalchemy.orm import Mapped, mapped_column
from app.db.base import Base
class LoginOutcome(StrEnum):
SUCCES = "success"
IDENTIFIANTS_INVALIDES = "bad_credentials"
LIMITE = "throttled"
COMPTE_INDISPONIBLE = "inactive"
ISSUES_AUTORISEES = ", ".join(f"'{issue.value}'" for issue in LoginOutcome)
class LoginAttempt(Base):
__tablename__ = "login_attempt"
__table_args__ = (
CheckConstraint(f"outcome in ({ISSUES_AUTORISEES})", name="ck_login_attempt_outcome"),
Index("ix_login_attempt_email_date", "email_tried", "occurred_at"),
Index("ix_login_attempt_ip_date", "client_ip", "occurred_at"),
)
id: Mapped[int] = mapped_column(BigInteger, Identity(always=True), primary_key=True)
occurred_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), nullable=False, server_default=func.now()
)
email_tried: Mapped[str] = mapped_column(String(320), nullable=False)
client_ip: Mapped[str | None] = mapped_column(INET, nullable=True)
outcome: Mapped[str] = mapped_column(Text, nullable=False)
user_id: Mapped[uuid.UUID | None] = mapped_column(PG_UUID(as_uuid=True), nullable=True)
+64
View File
@@ -0,0 +1,64 @@
# Pourquoi : un jeton de rafraîchissement est une chaîne opaque, jamais un JWT. Il doit être
# révocable, donc cette ligne existe de toute façon ; le JWT n'ajouterait qu'un second chemin de
# signature. Surtout, la séparation devient structurelle : un JWT ne figure dans aucune ligne,
# une chaîne opaque échoue au décodage. Aucune confusion de type n'est possible.
# Piège : `expires_at` est absolu et hérité du prédécesseur à chaque rotation. S'il glissait,
# la promesse de sept jours serait fictive et une session active ne finirait jamais.
import uuid
from datetime import datetime
from enum import StrEnum
from sqlalchemy import CheckConstraint, DateTime, ForeignKey, Index, LargeBinary, Text, func
from sqlalchemy.dialects.postgresql import INET
from sqlalchemy.dialects.postgresql import UUID as PG_UUID
from sqlalchemy.orm import Mapped, mapped_column
from app.db.base import Base
class RevocationReason(StrEnum):
DECONNEXION = "logout"
ROTATION = "rotation"
REUTILISATION = "reuse_detected"
CHANGEMENT_MOT_DE_PASSE = "password_change"
ADMINISTRATION = "admin"
MOTIFS_AUTORISES = ", ".join(f"'{motif.value}'" for motif in RevocationReason)
class RefreshToken(Base):
__tablename__ = "refresh_token"
__table_args__ = (
CheckConstraint(
f"revoked_reason is null or revoked_reason in ({MOTIFS_AUTORISES})",
name="ck_refresh_token_revoked_reason",
),
Index("ix_refresh_token_family", "family_id"),
Index("ix_refresh_token_user", "user_id"),
Index(
"ix_refresh_token_vivants",
"user_id",
postgresql_where="revoked_at is null and rotated_at is null",
),
)
id: Mapped[uuid.UUID] = mapped_column(
PG_UUID(as_uuid=True), primary_key=True, server_default=func.gen_random_uuid()
)
family_id: Mapped[uuid.UUID] = mapped_column(PG_UUID(as_uuid=True), nullable=False)
user_id: Mapped[uuid.UUID] = mapped_column(
PG_UUID(as_uuid=True), ForeignKey("app_user.id", ondelete="CASCADE"), nullable=False
)
token_hash: Mapped[bytes] = mapped_column(LargeBinary, 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)
rotated_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
revoked_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
revoked_reason: Mapped[str | None] = mapped_column(Text, nullable=True)
replaced_by: Mapped[uuid.UUID | None] = mapped_column(PG_UUID(as_uuid=True), nullable=True)
client_ip: Mapped[str | None] = mapped_column(INET, nullable=True)
user_agent: Mapped[str | None] = mapped_column(Text, nullable=True)
+50
View File
@@ -0,0 +1,50 @@
# Contrainte : la table s'appelle `app_user` et non `user`, qui est un mot réservé PostgreSQL,
# raccourci de `CURRENT_USER`. Le nom rappelle aussi qu'il s'agit d'un compte applicatif, par
# opposition au rôle PostgreSQL qui porte, lui, le cantonnement des accès.
import uuid
from datetime import datetime
from sqlalchemy import Boolean, CheckConstraint, DateTime, String, Text, func, text
from sqlalchemy.dialects.postgresql import UUID as PG_UUID
from sqlalchemy.orm import Mapped, mapped_column
from app.core.roles import AccountKind, Role
from app.db.base import Base
ROLES_AUTORISES = ", ".join(f"'{role.value}'" for role in Role)
NATURES_AUTORISEES = ", ".join(f"'{nature.value}'" for nature in AccountKind)
class AppUser(Base):
__tablename__ = "app_user"
__table_args__ = (
CheckConstraint("email = lower(email)", name="ck_app_user_email_minuscule"),
CheckConstraint(f"role in ({ROLES_AUTORISES})", name="ck_app_user_role"),
CheckConstraint(f"kind in ({NATURES_AUTORISEES})", name="ck_app_user_kind"),
)
id: Mapped[uuid.UUID] = mapped_column(
PG_UUID(as_uuid=True), primary_key=True, server_default=func.gen_random_uuid()
)
email: Mapped[str] = mapped_column(String(320), unique=True, nullable=False)
password_hash: Mapped[str] = mapped_column(Text, nullable=False)
role: Mapped[str] = mapped_column(Text, nullable=False)
kind: Mapped[str] = mapped_column(Text, nullable=False, server_default=text("'human'"))
is_active: Mapped[bool] = mapped_column(Boolean, nullable=False, server_default=text("true"))
must_change_password: Mapped[bool] = mapped_column(
Boolean, nullable=False, server_default=text("false")
)
# Une seule colonne couvre le changement de mot de passe, le changement de rôle et la
# désactivation : tout jeton émis avant cet instant est périmé.
credentials_changed_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), nullable=False, server_default=func.now()
)
last_login_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
full_name: 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()
)