La visibilité qui s'hérite (pourquoi RLS gagne)
Trois options, une gagnante
Pour appliquer la visibilité par utilisateur, trois familles :
| Option | Verdict |
|---|---|
| A. RLS + GUC (leçon précédente) | ✅ couvre lecture ET écriture, et hérite partout |
B. Vues de visibilité (v_visible_*) | ❌ ne scopent pas les écritures → policy quand même nécessaire = logique dupliquée ; et il faut réécrire toutes les requêtes pour cibler les vues (une oubliée = fuite) |
C. Filtrage applicatif (WHERE user_sub = …) | ❌ à répliquer sur inbox + listes + détail + chaque tool + chaque export ; aucun garde-fou substrat ; c'est l'endroit où on en oublie un |
Ce qui fait gagner A, ce n'est pas l'élégance — c'est l'héritage.
Ce qui hérite gratuitement
Les tables filles. Les lignes de détail, pièces jointes, messages de conversation n'ont pas de policy de visibilité propre : leur policy dit « visible si la ligne parente l'est » (EXISTS (SELECT 1 FROM parent WHERE …)). Comme cette sous-requête est elle-même filtrée par la RLS du parent, elle hérite automatiquement de la visibilité utilisateur. Zéro migration sur les filles.
Les vues. Le copilot lit des vues security_invoker = true (cf. leçon RLS). Une vue en security_invoker s'exécute avec les droits de l'appelant → elle applique la RLS des tables de base. Donc si un tool du chat ouvre sa session en session_with_operator, le chat ne peut pas voir le portefeuille d'un autre utilisateur — sans une ligne de code dédiée dans les tools.
Les exports. L'export ERP, l'export CSV tournent sous session_with_operator → filtrés par la même policy. L'exigence « un standard n'exporte que son périmètre » est satisfaite par construction, pas par vigilance.
Mettre la règle dans la RLS, c'est la mettre sous tout ce qui lit la base. Tout ce qui passe par la base — connu ou futur — hérite de la règle. C'est l'opposé du filtrage applicatif, où chaque nouveau chemin de lecture doit se souvenir de filtrer.
« La visibilité suit le compte, pas l'auteur »
Une règle métier délicate : un devis créé par Alice sur un compte ensuite affecté exclusivement à Bob → Alice perd la visibilité. Avec un filtrage applicatif, ça se code (et s'oublie). Avec la RLS d'Option A, c'est impossible à violer : la policy ne regarde jamais la colonne « créé par ». Elle ne lit que le compte et son affectation. La visibilité bascule à l'affectation, structurellement, sans code applicatif. Ce que la policy ne lit pas ne peut pas être une faille.
Deux décisions qui comptent
La clé d'identité de la policy. On compare app.user_id à quoi ? Au sub de l'identité (le claim du JWT), déjà présent dans la requête et déjà stocké dans les colonnes d'attribution → comparaison texte = texte, aucun join dans le prédicat RLS chaud. L'alternative (un id interne de membre) est plus « propre » en modélisation mais impose un join dans chaque prédicat exécuté par ligne — coût perf réel. La cohérence avec l'existant prime sur la pureté.
La migration sans friction. Comment enrôler les tenants existants sans backfill douloureux ?
- JIT bootstrap : au 1er passage après déploiement, si le tenant n'a aucun membre, on crée le membre historique en
admin/actif. L'utilisateur seul redevient admin sans rien faire. - Garde-fou : dès qu'un membre existe, un utilisateur sans invitation ne doit pas s'auto-promouvoir (sinon trou de sécurité) → rejet, l'invitation devient requise.
- Rien d'affecté = tout visible (la policy tombe sur
NOT EXISTS) → un tenant mono-utilisateur ne voit aucune différence. La feature est invisible tant qu'on ne s'en sert pas.
Pourquoi les vues de visibilité (Option B) ne suffisent-elles pas ?