Aller au contenu

Vue filtrée pour Administrateur Planificateur Gestionnaire de patrimoine Enseignant Étudiant Tout afficher

J4 — Le modèle et le RLS

Vérifié contre le produit le .

L’objectif du jour : comprendre pourquoi une requête parfaitement écrite peut ne rien renvoyer — et ne plus jamais y perdre une journée.

Durée réaliste une demi-journée
Prérequis J1 et J2 — une base peuplée à interroger

Tout est dans packages/db, qui est la source de vérité de l’isolation. Son README.md est court et précis : lisez-le, cette page ne le remplace pas, elle vous dit quoi en retenir.

Une table métier ne se déclare pas avec pgTable()

Section intitulée « Une table métier ne se déclare pas avec pgTable() »

Trois fonctions, et le choix n’est jamais laissé au goût de chacun :

Fonction Pour quoi Ce qu’elle ajoute
tenantTable() toute table métier les colonnes de convention — id, establishment_id, created_at, updated_at, created_byet la politique d’isolation
sharedTable() les rares données globales, comme un catalogue lecture pour tous les établissements, écriture réservée à la connexion propriétaire
pgTable() jamais, pour une table de domaine rien — et c’est le problème

La politique posée par tenantTable() compare establishment_id à current_setting('app.tenant_id')::uuid. Ce réglage n’est pas une variable d’application : c’est un paramètre de session PostgreSQL, et c’est la base elle-même qui filtre.

withTenant() est le point d’entrée de tout accès aux données :

const rooms = await withTenant(db, establishmentId, (tx) => tx.select().from(room));

Il ouvre une transaction, y pose le tenant avec set_config(..., is_local => true) — la sémantique de SET LOCAL — puis exécute votre code. Le réglage meurt avec la transaction, ce qui est obligatoire avec un pool de connexions partagé : sans cela, une requête pourrait hériter du tenant de la précédente.

Deux chemins seulement y mènent :

  • une requête HTTP — l’API résout la session, puis l’organisation active, puis l’établissement, et passe par TenantContextService ;
  • un worker asynchrone — pas de requête HTTP : l’establishment_id voyage dans le message de la file, et le gestionnaire rappelle withTenant() avec lui.
Fenêtre de terminal
bun run --filter @beemyschool/db db:generate # dérive une migration du schéma
bun run --filter @beemyschool/db db:migrate # l'applique (connexion propriétaire)

On modifie src/schema.ts, puis on génère : le SQL est dérivé du schéma, jamais écrit à la main d’abord. Le fichier produit se relit avant d’être committé — c’est du SQL qui tournera en production.

En développement, l’API applique les migrations à son démarrage. Dans les tests, chaque suite crée sa propre base et appelle migrateDb().

Pourquoi la migration 0000 crée un rôle PostgreSQL. C’est elle qui crée beemyschool_app, le rôle non-superutilisateur du runtime — celui à qui le RLS s’applique toujours. Conséquence directe, visible dans le workflow de CI : le rôle n’y est pas provisionné, volontairement. Chaque suite le fabrique en jouant la migration, et le provisionner en double dans le workflow le ferait diverger de ce que la production exécutera.

  • Vous savez déclarer une table métier avec tenantTable() et dire ce qu’elle ajoute
  • Vous savez pourquoi le contexte de tenant est posé par transaction et non par connexion
  • Vous avez lu packages/db/README.md et parcouru src/rls.test.ts
  • Vous savez générer et appliquer une migration
  • Vous savez expliquer pourquoi la migration 0000 crée un rôle PostgreSQL
  • Devant une requête qui ne renvoie rien, votre première hypothèse est le contexte de tenant

J5 — Les contrats : la chaîne qui relie l’API au web, et que personne ne devine avant de s’y être cassé les dents une fois.

Documentation technique source (2)