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
@@ -0,0 +1,119 @@
"""tentatives de connexion et journal d audit
Revision ID: 517053a3c044
Revises: b1a7c3d9e240
Create Date: 2026-09-15 14:31:07.966180
Deux tables aux vocations opposees. `login_attempt` est le compteur de la limitation
de debit : son volume est pilote par l'attaquant, donc elle se purge. `audit_log` est
en ajout seul, garanti par deux declencheurs.
Le declencheur TRUNCATE n'est pas redondant : TRUNCATE ne passe pas par les
declencheurs de ligne. Et RAISE EXCEPTION plutot qu'un RETURN NULL, qui annulerait
l'operation silencieusement.
"""
from collections.abc import Sequence
import sqlalchemy as sa
from alembic import op
from sqlalchemy.dialects import postgresql
revision: str = "517053a3c044"
down_revision: str | Sequence[str] | None = "b1a7c3d9e240"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
FONCTION_AJOUT_SEUL = """
CREATE FUNCTION audit_log_append_only() RETURNS trigger AS $$
BEGIN
RAISE EXCEPTION 'audit_log est en ajout seul : % interdit', TG_OP;
END
$$ LANGUAGE plpgsql;
"""
DECLENCHEUR_LIGNE = """
CREATE TRIGGER audit_log_no_update_delete
BEFORE UPDATE OR DELETE ON audit_log
FOR EACH ROW EXECUTE FUNCTION audit_log_append_only();
"""
DECLENCHEUR_TRUNCATE = """
CREATE TRIGGER audit_log_no_truncate
BEFORE TRUNCATE ON audit_log
FOR EACH STATEMENT EXECUTE FUNCTION audit_log_append_only();
"""
def upgrade() -> None:
op.create_table(
"login_attempt",
sa.Column("id", sa.BigInteger(), sa.Identity(always=True), nullable=False),
sa.Column(
"occurred_at",
sa.DateTime(timezone=True),
server_default=sa.text("now()"),
nullable=False,
),
sa.Column("email_tried", sa.String(length=320), nullable=False),
sa.Column("client_ip", postgresql.INET(), nullable=True),
sa.Column("outcome", sa.Text(), nullable=False),
sa.Column("user_id", sa.UUID(), nullable=True),
sa.CheckConstraint(
"outcome in ('success', 'bad_credentials', 'throttled', 'inactive')",
name="ck_login_attempt_outcome",
),
sa.PrimaryKeyConstraint("id", name="pk_login_attempt"),
)
op.create_index(
"ix_login_attempt_email_date", "login_attempt", ["email_tried", "occurred_at"]
)
op.create_index("ix_login_attempt_ip_date", "login_attempt", ["client_ip", "occurred_at"])
op.create_table(
"audit_log",
sa.Column("id", sa.BigInteger(), sa.Identity(always=True), nullable=False),
sa.Column(
"occurred_at",
sa.DateTime(timezone=True),
server_default=sa.text("now()"),
nullable=False,
),
sa.Column("actor_id", sa.UUID(), nullable=True),
sa.Column("actor_email", sa.Text(), nullable=True),
sa.Column("actor_role", sa.Text(), nullable=True),
sa.Column("action", sa.Text(), nullable=False),
sa.Column("target_type", sa.Text(), nullable=True),
sa.Column("target_id", sa.Text(), nullable=True),
sa.Column("outcome", sa.Text(), nullable=False),
sa.Column("client_ip", postgresql.INET(), nullable=True),
sa.Column("user_agent", sa.Text(), nullable=True),
sa.Column(
"detail",
postgresql.JSONB(astext_type=sa.Text()),
server_default=sa.text("jsonb_build_object()"),
nullable=False,
),
sa.CheckConstraint("outcome in ('success', 'failure')", name="ck_audit_log_outcome"),
sa.PrimaryKeyConstraint("id", name="pk_audit_log"),
)
op.create_index("ix_audit_log_date", "audit_log", ["occurred_at"])
op.create_index("ix_audit_log_action_date", "audit_log", ["action", "occurred_at"])
op.execute(FONCTION_AJOUT_SEUL)
op.execute(DECLENCHEUR_LIGNE)
op.execute(DECLENCHEUR_TRUNCATE)
def downgrade() -> None:
op.execute("DROP TRIGGER IF EXISTS audit_log_no_truncate ON audit_log;")
op.execute("DROP TRIGGER IF EXISTS audit_log_no_update_delete ON audit_log;")
op.execute("DROP FUNCTION IF EXISTS audit_log_append_only();")
op.drop_index("ix_audit_log_action_date", table_name="audit_log")
op.drop_index("ix_audit_log_date", table_name="audit_log")
op.drop_table("audit_log")
op.drop_index("ix_login_attempt_ip_date", table_name="login_attempt")
op.drop_index("ix_login_attempt_email_date", table_name="login_attempt")
op.drop_table("login_attempt")
@@ -0,0 +1,77 @@
"""jetons de rafraichissement
Revision ID: 821f71be74c0
Revises: 517053a3c044
Create Date: 2026-09-15 14:42:09.757949
Le jeton lui-meme n'est jamais stocke : seule son empreinte SHA-256 l'est, dans
`token_hash`. Un pg_dump qui fuiterait ne livrerait donc aucune session utilisable.
L'index partiel `ix_refresh_token_vivants` sert la revocation en cascade et la
recherche des sessions actives, qui ne regardent jamais les lignes deja tournees.
"""
from collections.abc import Sequence
import sqlalchemy as sa
from alembic import op
from sqlalchemy.dialects import postgresql
revision: str = "821f71be74c0"
down_revision: str | Sequence[str] | None = "517053a3c044"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
MOTIFS = "'logout', 'rotation', 'reuse_detected', 'password_change', 'admin'"
JETONS_VIVANTS = "revoked_at is null and rotated_at is null"
def upgrade() -> None:
op.create_table(
"refresh_token",
sa.Column(
"id", sa.UUID(), server_default=sa.text("gen_random_uuid()"), nullable=False
),
sa.Column("family_id", sa.UUID(), nullable=False),
sa.Column("user_id", sa.UUID(), nullable=False),
sa.Column("token_hash", sa.LargeBinary(), nullable=False),
sa.Column(
"issued_at",
sa.DateTime(timezone=True),
server_default=sa.text("now()"),
nullable=False,
),
sa.Column("expires_at", sa.DateTime(timezone=True), nullable=False),
sa.Column("rotated_at", sa.DateTime(timezone=True), nullable=True),
sa.Column("revoked_at", sa.DateTime(timezone=True), nullable=True),
sa.Column("revoked_reason", sa.Text(), nullable=True),
sa.Column("replaced_by", sa.UUID(), nullable=True),
sa.Column("client_ip", postgresql.INET(), nullable=True),
sa.Column("user_agent", sa.Text(), nullable=True),
sa.CheckConstraint(
f"revoked_reason is null or revoked_reason in ({MOTIFS})",
name="ck_refresh_token_revoked_reason",
),
sa.ForeignKeyConstraint(
["user_id"], ["app_user.id"], name="fk_refresh_token_user", ondelete="CASCADE"
),
sa.PrimaryKeyConstraint("id", name="pk_refresh_token"),
sa.UniqueConstraint("token_hash", name="uq_refresh_token_hash"),
)
op.create_index("ix_refresh_token_family", "refresh_token", ["family_id"])
op.create_index("ix_refresh_token_user", "refresh_token", ["user_id"])
op.create_index(
"ix_refresh_token_vivants",
"refresh_token",
["user_id"],
postgresql_where=JETONS_VIVANTS,
)
def downgrade() -> None:
op.drop_index(
"ix_refresh_token_vivants", table_name="refresh_token", postgresql_where=JETONS_VIVANTS
)
op.drop_index("ix_refresh_token_user", table_name="refresh_token")
op.drop_index("ix_refresh_token_family", table_name="refresh_token")
op.drop_table("refresh_token")
@@ -0,0 +1,72 @@
"""comptes applicatifs
Revision ID: b1a7c3d9e240
Revises: 5353c0e4f094
Create Date: 2026-09-15 14:40:00.000000
Cree `app_user`, la table des comptes humains et de service. Le nom evite `user`,
mot reserve de PostgreSQL. `gen_random_uuid()` est au coeur de PG17, aucune
extension n'est necessaire.
"""
from collections.abc import Sequence
import sqlalchemy as sa
from alembic import op
from sqlalchemy.dialects import postgresql
revision: str = "b1a7c3d9e240"
down_revision: str | Sequence[str] | None = "5353c0e4f094"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
def upgrade() -> None:
op.create_table(
"app_user",
sa.Column(
"id",
postgresql.UUID(as_uuid=True),
server_default=sa.text("gen_random_uuid()"),
nullable=False,
),
sa.Column("email", sa.String(length=320), nullable=False),
sa.Column("password_hash", sa.Text(), nullable=False),
sa.Column("role", sa.Text(), nullable=False),
sa.Column("kind", sa.Text(), server_default=sa.text("'human'"), nullable=False),
sa.Column("is_active", sa.Boolean(), server_default=sa.text("true"), nullable=False),
sa.Column(
"must_change_password", sa.Boolean(), server_default=sa.text("false"), nullable=False
),
sa.Column(
"credentials_changed_at",
sa.DateTime(timezone=True),
server_default=sa.text("now()"),
nullable=False,
),
sa.Column("last_login_at", sa.DateTime(timezone=True), nullable=True),
sa.Column("full_name", sa.Text(), nullable=True),
sa.Column(
"created_at",
sa.DateTime(timezone=True),
server_default=sa.text("now()"),
nullable=False,
),
sa.Column(
"updated_at",
sa.DateTime(timezone=True),
server_default=sa.text("now()"),
nullable=False,
),
sa.CheckConstraint("email = lower(email)", name="ck_app_user_email_minuscule"),
sa.CheckConstraint(
"role in ('lecteur', 'operateur', 'admin')", name="ck_app_user_role"
),
sa.CheckConstraint("kind in ('human', 'service')", name="ck_app_user_kind"),
sa.PrimaryKeyConstraint("id", name="pk_app_user"),
sa.UniqueConstraint("email", name="uq_app_user_email"),
)
def downgrade() -> None:
op.drop_table("app_user")