Sirocco

PostgreSQL RLS : isoler les clients avec Row Level Security

Database-per-tenant vs schema-per-tenant vs RLS. Pièges RLS, stratégies d'index matérialisés, migrer une ORM.

4 min de lecture
PostgreSQL RLS : isoler les clients avec Row Level Security

L'isolation multi-tenant est l'une de ces décisions architecturales qui paraissent simples sur le tableau blanc et coûtent cher quand elles sont prises trop vite. Trois familles de solutions cohabitent en PostgreSQL : une base par client, un schéma par client, ou un schéma partagé avec Row Level Security. Aucune n'est universellement supérieure ; le bon choix dépend du nombre de tenants, de l'hétérogénéité de leurs charges, des exigences réglementaires d'isolation et de la maturité de l'équipe ops. Cet article se concentre sur RLS, parce que c'est l'option la plus dense en coût d'infrastructure et la plus piégeuse en pratique. Le modèle database-per-tenant est conceptuellement le plus simple : une instance PostgreSQL ou un cluster par client. L'isolation est garantie par construction, les migrations sont localisables, et un client lourd ne perturbe pas les autres. Le coût marginal d'un nouveau tenant — instance RDS, sauvegardes, connexions — devient toutefois prohibitif au-delà de 50 à 100 clients, et chaque migration applicative doit s'orchestrer N fois. Le schema-per-tenant — un schéma Postgres par client dans une même base — réduit le coût d'instance mais introduit la complexité du `search_path` dynamique et la limite naturelle de Postgres à quelques milliers de schémas avant que les opérations DDL deviennent lentes. RLS sur schéma partagé est la voie qui scale au million de tenants. Une colonne `tenant_id` est ajoutée à chaque table métier, une policy `ALTER TABLE ... ENABLE ROW LEVEL SECURITY` est activée, puis une policy `USING (tenant_id = current_setting('app.tenant')::uuid)` filtre les lignes. Le GUC `app.tenant` est positionné en début de transaction par l'application. C'est cinquante lignes de SQL pour des millions de clients potentiels, et c'est précisément cette densité qui rend les pièges plus violents quand on s'y méprend. Le premier piège est l'asymétrie entre lecture et écriture. Une policy avec seulement `USING` filtre les lectures, mais autorise les écritures sans contrôle : un client peut créer une ligne avec un `tenant_id` qui n'est pas le sien, polluant la base d'un autre tenant. La bonne formule est `USING (tenant_id = current_setting('app.tenant')::uuid) WITH CHECK (tenant_id = current_setting('app.tenant')::uuid)`. Sans `WITH CHECK`, on se croit isolé en lecture, on est ouvert en écriture, et la régression passe inaperçue jusqu'à ce qu'un client lit ses propres données et y voit des lignes étrangères. Les index sont le deuxième piège. Postgres applique la policy comme un filtre supplémentaire, donc une requête `SELECT * FROM orders WHERE id = ?` lue avec RLS devient `... WHERE id = ? AND tenant_id = ?`. Si l'index est sur `(id)` seul, le planner doit lire toutes les lignes matching l'id puis filtrer par tenant_id ; sur une table large avec collisions d'id entre tenants, c'est catastrophique. La règle : tout index utilisé sous RLS doit commencer par `tenant_id` ou être un index composite incluant `tenant_id`. Quelques heures d'audit d'index avec `pg_stat_statements` rattrapent souvent 90% du gain de performance. Troisième piège : les vues matérialisées et les fonctions SECURITY DEFINER ignorent RLS par défaut. Une vue matérialisée est calculée hors policy au moment du `REFRESH`, et tous les utilisateurs voient l'intégralité du résultat précalculé. Une fonction SECURITY DEFINER s'exécute avec les droits de son propriétaire, court-circuitant les policies tenant. La parade est explicite : pour les vues matérialisées, intégrer un `tenant_id` dans la clé d'index et ré-appliquer la policy sur la vue elle-même ; pour les SECURITY DEFINER, vérifier manuellement le `current_setting('app.tenant')` dans le corps de la fonction. Ces deux objets sont les sources les plus fréquentes de fuites cross-tenant en production. Les migrations zero-downtime sous RLS demandent une discipline supplémentaire. Ajouter une colonne `tenant_id` à une table existante de 50 millions de lignes ne se fait pas avec `ALTER TABLE ... ADD COLUMN tenant_id ... NOT NULL` (qui pose un AccessExclusiveLock pendant la durée du backfill). La séquence éprouvée : ajouter la colonne nullable, backfill par batchs de 10 000 lignes avec `UPDATE ... WHERE ctid IN ...`, ajouter un trigger qui force la valeur sur les inserts pendant la transition, puis basculer en `NOT NULL` et activer la policy. Cette danse dure des heures sur les grosses tables ; les outils pg_repack et pg-migrate-tools la gèrent élégamment. Les ORM ne supportent pas RLS nativement, ce qui est une décision de design défendable : RLS est un mécanisme côté base, pas côté code. Le pattern le plus propre est un middleware applicatif (Express, FastAPI, Django middleware) qui ouvre la transaction, exécute `SET LOCAL app.tenant = '...'`, puis laisse l'ORM faire son travail. Le `LOCAL` est essentiel : il limite la portée du GUC à la transaction courante, donc une fuite via un pool de connexions partagé est impossible. Tout code qui touche la base hors de ce middleware (jobs background, scripts d'admin) doit reproduire le pattern explicitement. Le test ultime est aussi le plus simple à écrire et le plus rarement écrit : un test d'intégration qui, pour chaque table, ouvre une connexion en tant que tenant A, tente toutes les opérations CRUD sur des lignes appartenant à tenant B, et vérifie qu'aucune ligne n'est lue, modifiée ou supprimée. Faire tourner ce test à chaque PR ; ajouter un test variant pour les jointures, les vues, et les fonctions stockées. Un projet RLS sans cette suite de tests d'isolation est un projet qui aura sa fuite, tôt ou tard — la seule question est quand.