Merge remote-tracking branch 'origin/dev' into feat/data-schema
# Conflicts: # apps/backend/app/models/__init__.py
This commit is contained in:
@@ -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",
|
||||
]
|
||||
|
||||
@@ -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()
|
||||
)
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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()
|
||||
)
|
||||
Reference in New Issue
Block a user