Data & Systems
Un backend qui tient
Postgres, Supabase et RLS : où vit la donnée et qui peut y toucher. La sécurité comme autorité du serveur, pas du client. La colonne vertébrale de XHUB, Espejo et cette école.
DS-01 Modélisation sous Postgres
leçon Modéliser un schéma Postgres avec des tables, des clés, des types et des relations qui reflètent les invariants réels du domaine et ne t'obligent pas à des migrations douloureuses trois semaines plus tard.
Modélisation sous Postgres
leçonModéliser un schéma Postgres avec des tables, des clés, des types et des relations qui reflètent les invariants réels du domaine et ne t'obligent pas à des migrations douloureuses trois semaines plus tard.
Le modèle de données est la décision la plus coûteuse à revenir en arrière de toute la stack : le code se réécrit en une après-midi, mais une table mal typée avec 50 000 lignes en production te poursuit pendant des mois. Dans la DB partagée de MOONKEY LAB (profiles, progress, proofs), un type faible ou une FK oubliée est un trou d'intégrité qu'aucune RLS ne répare.
LA LEÇON
Postgres n'est pas « une feuille de calcul avec du SQL ». C'est un moteur relationnel doté d'un système de types sérieux, et ton premier travail en tant qu'opérateur de données est d'utiliser ce système de types pour que les états impossibles soient littéralement impossibles à représenter. Avant d'écrire un seul CREATE TABLE, énumère les entités du domaine et les relations entre elles. Dans MOONKEY LAB, les entités réelles sont : un utilisateur (profiles), sa progression par module (progress), les preuves qu'il téléverse (proofs), le feedback qu'il laisse (feedback) et les leads de captation. Chacune est une table. Chaque relation 1-à-N entre elles est une foreign key. Ne commence pas par les colonnes ; commence par les flèches entre les boîtes.
L'identifiant. Dans Supabase, la table profiles dépend de auth.users, donc sa clé primaire n'est PAS un id auto-généré nouveau : c'est `id uuid primary key references auth.users(id) on delete cascade`. C'est le patron canonique de Supabase et c'est délibéré — l'uuid de l'utilisateur authentifié EST l'identité dans toute ton app, et `auth.uid()` (que tu utiliseras dans chaque policy RLS) renvoie exactement cet uuid. Pour les tables filles comme progress ou proofs, là tu utilises bien une clé propre : `id uuid primary key default gen_random_uuid()`, plus une colonne `user_id uuid not null references auth.users(id) on delete cascade`. Note bien le `not null` et le `on delete cascade` : si tu supprimes un utilisateur, ses preuves partent avec lui ; si tu autorisais un user_id nul, tu aurais des lignes orphelines qu'aucune policy `user_id = auth.uid()` ne pourrait ni afficher ni protéger.
Les types comptent et sont bon marché à choisir correctement au début. Pour les timestamps, utilise TOUJOURS `timestamptz` (with time zone), jamais `timestamp` tout court — un projet avec des utilisateurs à Chiang Mai, Majorque et Madrid ne peut pas se permettre d'ambiguïté de fuseau horaire, et `timestamptz` stocke en UTC et convertit à la frontière. Pour l'argent, `numeric`, jamais `float` (les flottants binaires ne représentent pas 0.10 exact). Pour un champ avec un ensemble fermé de valeurs — le rang de l'opérateur : monkey, gorilla, eagle — tu as deux options honnêtes : un `enum` de Postgres, ou `text` avec un `check (role in ('monkey','gorilla','eagle'))`. Je préfère le CHECK à l'enum dans les projets jeunes : ajouter une valeur à un enum exige un `ALTER TYPE` et a une friction transactionnelle, alors que modifier un CHECK est un ALTER de table ordinaire. Pour les données semi-structurées — le payload d'une proof, des métadonnées flexibles — utilise `jsonb`, pas `json` : jsonb est binaire, indexable avec GIN et déduplique les clés.
Les defaults et NOT NULL sont ta première ligne de validation, avant toute RLS ou tout check applicatif. `created_at timestamptz not null default now()` signifie qu'il est impossible d'insérer une ligne sans date. `status text not null default 'forming'` signifie que l'état n'est jamais nul et donc que ton code n'a jamais besoin de branches `if status is null`. Chaque colonne que tu laisses nullable est une branche conditionnelle que tu paies pour toujours dans le code qui la lit. La règle : une colonne est NOT NULL par défaut, et tu ne la rends nullable que lorsque l'absence de valeur est un état de domaine légitime et distinct d'une valeur vide.
Unicité et index. La relation « un utilisateur a exactement un enregistrement de progression par module » n'est pas un commentaire : c'est `unique (user_id, module_code)`. Cette contrainte d'unicité te donne en plus l'index dont tu as besoin pour l'upsert (`insert ... on conflict (user_id, module_code) do update`), qui est exactement la façon dont MOONKEY persiste la progression. À part les clés, crée des index sur les colonnes par lesquelles tu filtres réellement : si tu interroges proofs par `user_id` en permanence, `create index on proofs (user_id)`. Mais n'indexe pas par réflexe — chaque index ralentit les INSERT et occupe de l'espace ; indexe ce que tu mesures comme étant interrogé, pas ce que tu imagines qu'on interrogera.
Applique le schéma comme une migration versionnée, jamais à la main dans l'éditeur SQL. Dans Supabase, c'est `apply_migration` (un fichier .sql avec nom et timestamp), pas un `execute_sql` isolé. La différence, c'est l'audit : dans six mois, tu voudras savoir pourquoi proofs a cette colonne, et la réponse est dans l'historique des migrations, pas dans ta mémoire. Après avoir appliqué, lance `generate_typescript_types` pour que le type TypeScript du client dérive du schéma réel — ainsi la DB est la source unique de vérité et le front ne peut pas mentir sur la forme de la donnée.
Un exemple concret et complet pour proofs, la table où un opérateur téléverse la preuve qu'il a complété un livrable : une clé propre uuid, un user_id NOT NULL avec FK et cascade, un module_code avec CHECK contre la liste des codes valides, un payload jsonb, un created_at timestamptz NOT NULL default now(), et un index par user_id. Ce design fait que (a) toute ligne appartient à un utilisateur existant, (b) aucune ligne ne reste orpheline, (c) aucun module_code inventé n'entre, et (d) la requête « mes preuves » soit rapide. Sur cette base — et seulement sur elle — il est pertinent de poser la RLS au module suivant.
EXERCICE
Dans le projet Supabase moonkey-lab (ou une branch de développement), écris la migration SQL qui crée la table `proofs` depuis zéro : id uuid PK avec gen_random_uuid(), user_id uuid NOT NULL references auth.users(id) on delete cascade, module_code text NOT NULL avec CHECK contre au moins trois codes réels ('DS-01','DS-02','DS-03'), payload jsonb NOT NULL default '{}', created_at timestamptz NOT NULL default now(), et un index sur user_id. Applique-la avec apply_migration. Ensuite, tente exprès un INSERT avec module_code='XX-99' et un autre avec un user_id d'un uuid qui n'existe pas dans auth.users, et vérifie que Postgres rejette les deux. Enfin, lance generate_typescript_types et vérifie que le type Proof généré reflète exactement tes colonnes et leur nullabilité.
LIVRABLE
Un fichier de migration `supabase/migrations/<timestamp>_create_proofs.sql` appliqué sur une branch, plus une capture de l'erreur de Postgres face à l'INSERT invalide (violation de CHECK et de FK) et le type TypeScript `Proof` généré à partir du schéma.
CLÉ ESSENTIELLE
Le bon modèle de données est celui qui transforme un bug de l'application en une erreur de la base de données. Chaque CHECK, chaque NOT NULL et chaque FK est une classe entière de bugs que ton code applicatif n'a plus à défendre — parce que Postgres les rejette avant qu'ils n'existent.
ERREURS À ÉVITER
- ×Utiliser `timestamp` au lieu de `timestamptz` et découvrir le décalage horaire seulement quand un utilisateur en Thaïlande voit des dates erronées ; en production, il est déjà trop tard.
- ×Laisser des colonnes nullable « au cas où » : chaque nullable inutile est une branche `if x is null` que tu traînes dans tout le code qui lit la table.
- ×Créer la table profiles avec un id propre au lieu de `references auth.users(id)`, brisant la chaîne entre auth.uid() et l'identité — et laissant la RLS ultérieure sans champ contre lequel comparer.
- ×Appliquer le schéma avec execute_sql à la main au lieu de apply_migration : tu perds l'historique versionné et personne ne pourra reconstruire pourquoi le schéma est ce qu'il est.
- ×Semer des index par réflexe sur des colonnes que tu ne filtres jamais : tu pénalises chaque INSERT et occupes de l'espace sans accélérer aucune requête réelle.
DS-02 RLS : l'autorité, c'est le serveur
leçon Écrire et vérifier des politiques Row Level Security qui font qu'un utilisateur ne peut lire et écrire que ses propres lignes, en comprenant que le gate côté client est de l'UX et que la RLS est la seule sécurité réelle sur un site statique.
RLS : l'autorité, c'est le serveur
leçonÉcrire et vérifier des politiques Row Level Security qui font qu'un utilisateur ne peut lire et écrire que ses propres lignes, en comprenant que le gate côté client est de l'UX et que la RLS est la seule sécurité réelle sur un site statique.
MOONKEY LAB et Espejo sont des sites statiques sans handler server-side : il n'y a pas de backend où glisser un `if (user.id === row.user_id)`. Le client porte l'anon key, qui est publique par conception. Si la RLS est mal faite, n'importe qui avec les DevTools ouverts lit la table profiles entière. La RLS n'est pas une couche de défense de plus : c'est la SEULE couche.
LA LEÇON
Intériorise le modèle de menace avant de toucher à une policy. Dans une SSG avec Supabase, l'attaquant N'UTILISE PAS ton interface. Il ouvre la console du navigateur, prend l'anon key (qui est dans le bundle, parce qu'elle doit y être), instancie son propre client Supabase et appelle `supabase.from('profiles').select('*')`. Ton fichier admin.astro qui « cache » le panneau n'existe pas pour lui. Le bouton désactivé n'existe pas pour lui. La seule chose qui s'interpose entre son SELECT et toutes les lignes de tous les utilisateurs, c'est la Row Level Security de Postgres. D'où la phrase du module : le gate côté client est de l'UX (il améliore l'expérience de l'utilisateur légitime), la RLS est la sécurité (elle arrête l'illégitime).
La RLS s'active par table et par défaut elle est deny-all. `alter table profiles enable row level security` — et à cet instant, sans aucune policy, PERSONNE (sauf le rôle de service et le propriétaire de la table) ne peut lire ni écrire quoi que ce soit. C'est ce qui est correct : tu pars de zéro permission et tu ouvres exactement ce dont tu as besoin. L'erreur catastrophique est d'activer la RLS et d'oublier une opération : si tu poses des policies de SELECT mais aucune d'INSERT, les INSERT échouent silencieusement ; pire, si tu N'ACTIVES PAS la RLS sur une nouvelle table, elle est complètement ouverte à l'anon key. C'est pour ça que DS-06 introduit les advisors : ils détectent exactement la table sans RLS que tu as oubliée.
Une policy a deux clauses qui embrouillent tout le monde : USING et WITH CHECK. USING filtre quelles lignes existantes l'opération voit ou touche (SELECT, UPDATE, DELETE). WITH CHECK valide quelles lignes nouvelles ou modifiées on autorise à écrire (INSERT, UPDATE). Pour SELECT, il n'y a que USING. Pour INSERT, il n'y a que WITH CHECK. Pour UPDATE, il y a les DEUX : USING dit quelles lignes tu peux mettre à jour, WITH CHECK dit en quoi elles peuvent se transformer. Une erreur classique est de mettre USING sur un INSERT et que ça ne fasse rien, ou de ne mettre que USING sur un UPDATE et de permettre à l'utilisateur de déplacer sa ligne vers un autre user_id. Le patron self-or-admin de MOONKEY : `using (user_id = auth.uid() or is_admin())`.
Le patron canonique self. Pour proofs, la policy d'insertion réelle de MOONKEY est `proofs_insert_self` : `create policy proofs_insert_self on proofs for insert to authenticated with check (user_id = auth.uid())`. Lis-la à voix haute : un utilisateur authentifié peut insérer une proof seulement si le user_id de la ligne qu'il insère est égal à sa propre identité. Il ne peut pas créer une proof au nom d'un autre, parce que WITH CHECK rejetterait toute ligne avec user_id ≠ auth.uid(). Note le `to authenticated` : la policy ne s'applique même pas au rôle anon, donc un visiteur sans session ne peut rien insérer. Pour SELECT, le patron self-or-admin : `using (id = auth.uid() or is_admin())` sur profiles — tu vois ta ligne, ou toutes si tu es admin.
auth.uid() est le cœur du système et il convient de savoir ce que c'est : une fonction SECURITY DEFINER de Supabase qui extrait le `sub` du JWT que le client envoie à chaque requête. Le client ne peut pas le falsifier parce que le JWT est signé par Supabase avec un secret que le client n'a pas ; le manipuler invalide la signature et Postgres rejette la session. C'est pour ça que `user_id = auth.uid()` est fiable d'une manière que `user_id = <valeur envoyée par le client>` ne le serait jamais. L'autorité vit dans la signature du token, pas dans la bonne foi du front.
Vérification par usurpation — c'est ce qui sépare un opérateur sérieux d'un opérateur qui « croit » que sa RLS fonctionne. Il ne suffit pas de lire la policy et d'acquiescer. Dans l'éditeur SQL de Supabase, tu peux simuler être un utilisateur concret en fixant le rôle et le claim : `set local role authenticated; set local request.jwt.claims to '{"sub":"<uuid-de-l-utilisateur-A>"}';` puis `select * from profiles`. Si tu vois des lignes d'autres utilisateurs, ta RLS est cassée. Le modèle de sécurité de MOONKEY dit explicitement « vérifié par usurpation » : un non-admin ne peut pas lire les lignes d'autrui, et cela a été PROUVÉ, pas supposé. Fais pareil : usurpe l'utilisateur A, tente de lire la ligne de l'utilisateur B, et exige que le résultat soit zéro ligne.
Deux pièges finaux. Premier : le rôle `service_role` (la clé de service, JAMAIS dans le client) contourne toute RLS — c'est pour ça qu'elle ne vit jamais dans une SSG et ne s'utilise que dans des edge functions de confiance. Deuxième : les policies sont permissives par défaut et se combinent avec OR, pas avec AND. Si tu as deux policies de SELECT sur la même table, une ligne visible par L'UNE des deux est visible. Ça surprend : ajouter une policy ne restreint jamais, ça élargit seulement. Pour restreindre, tu as besoin de policies RESTRICTIVE explicites, ou — plus simple et le plus habituel — d'une seule policy bien écrite par opération.
EXERCICE
Sur une branch de moonkey-lab, sur la table proofs déjà créée : active la RLS, et écris trois policies — `proofs_select_self` (SELECT, using user_id=auth.uid() or is_admin()), `proofs_insert_self` (INSERT, with check user_id=auth.uid()), et AUCUNE policy d'UPDATE/DELETE (deny par défaut). Crée deux utilisateurs de test A et B avec chacun une proof. Dans l'éditeur SQL, usurpe A (set local role authenticated + jwt claims sub=uuid_A) et vérifie : (1) un SELECT ne renvoie que la proof de A ; (2) un INSERT avec user_id=uuid_B est rejeté par WITH CHECK ; (3) un DELETE de la proof de A est rejeté parce qu'il n'y a pas de policy. Documente chacun des trois résultats.
LIVRABLE
Migration avec les trois policies sur proofs, plus un log de la session d'usurpation qui démontre les trois comportements : A ne voit pas B (SELECT), A ne peut pas écrire comme B (INSERT avec WITH CHECK), et personne ne peut supprimer (sans policy DELETE).
CLÉ ESSENTIELLE
Une RLS que tu n'as pas cassée exprès en usurpant un autre utilisateur est une RLS dont tu ne sais pas si elle fonctionne. La confiance en sécurité ne vient pas de la lecture de la policy ; elle vient d'avoir tenté l'abus et d'avoir vu Postgres le refuser.
ERREURS À ÉVITER
- ×Confondre le gate de admin.astro avec de la sécurité : cacher le panneau dans le front n'empêche pas l'attaquant d'appeler la table directement avec l'anon key depuis la console.
- ×Mettre USING là où va WITH CHECK (ou l'inverse) : un INSERT avec seulement USING ne valide rien et laisse entrer des lignes avec un user_id d'autrui.
- ×Activer la RLS et oublier la policy d'une opération : les INSERT commencent à échouer en silence, ou pire, une table sans RLS reste grande ouverte à l'anon key.
- ×Supposer que la RLS fonctionne en la lisant, au lieu de la vérifier par usurpation avec set role + jwt claims et d'exiger zéro ligne d'autrui.
- ×Croire qu'ajouter une seconde policy permissive restreint l'accès : les policies se combinent avec OR, donc chaque nouvelle policy ne fait qu'élargir le visible, jamais le réduire.
DS-03 Auth magic-link & sessions
leçon Implémenter l'authentification par magic-link avec Supabase et gérer la session côté client de façon honnête : savoir ce que la session garantit, ce qu'elle ne garantit pas, et pourquoi cela n'affaiblit pas ta RLS.
Auth magic-link & sessions
leçonImplémenter l'authentification par magic-link avec Supabase et gérer la session côté client de façon honnête : savoir ce que la session garantit, ce qu'elle ne garantit pas, et pourquoi cela n'affaiblit pas ta RLS.
MOONKEY LAB et Espejo utilisent le login sans mot de passe : l'utilisateur saisit son email, reçoit un lien, clique et se retrouve authentifié. C'est la meilleure UX et ça élimine toute une classe de vulnérabilités (tu ne stockes pas de mots de passe, il n'y a pas de fuites de hashes). Mais la session vit dans le navigateur, et un opérateur qui ne comprend pas où et comment le token est conservé confondra commodité et sécurité.
LA LEÇON
Comment fonctionne le magic-link, étape par étape. Le client appelle `supabase.auth.signInWithOtp({ email })`. Supabase génère un token à usage unique, l'associe à l'email et envoie un courriel avec un lien qui pointe vers ton app avec ce token dans le fragment d'URL. L'utilisateur clique ; ton app, au chargement, détecte le token, l'échange auprès de Supabase contre une paire access_token / refresh_token, et à partir de là le client est authentifié. L'access_token est un JWT signé à expiration courte (par défaut une heure) ; le refresh_token est de longue durée et sert à obtenir de nouveaux access tokens sans que l'utilisateur refasse un login. Tout cela est orchestré par le SDK ; ton travail est de comprendre le flux, pas de le réimplémenter.
Où vit la session. Par défaut, le SDK de Supabase dans le navigateur persiste la session dans localStorage. Cela a une conséquence de sécurité que tu dois énoncer à voix haute : un token dans localStorage est lisible par tout JavaScript qui tourne sur ta page. Cela signifie que ta surface d'attaque numéro un, c'est le XSS — si un attaquant parvient à injecter du JS dans ton site (un script tiers compromis, un innerHTML avec un input non assaini), il peut lire le token et voler la session. La défense n'est pas de cacher le token ; c'est de ne pas avoir de XSS : n'injecte pas de HTML utilisateur non assaini, audite chaque dépendance front, et traite chaque `<script>` tiers comme du code qui verra tes tokens.
La distinction honnête qui donne son nom au module : la session est une identité, pas une autorisation. Le fait que le client ait un access_token valide prouve QUI il est (auth.uid() renverra son uuid), mais ne lui donne droit à rien par lui-même. Le droit est décidé par la RLS à chaque query. C'est libérateur : tu n'as pas à défendre tes données dans le front. Même si un attaquant vole une session, il ne peut faire que ce que la RLS permet à CET utilisateur — voir ses propres lignes, pas celles des autres. La session volée d'un utilisateur normal ne donne pas d'accès admin, parce que is_admin() revalide contre la ligne de profiles, pas contre un claim que le client pourrait manipuler.
Gestion de l'état de session dans l'app. Le SDK expose `supabase.auth.getSession()` (lit la session actuelle, éventuellement depuis le localStorage) et `supabase.auth.onAuthStateChange((event, session) => ...)` (te notifie de SIGNED_IN, SIGNED_OUT, TOKEN_REFRESHED). Dans une SSG comme MOONKEY, tu n'as pas de rendu server-side de l'état d'auth, donc la page se charge d'abord dans un état « inconnu » puis, côté client, tu résous s'il y a une session. Conçois pour ça : montre un état neutre de chargement, ne clignote pas entre « invité » et « connecté ». Le gate visuel (montrer /cuenta seulement s'il y a une session) est une UX légitime — souviens-toi de DS-02 : c'est de l'UX, la sécurité reste dans la RLS.
Le trigger qui boucle la boucle. Quand un utilisateur s'inscrit pour la première fois via magic-link, Supabase crée une ligne dans auth.users. Mais ton app a besoin d'une ligne correspondante dans profiles. Ce n'est PAS le client qui le fait (il ne doit pas pouvoir choisir son propre role ni son founder_badge). C'est un trigger SECURITY DEFINER dans la base de données : `handle_new_user`, qui se déclenche `after insert on auth.users` et crée la ligne de profiles avec des valeurs par défaut sûres (role='monkey', jamais admin). Ainsi la création du profil est autoritative côté serveur : l'utilisateur ne peut pas naître admin parce que c'est le trigger, pas le client, qui décide des valeurs initiales. Ce patron, tu le développes à fond dans DS-04.
Logout et expiration, faits honnêtement. `supabase.auth.signOut()` efface les tokens du localStorage et révoque le refresh_token côté serveur. Important : si tu effaces seulement le localStorage à la main sans appeler signOut, le refresh_token reste valide côté serveur — fais-le toujours via le SDK. À propos de l'expiration : ne promets pas une « session pour toujours ». L'access_token expire au bout d'une heure et le SDK le rafraîchit avec le refresh_token de façon transparente ; si le refresh_token est révoqué ou expire, l'utilisateur refait un login. Communique-le à l'utilisateur avec honnêteté plutôt que de feindre une persistance éternelle.
Configure les URLs de redirection dans le panneau de Supabase (Auth > URL Configuration). Le magic-link redirige vers une URL qui DOIT figurer dans l'allowlist, sinon Supabase refuse l'échange — cela empêche qu'un attaquant fasse rediriger le lien vers un domaine qu'il contrôle. Dans MOONKEY, les redirect URLs incluent le domaine de production (moonkeylab.pages.dev) et localhost pour le développement, et rien d'autre. Une allowlist de redirection laxiste est un vrai vecteur de phishing.
EXERCICE
Dans MOONKEY (ou un clone local pointant vers une branch de Supabase), implémente le flux complet : une page de login qui appelle signInWithOtp avec l'email de l'utilisateur et affiche « consulte ton courrier » ; la gestion de l'échange au retour du lien ; et une page /cuenta qui utilise getSession + onAuthStateChange pour afficher l'email de l'utilisateur connecté ou rediriger vers login s'il n'y a pas de session. Vérifie trois choses : (1) après le clic sur le magic-link, il existe une ligne dans profiles créée par le trigger handle_new_user avec role='monkey' ; (2) signOut efface la session et révoque le refresh token ; (3) configure les redirect URLs dans le panneau et confirme qu'une redirection vers un domaine NON listé est rejetée.
LIVRABLE
Un flux de login par magic-link fonctionnant contre une branch de Supabase, avec capture de : la ligne profiles auto-créée par le trigger (role='monkey'), l'état de session lu dans /cuenta, et l'écran Auth > URL Configuration montrant l'allowlist des redirect URLs.
CLÉ ESSENTIELLE
La session prouve qui tu es, pas ce que tu peux faire. Si ta sécurité se casse quand quelqu'un vole une session, c'est que tu faisais confiance au client pour autoriser — et l'autorisation doit toujours vivre dans la RLS, où une session volée n'ouvre que ce que CET utilisateur pouvait déjà voir.
ERREURS À ÉVITER
- ×Traiter le token dans localStorage comme un secret sûr : il est lisible par tout JS de la page, donc ta vraie défense est de ne pas avoir de XSS, pas de cacher le token.
- ×Confondre avoir une session avec avoir une permission : la session donne une identité (auth.uid()), mais chaque accès reste décidé par la RLS — n'autorise jamais dans le front.
- ×Créer la ligne de profiles depuis le client au lieu du trigger handle_new_user : tu laisserais l'utilisateur choisir son propre role et tu ouvrirais l'auto-escalade vers admin.
- ×Effacer le localStorage à la main au lieu d'appeler signOut : le refresh_token reste vivant côté serveur et la session peut reprendre.
- ×Laisser l'allowlist des redirect URLs ouverte ou avec des jokers : ça transforme le magic-link en vecteur de phishing qui redirige vers un domaine de l'attaquant.
DS-04 SECURITY DEFINER, RPC et triggers
leçon Écrire des fonctions SECURITY DEFINER, des RPCs et des triggers qui exécutent de la logique privilégiée de façon contrôlée, sans ouvrir de trous d'escalade de privilèges.
SECURITY DEFINER, RPC et triggers
leçonÉcrire des fonctions SECURITY DEFINER, des RPCs et des triggers qui exécutent de la logique privilégiée de façon contrôlée, sans ouvrir de trous d'escalade de privilèges.
Il y a des opérations que la RLS seule ne peut pas exprimer : vérifier si quelqu'un est admin (la vérification elle-même a besoin de lire profiles, ce qui créerait une récursion), faire monter le rang d'un opérateur en revalidant les règles, ou empêcher un utilisateur de s'attribuer lui-même le founder_badge. SECURITY DEFINER est l'outil — et c'est exactement là où, mal utilisé, tu ouvres la porte dérobée que toute ta RLS tentait de fermer.
LA LEÇON
Ce que signifie SECURITY DEFINER. Une fonction normale dans Postgres tourne avec les permissions de qui l'APPELLE (SECURITY INVOKER, le défaut). Une fonction SECURITY DEFINER tourne avec les permissions de qui l'a CRÉÉE (typiquement un rôle avec privilèges, propriétaire des tables). Cela lui permet de faire des choses que l'appelant ne pourrait pas faire directement — par exemple, lire profiles pour vérifier un rôle, même si la RLS refuserait ce SELECT à l'utilisateur. C'est puissant et c'est dangereux : une SECURITY DEFINER est un petit morceau de code qui tourne au-dessus de la RLS. Chacune est une exception à ton modèle de sécurité, donc chacune doit être auditée comme telle.
Le cas is_admin(). Tu as besoin de savoir si l'utilisateur actuel est admin pour l'utiliser dans des policies (`using (... or is_admin())`). Mais si la policy de SELECT de profiles dépend de lire profiles pour connaître le rôle, tu as une récursion infinie : pour lire ta ligne tu as besoin de savoir si tu es admin, pour le savoir tu lis profiles, ce qui déclenche la policy à nouveau. La solution, c'est is_admin() en SECURITY DEFINER : elle tourne avec les privilèges du propriétaire, lit profiles SANS passer par la RLS, renvoie un booléen. De façon cruciale, elle NE renvoie PAS de données sensibles — seulement true/false sur l'appelant (`select role = 'admin' from profiles where id = auth.uid()`). Une SECURITY DEFINER sûre expose le minimum d'information : une décision, pas un dataset.
Blinder le search_path — c'est la vulnérabilité classique et celle que les advisors marquent sans pitié. Une SECURITY DEFINER qui ne fixe pas son search_path est exploitable : un attaquant crée une table ou une fonction du même nom qu'une que ta fonction utilise, dans un schéma qui est plus tôt dans le search_path, et ta fonction privilégiée exécute le code de l'attaquant avec les permissions du propriétaire. La défense est obligatoire : `create function is_admin() ... security definer set search_path = '' as $$ ... $$;` (ou `set search_path = pg_catalog, public` en qualifiant explicitement). Avec un search_path vide, tu références chaque table avec son schéma complet : `public.profiles`, pas `profiles`. Sans ça, ta fonction de sécurité EST le trou.
RPCs : logique métier invocable depuis le client. Un RPC dans Supabase est une fonction Postgres exposée via `supabase.rpc('nom', args)`. Dans MOONKEY, les RPCs réels sont update_operator_rank (fait monter le rang en revalidant), my_referral_stats (renvoie les statistiques de parrainage de l'utilisateur) et is_admin. Le patron en or : le RPC NE fait PAS confiance aux arguments du client pour l'identité. update_operator_rank ne reçoit pas « quel utilisateur faire monter » comme paramètre libre — il utilise auth.uid() en interne. S'il recevait un user_id comme argument, un attaquant appellerait rpc('update_operator_rank', { user_id: 'celui d'un autre' }). L'identité sort TOUJOURS de auth.uid() à l'intérieur de la fonction, jamais d'un paramètre que le client contrôle.
Le RPC revalide, il n'obéit pas. update_operator_rank n'est pas « mets mon rang à X parce que je le demande ». Il revalide les règles : l'utilisateur a-t-il complété les preuves requises pour ce rang ? Le commentaire du modèle de sécurité de MOONKEY est explicite — « Rangs seulement via update_operator_rank (revalide le rôle) ». Le client ne peut pas sauter de monkey à admin en le demandant ; le RPC vérifie les conditions réelles dans la DB et seulement ensuite il écrit. C'est là la différence entre un RPC qui est une API métier (vérifie les invariants) et un qui est un trou (écrit ce qu'on lui dit). N'accorde EXECUTE qu'à `authenticated`, jamais à anon : `grant execute on function update_operator_rank to authenticated`.
Triggers : des invariants qui s'appliquent quoi qu'il arrive. Certains invariants ne peuvent pas dépendre du fait que l'app les respecte. « Un non-admin ne peut jamais changer son propre role ni son founder_badge » en est un : si ça dépendait de l'app, n'importe quel UPDATE direct via l'anon key le contournerait. La solution de MOONKEY est le trigger guard_privileged_profile_columns, qui se déclenche `before update on profiles` et, si l'appelant n'est pas admin et tente de changer role ou founder_badge, lève une exception qui avorte la transaction. Combiné avec handle_new_user (after insert on auth.users, crée profiles avec role='monkey'), le résultat est qu'un utilisateur NAÎT comme monkey et NE PEUT PAS s'auto-promouvoir — ni par l'app, ni par un UPDATE brut avec l'anon key. Le trigger est le filet sous la RLS.
La discipline d'audit. Pour chaque SECURITY DEFINER que tu écris : son search_path est-il fixé ? renvoie-t-elle le minimum d'information possible ? dérive-t-elle l'identité de auth.uid() et non d'un paramètre ? son EXECUTE est-il restreint au bon rôle ? Après avoir créé ou modifié l'une de ces fonctions, lance get_advisors (DS-06) : l'advisor de sécurité marque les SECURITY DEFINER sans search_path et les fonctions avec des permissions laxistes. Une SECURITY DEFINER est du code privilégié ; traite-la avec la paranoïa que mérite du code qui tourne au-dessus de ta propre sécurité.
EXERCICE
Sur une branch de moonkey-lab : (1) Écris is_admin() en SECURITY DEFINER avec `set search_path = ''`, qui lit public.profiles et renvoie un booléen sur auth.uid(). (2) Écris un trigger before-update sur profiles qui avorte si un non-admin tente de modifier role ou founder_badge, et implémente-le en dérivant admin de is_admin(). (3) Teste l'abus : avec une session d'utilisateur normal (usurpée), tente `update profiles set role='admin' where id=auth.uid()` et vérifie que le trigger lève une exception. (4) Lance get_advisors(type='security') et confirme qu'aucun warning de search_path mutable n'apparaît sur tes fonctions.
LIVRABLE
Migration avec is_admin() (SECURITY DEFINER, search_path fixé) et le trigger guard sur profiles, plus la preuve de : l'UPDATE d'auto-escalade rejeté par le trigger, et un get_advisors de sécurité propre (sans warnings de search_path).
CLÉ ESSENTIELLE
SECURITY DEFINER est la seule partie de ton système qui tourne au-dessus de la RLS, donc c'est le seul endroit où une négligence escalade en brèche totale. La règle minimale non négociable : search_path fixé, identité depuis auth.uid() jamais depuis des paramètres, et permissions d'EXECUTE restreintes — parce qu'ici il n'y a pas de second filet.
ERREURS À ÉVITER
- ×Créer une SECURITY DEFINER sans `set search_path` : ça laisse un attaquant détourner des noms de table/fonction et exécuter son code avec les permissions du propriétaire — les advisors le marquent pour une raison.
- ×Passer le user_id comme argument du RPC au lieu d'utiliser auth.uid() à l'intérieur : le client appellerait le RPC avec l'id d'un autre utilisateur et opérerait en son nom.
- ×Faire en sorte que update_operator_rank obéisse au rang demandé au lieu de revalider les conditions : ça transforme la montée en grade en auto-escalade en un clic.
- ×Faire confiance à l'app pour empêcher un utilisateur de changer son role : un UPDATE direct avec l'anon key le contournerait ; l'invariant doit vivre dans un trigger.
- ×Accorder EXECUTE des RPCs à anon ou à public au lieu de seulement à authenticated : tu exposes de la logique métier privilégiée à des requêtes sans session.
DS-05 Sync local ↔ cloud
leçon Concevoir une synchronisation local↔cloud où l'état qui vit dans localStorage remonte vers Postgres sans perdre de données ni créer de doublons, en résolvant les conflits de façon déterministe.
Sync local ↔ cloud
leçonConcevoir une synchronisation local↔cloud où l'état qui vit dans localStorage remonte vers Postgres sans perdre de données ni créer de doublons, en résolvant les conflits de façon déterministe.
Espejo démarre avec son état dans localStorage (son seam Store est pensé pour passer de localStorage à Supabase sans réécrire l'app) et MOONKEY persiste la progression de l'opérateur qui existe d'abord dans le navigateur puis doit remonter vers le cloud quand l'utilisateur se connecte. Si la sync est naïve, un utilisateur qui a progressé hors ligne puis se connecte perd sa progression, ou la duplique, ou écrase ce qu'il avait sur un autre appareil.
LA LEÇON
Le vrai problème n'est pas « copier des données ». C'est réconcilier deux sources de vérité qui ont évolué séparément : le localStorage de ce navigateur et la ligne dans Postgres (qui a pu changer depuis un autre appareil). Avant d'écrire du code, décide explicitement la politique de conflit, parce que « ce qui arrivera » est la recette de la perte de données. Les options honnêtes : last-write-wins (le timestamp le plus récent gagne, simple mais peut perdre des éditions concurrentes), merge par champ (tu combines champ par champ selon des règles), ou append-only (tu n'écrases jamais, tu ajoutes seulement, et tu dérives l'état). Pour une progression d'apprentissage — qui est monotone, ne fait qu'avancer — la meilleure politique est généralement « le maximum gagne » : si local dit module 3 complété et cloud dit module 5, le résultat est 5 ; tu ne recules jamais.
Le localStorage comme couche, pas comme vérité. Le patron du seam Store d'Espejo est la bonne abstraction : ton app n'appelle pas localStorage ni Supabase directement, elle appelle un Store avec une interface (get, set, list). Il y a une implémentation LocalStore (localStorage) et une SupabaseStore (Postgres). L'app ne sait pas laquelle elle utilise. Cela transforme « passer au cloud » d'une réécriture en un changement d'implémentation derrière la même interface. La sync, alors, est une opération entre deux Stores : lire tout depuis LocalStore, réconcilier avec SupabaseStore, écrire le résultat dans les deux. Construis le seam AVANT d'avoir besoin du cloud ; c'est bon marché au début et hors de prix à rétro-adapter.
L'idempotence est la propriété qui te sauve. La sync va s'interrompre : l'utilisateur ferme l'onglet à mi-chemin, le réseau tombe, le SDK réessaie. Si ta sync n'est pas idempotente, une seconde exécution crée des doublons. L'outil, c'est l'upsert avec clé naturelle : `insert into progress (user_id, module_code, completed_at) values (...) on conflict (user_id, module_code) do update set completed_at = greatest(progress.completed_at, excluded.completed_at)`. Cette unique (user_id, module_code) de DS-01 est exactement ce qui rend l'upsert possible. Exécute la sync deux fois de suite : si l'état final est identique, c'est idempotent. Si la seconde fois duplique des lignes ou change quelque chose, tu as un bug qui en production se manifeste comme des données corrompues à minuit.
Le moment critique : le premier login après avoir travaillé en invité. L'utilisateur a progressé dans localStorage sans session, puis fait un magic-link (DS-03) et obtient un auth.uid(). Maintenant il faut adopter l'état anonyme sous son identité. Le flux sûr : au déclenchement de onAuthStateChange avec SIGNED_IN, tu lis la progression de LocalStore, tu la remontes avec un upsert en liant user_id = auth.uid(), et seulement ensuite tu marques le local comme synchronisé. NE supprime PAS le local tant que tu n'as pas confirmé que le cloud l'a reçu (la confirmation est la réponse sans erreur de l'upsert). Si tu supprimes avant et que la remontée échoue, tu as perdu la donnée. Ordre : remonter, confirmer, marquer synchronisé, optionnellement nettoyer.
La RLS reste maîtresse pendant la sync. Quand tu remontes la progression avec la session de l'utilisateur, l'upsert part avec son JWT, donc la policy `with check (user_id = auth.uid())` s'applique : tu ne peux pas remonter de progression au nom d'un autre, même si le localStorage disait le contraire. C'est une bonne chose — la sync n'est pas une porte dérobée à la sécurité. Cela signifie que tu dois lier user_id à auth.uid() au moment de remonter, pas utiliser un user_id que tu traînais de l'état anonyme (qui n'avait pas d'identité réelle). La sync respecte le modèle : le serveur reste l'autorité sur à qui appartient chaque ligne.
Conflits entre appareils, le cas que les gens oublient. L'utilisateur progresse sur le mobile (remonte au cloud), puis ouvre le portable qui avait un état local ancien. Sans précaution, le portable écrase le cloud avec des données dépassées. La défense : la réconciliation N'EST PAS « local écrase cloud », c'est « réconcilier les deux selon la politique ». Pour une progression monotone, tu ramènes le cloud, tu fais le merge « le maximum gagne » avec le local, et tu écris le résultat dans les deux. Ainsi le portable apprend ce qu'a fait le mobile au lieu de l'effacer. Pour des données non monotones, tu as besoin de timestamps par champ (updated_at) et de last-write-wins par champ, ce qui exige de stocker ces timestamps dès le début — une raison de plus pour les timestamptz de DS-01.
États de la sync, visibles et honnêtes. Modélise explicitement : synced (local == cloud), pending (il y a des changements locaux non remontés), syncing (en cours), error (a échoué, à réessayer). Ne mens pas à l'utilisateur avec une coche verte si la remontée a échoué. Un indicateur honnête de « changements non enregistrés » évite que l'utilisateur ferme l'onglet en croyant qu'il était à l'abri. La sync silencieuse qui échoue en silence est pire que ne pas avoir de sync : l'utilisateur fait confiance et perd des données sans le savoir.
EXERCICE
Sur la table progress (user_id, module_code, completed_at, avec unique(user_id, module_code)) : implémente un seam Store avec deux backends, LocalStore (localStorage) et SupabaseStore. Écris une fonction sync() qui : lit la progression locale, la réconcilie avec celle du cloud en utilisant 'le maximum completed_at gagne' via upsert avec `on conflict do update set completed_at = greatest(...)`, en liant user_id = auth.uid(). Teste trois scénarios : (1) idempotence — lance sync() deux fois et vérifie un état final identique, zéro doublon ; (2) premier login — progresse en invité, connecte-toi, et confirme que la progression anonyme apparaît sous ton user_id dans Postgres ; (3) deux appareils — simule un cloud avec module 5 et un local avec module 3, lance sync, et vérifie que le résultat est 5 des deux côtés (ne recule pas).
LIVRABLE
Un module Store avec interface commune et deux implémentations (LocalStore/SupabaseStore) plus une fonction sync() idempotente basée sur upsert, avec un log des trois scénarios : double exécution sans doublons, adoption de l'état anonyme au login, et réconciliation maximum-gagne entre deux appareils sans perte.
CLÉ ESSENTIELLE
La sync n'est pas copier des données, c'est réconcilier deux sources de vérité qui ont divergé — et la seule façon de ne rien perdre est de choisir explicitement la politique de conflit et de rendre l'opération idempotente avec un upsert sur clé naturelle. Si tu ne peux pas lancer ta sync deux fois de suite avec le même résultat, tu n'as pas de sync, tu as une bombe à retardement.
ERREURS À ÉVITER
- ×Effacer le localStorage avant de confirmer que la remontée au cloud a réussi : si l'upsert échoue, la donnée est perdue pour toujours.
- ×Sync non idempotente sans upsert sur clé naturelle : une exécution interrompue puis réessayée duplique des lignes qui apparaissent comme de la corruption à des heures indues.
- ×Laisser l'appareil avec l'état ancien écraser le cloud ('local gagne') au lieu de réconcilier : le portable efface ce que le mobile avait avancé.
- ×Traîner le user_id de l'état anonyme au lieu de le lier à auth.uid() au moment de remonter : la policy WITH CHECK le rejettera, ou pire, tu tenteras d'écrire sous une identité qui n'est pas la vraie.
- ×Afficher une coche verte de 'synchronisé' quand la remontée a échoué : l'utilisateur fait confiance, ferme l'onglet et perd son travail sans le savoir.
DS-06 Advisors, migrations et audit
leçon Opérer la base de données avec des changements versionnés via des migrations et utiliser les advisors de Supabase comme un linter de sécurité continu qui te prévient des tables sans RLS, des fonctions sans search_path et autres trous avant qu'ils n'atteignent la production.
Advisors, migrations et audit
leçonOpérer la base de données avec des changements versionnés via des migrations et utiliser les advisors de Supabase comme un linter de sécurité continu qui te prévient des tables sans RLS, des fonctions sans search_path et autres trous avant qu'ils n'atteignent la production.
MOONKEY partage la base de données avec XHUB IRON : un changement négligent peut écraser des tables d'un autre projet ou laisser une nouvelle table sans RLS ouverte à l'anon key. Sans migrations versionnées, il n'y a aucun moyen de savoir ce qui a changé ni de le revenir en arrière ; sans les advisors, tu découvres le trou de sécurité quand quelqu'un l'a déjà exploité. C'est la discipline qui garde honnête tout ce qui précède.
LA LEÇON
Migrations : la base de données comme code versionné. Chaque changement de schéma — une table, une colonne, une policy, une fonction — est un fichier .sql avec timestamp dans supabase/migrations/, appliqué avec apply_migration, jamais avec execute_sql à la main. La différence est la même qu'entre committer et éditer des fichiers en production par SSH : l'une te donne historique, revue et rollback ; l'autre te donne l'amnésie. Le nom du fichier (`<timestamp>_create_proofs.sql`, `<timestamp>_add_proofs_rls.sql`) raconte l'histoire du schéma. Quand dans six mois tu te demanderas pourquoi une colonne existe, la réponse est dans la migration qui l'a introduite, avec son nom et sa date — pas dans ta mémoire ni dans celle de personne.
execute_sql sert à LIRE, apply_migration sert à CHANGER. Cette règle opérationnelle évite l'erreur la plus courante. Utilise execute_sql pour inspecter (select, explain, vérifier l'état), pour usurper et vérifier la RLS (DS-02), pour l'exploration. Au moment où le SQL altère le schéma ou les policies d'une façon que tu veux persistante, ça va dans une migration. Un changement de sécurité appliqué avec execute_sql qui fonctionne mais n'est pas versionné est une dette : personne ne sait qu'il existe, personne ne peut le réviser, et en recréant le projet il disparaît.
Branches de Supabase pour ne pas casser la production. Avant d'appliquer une migration à impact, crée-la dans une branch (create_branch), teste-la là — y compris la vérification par usurpation de DS-02 et le get_advisors de DS-04 — et seulement ensuite fais-en le merge vers la production (merge_branch). La branch a sa propre base de données éphémère ; tu casses ce que tu veux sans toucher aux utilisateurs réels. C'est particulièrement critique dans MOONKEY parce que la DB est partagée : une migration qui touche par erreur une table iron_* ou world_* de XHUB se teste et se jette dans la branch, pas dans la base vivante qui sert deux projets.
Les advisors sont ton linter de sécurité. get_advisors(type='security') lance un ensemble de vérifications qui détectent exactement les trous que ces modules apprennent à éviter : tables avec RLS désactivée (DS-02), fonctions SECURITY DEFINER avec search_path mutable (DS-04), policies qui exposent des données, colonnes sans protection. get_advisors(type='performance') marque le reste : foreign keys sans index, index dupliqués, queries non couvertes. La discipline non négociable du CLAUDE.md de MOONKEY : 'Changements de RLS/sécurité : appliquer comme migration versionnée + get_advisors après'. Chaque fois que tu touches à la sécurité, l'advisor est la fermeture de la boucle — tu ne supposes pas que c'est bon, tu le vérifies avec l'outil.
Comment lire un advisor et agir. Un warning de l'advisor n'est pas du bruit à faire taire ; c'est une vulnérabilité concrète avec un remède concret. 'RLS disabled on public.proofs' signifie que n'importe qui avec l'anon key lit la table — le remède est enable row level security + policies, dans une migration. 'Function public.is_admin has a role mutable search_path' signifie que la fonction est détournable — le remède est `alter function ... set search_path = ''`, dans une migration. La routine mature : tu touches quelque chose → migration → get_advisors → s'il y a un warning, une autre migration qui le ferme → get_advisors propre. Il n'y a pas de 'je le répare plus tard' dans les advisors de sécurité ; plus tard, c'est après la brèche.
Isolation dans la DB partagée, le risque spécifique de MOONKEY. La base héberge des tables de MOONKEY (profiles, progress, feedback, leads, proofs) et de XHUB IRON (iron_*, world_*, focus_*, daily_focus_history). Ta discipline de migrations doit respecter cette frontière : une migration de MOONKEY ne doit JAMAIS altérer, ne serait-ce que par la négligence d'un DROP ou d'un ALTER trop large, une table de l'autre projet. Avant d'appliquer, lis le diff de la migration en adversaire : ne touche-t-elle que les tables que j'ai dites ? L'advisor et la revue du SQL sont les deux filets. Dans une DB partagée, un changement mal délimité n'est pas un bug à toi, c'est un incident d'un autre projet.
L'audit comme habitude, pas comme événement. L'audit n'est pas une chose que tu fais avant un launch ; c'est l'état par défaut quand on opère des données sérieusement. list_migrations te donne l'historique complet de comment le schéma est arrivé là où il est. get_logs te montre ce qui échoue en temps réel. get_advisors est le bilan de santé que tu lances après chaque changement et périodiquement même si tu ne changes rien (parce que Supabase ajoute de nouvelles vérifications et parce que le contexte change). L'opérateur qui traite la base de données comme un système vivant qu'on audite en continu est celui qui n'a pas l'appel de 3h du matin — parce qu'il a vu le warning dans la branch, une semaine avant, avec get_advisors.
EXERCICE
Prends la migration de RLS de proofs que tu as écrite en DS-02 mais cette fois avec discipline complète : (1) crée une branch de moonkey-lab ; (2) applique-y, comme migrations versionnées séparées et avec des noms descriptifs, la création de la table et ses policies ; (3) lance get_advisors(type='security') AVANT les policies et confirme que le warning 'RLS disabled' apparaît sur proofs ; (4) applique les policies et relance get_advisors, en confirmant que le warning disparaît ; (5) introduis exprès une fonction SECURITY DEFINER sans search_path, vérifie que l'advisor la marque, corrige-la avec set search_path='' dans une autre migration, et confirme un advisor propre ; (6) fais le merge_branch vers la production seulement avec l'advisor au vert. Documente la liste des migrations finale avec list_migrations.
LIVRABLE
Une branch avec des migrations versionnées et nommées pour table+policies+fonction, plus une séquence de sorties de get_advisors qui démontre le cycle fermer-warning : 'RLS disabled' présent → absent après les policies, 'mutable search_path' présent → absent après le fix, et un get_advisors final propre avant le merge en production.
CLÉ ESSENTIELLE
Les advisors transforment ton modèle de sécurité de quelque chose que tu crois avoir bien fait en quelque chose que l'outil confirme être bien fait. La règle de l'opérateur sérieux : aucun changement de sécurité n'est considéré terminé tant que get_advisors n'est pas propre — parce que le coût d'un warning ignoré n'est pas un warning, c'est une brèche que tu découvres à la dure.
ERREURS À ÉVITER
- ×Appliquer des changements de schéma ou de policies avec execute_sql au lieu de apply_migration : tu perds historique, revue et rollback, et le changement disparaît en recréant le projet.
- ×Toucher la base de production partagée directement au lieu de tester dans une branch : un ALTER ou un DROP trop large se transforme en incident pour XHUB IRON.
- ×Sauter get_advisors après un changement de sécurité : tu laisses vivante précisément la table sans RLS ou la fonction sans search_path que l'advisor aurait marquée en quelques secondes.
- ×Traiter un warning de l'advisor comme du bruit à faire taire au lieu d'une vulnérabilité avec un remède concret : 'je le répare plus tard' en sécurité, c'est 'je le répare après la brèche'.
- ×Ne pas lire le diff de la migration en adversaire avant d'appliquer dans une DB partagée : un changement mal délimité écrase des tables iron_*/world_* d'un autre projet sans que tu t'en aperçoives.
Constellation suivante
Builders
Livrer du produit