Files
ENI-projet-piscine/docs/architecture/40-data.md
T
Johan LEROY 3b7383697e docs: acte les décisions d'authentification et met à jour les vues
Trois ADR : le jeton d'accès et le rafraîchissement opaque, le RBAC avec
relecture du compte à chaque requête, et le journal d'audit en ajout
seul. Chacun porte ses alternatives écartées et son critère de bascule,
notamment celui vers OIDC.

`31-contrat-authentification.md` est destiné au frontend : endpoints,
codes d'erreur à traiter, et les quatre règles qui comptent. La
troisième, un seul rafraîchissement en vol, est une exigence et non une
optimisation : cinq rotations concurrentes seraient lues comme un rejeu
et révoqueraient la session à chaque chargement de page.

`owasp-traceabilite.md` remplace la revendication « couverture OWASP Top
10 et API Top 10 » de la NFR4, qui n'a pas de réponse honnête sur vingt
items en deux semaines. Un contrôle par ligne, l'item adressé, et une
section qui dit ce qui reste ouvert : portée par site, bornage des
lectures de séries, transport, et la consommation de l'API Mock.

Les vues 00, 20 et 40 suivent, comme l'impose leur propre règle de
maintenance. La question ouverte « quel mécanisme d'authentification »
est fermée ; trois autres la remplacent, dont la portée par site.
2026-09-15 15:05:28 +02:00

8.0 KiB

Données

PostgreSQL 17 avec l'extension TimescaleDB. Le choix, ses alternatives et ses conséquences sont dans l'ADR 0001, qui fait foi. Ce document décrit le système qui en découle.

Avertissement

Aucune table applicative n'existe à ce jour. Base.metadata est vide, app/models/ ne contient qu'un commentaire, l'unique révision Alembic ne crée aucune table, et aucune hypertable n'a été déclarée. Tout ce qui suit sous le statut Cible est une proposition de structure, pas un relevé du code. Le modèle sera arrêté au jalon J2.

Trois emplacements, trois rôles

C'est la règle que l'ADR 0001 existe surtout pour fixer. La confondre coûte cher : un script placé au mauvais endroit ne s'exécute jamais, ou s'exécute deux fois.

Emplacement Contenu Quand ça s'exécute
db/init/ Extensions, bases annexes Une seule fois, à la première initialisation du conteneur, quand PGDATA est vide. Ne rejoue jamais
db/migrations/ SQL versionné qui ne découle pas du schéma applicatif : rétention, compression À la main, aujourd'hui vide
apps/backend/alembic/ Le schéma exposé par l'API, et lui seul alembic upgrade head, c'est Base.metadata qui fait foi

Une hypertable relève des deux derniers : Alembic crée la table, et le create_hypertable() vit dans la même révision. Les séparer rendrait le schéma irreproductible depuis un seul alembic upgrade head.

Détail de db/init/ et du piège de montage : db/README.md.

Ce qui existe

Statut : Fait.

  • db/init/100-extensions.sql crée l'extension timescaledb.
  • db/init/110-test-database.sql crée enervision_test, dont le nom est attendu en dur par apps/backend/tests/conftest.py.
  • Quatre révisions Alembic. La première, 5353c0e4f094, ne crée aucune table : elle établit alembic_version et refuse de s'appliquer si l'extension manque :
IF NOT EXISTS (SELECT 1 FROM pg_extension WHERE extname = 'timescaledb') THEN
    RAISE EXCEPTION 'extension timescaledb absente, voir db/init et db/README.md';
END IF;

Cette garde forme paire avec le 503 de /api/v1/health/ready. Un bootstrap sauté ne se voit pas au démarrage de l'API : ces deux gardes le rendent visible tôt, des deux côtés.

Les trois suivantes créent les tables de l'authentification, décrites plus bas : app_user, puis login_attempt et audit_log, puis refresh_token.

Cycle de vie d'une mesure

Statut : Cible. Aucun de ces maillons n'existe.

flowchart LR
  src["Source de mesures"] -.-> ing["Ingestion Airflow"]
  ing -.-> hy[("Hypertable mesure")]
  hy -.-> agg[("Agrégat continu")]
  hy -.-> comp["Compression"]
  hy -.-> ret["Rétention"]
  agg -.-> api["API FastAPI"]
  agg -.-> graf["Grafana"]

Les lectures de l'API et de Grafana visent l'agrégat continu, pas la table brute : c'est tout l'intérêt de TimescaleDB, et cela doit rester vrai quand les volumes augmenteront.

Tables d'authentification

Statut : Fait. Elles ne sont pas des séries temporelles et n'ont donc rien à voir avec les hypertables ; elles vivent dans apps/backend/alembic/, qui porte le schéma exposé par l'API.

erDiagram
  APP_USER ||--o{ REFRESH_TOKEN : ouvre
  APP_USER {
    uuid id PK
    string email UK
    text password_hash
    text role
    text kind
    bool is_active
    bool must_change_password
    timestamptz credentials_changed_at
  }
  REFRESH_TOKEN {
    uuid id PK
    uuid family_id
    uuid user_id FK
    bytea token_hash UK
    timestamptz expires_at
    timestamptz rotated_at
    timestamptz revoked_at
    text revoked_reason
    uuid replaced_by
  }
  LOGIN_ATTEMPT {
    bigint id PK
    timestamptz occurred_at
    string email_tried
    inet client_ip
    text outcome
  }
  AUDIT_LOG {
    bigint id PK
    timestamptz occurred_at
    uuid actor_id
    text actor_email
    text action
    jsonb detail
  }

Quatre choix de modélisation portent une intention et se défendent seuls :

  • app_user et non user : user est un mot réservé PostgreSQL, raccourci de CURRENT_USER. Le nom rappelle en prime qu'il s'agit d'un compte applicatif, par opposition au rôle PostgreSQL qui portera le cantonnement de l'ETL.
  • credentials_changed_at, une seule colonne, couvre le changement de mot de passe, le changement de rôle et la désactivation. Un compteur de version ne dirait rien à un humain qui lit un audit.
  • refresh_token.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.
  • audit_log.actor_id n'a aucune clé étrangère, et actor_email comme actor_role sont dénormalisés. Une contrainte ON DELETE SET NULL déclencherait un UPDATE que le déclencheur d'ajout seul refuserait. Voir l'ADR 0004.

audit_log porte deux déclencheurs qui refusent UPDATE, DELETE et TRUNCATE. Elle n'est donc pas une hypertable : une politique de rétention émettrait des DELETE qu'ils refuseraient. login_attempt, à l'inverse, est faite pour se purger, puisque son volume est piloté par l'attaquant.

Modèle métier

Statut : Cible. Les entités ci-dessous sont des candidates, à valider en J2. Elles s'appuient sur les gabarits de apps/backend/TESTING.md, qui évoquent déjà un modèle Site, un SiteRepository et un ConsumptionService exposant un total_kwh(site_id).

erDiagram
  SITE ||--o{ POINT_DE_MESURE : porte
  POINT_DE_MESURE ||--o{ MESURE : produit

  SITE {
    int id PK
    string nom
  }
  POINT_DE_MESURE {
    int id PK
    int site_id FK
    string libelle
    string unite
  }
  MESURE {
    timestamptz horodatage PK
    int point_id PK
    double valeur
  }

MESURE est la table destinée à devenir une hypertable, partitionnée sur horodatage. Sa clé primaire doit inclure la colonne de temps : TimescaleDB l'exige, une clé sur le seul identifiant de point serait refusée.

Gabarit de révision créant une hypertable

Conforme à la règle de l'ADR 0001 : table et hypertable dans la même révision.

def upgrade() -> None:
    op.create_table(
        "mesure",
        sa.Column("horodatage", sa.DateTime(timezone=True), nullable=False),
        sa.Column("point_id", sa.Integer(), sa.ForeignKey("point_de_mesure.id"), nullable=False),
        sa.Column("valeur", sa.Float(), nullable=False),
        sa.PrimaryKeyConstraint("horodatage", "point_id"),
    )
    op.execute("SELECT create_hypertable('mesure', by_range('horodatage'))")


def downgrade() -> None:
    op.drop_table("mesure")

drop_table suffit au retour arrière : supprimer la table supprime l'hypertable et ses partitions.

Conventions

  • Noms au singulier, en minuscules, sans préfixe de table.
  • Toute colonne de temps en timestamptz. Jamais de timestamp nu : une mesure sans fuseau devient ininterprétable dès le premier changement d'heure.
  • La colonne de partitionnement s'appelle horodatage et entre dans la clé primaire.
  • Les politiques de rétention et de compression vont dans db/migrations/, pas dans Alembic : elles ne découlent pas du schéma applicatif.
  • Tout modèle doit être importé dans app/models/__init__.py, sans quoi alembic revision --autogenerate ne le voit pas et génère un drop de sa table.

Questions ouvertes

Elles relèvent du jalon J2, « valider le périmètre retenu », et bloquent le modèle définitif.

  • Quelles sources de mesures, et selon quel protocole elles sont collectées.
  • Quelle granularité à l'ingestion : la seconde, la minute, le quart d'heure.
  • Quels agrégats continus, et sur quelles fenêtres.
  • Quelle profondeur de rétention en données brutes, et à partir de quand on compresse.
  • Quelles unités sont manipulées, et si une même table les mélange.
  • Multi-tenant ou non : un site appartient-il à un client, et faut-il cloisonner les lectures.