Isoler les données de chaque entreprise avec PostgreSQL RLS
Dans un SaaS multi-tenant, une seule requête oubliée peut exposer les données d'un client à un autre. Voici comment je confie cette garantie à la base de données plutôt qu'au code applicatif.
Faciloop héberge plusieurs entreprises dans la même base PostgreSQL. Le risque classique : un WHERE organization_id = ... oublié dans une requête, et une entreprise voit les factures d'une autre. Plutôt que de compter sur la discipline de chaque développeur, j'ai déplacé la règle au niveau de la base avec la Row Level Security (RLS) de PostgreSQL, exposée par Supabase.
Le modèle : une colonne, une table d'appartenance
Chaque table métier porte une colonne organization_id. Une table memberships relie un utilisateur à une ou plusieurs organisations, avec son rôle. Toute la sécurité repose ensuite sur une seule question : l'utilisateur connecté est-il membre de l'organisation de cette ligne ?
create function public.is_member(org uuid)
returns boolean
language sql stable security definer
set search_path = public
as $$
select exists (
select 1 from memberships
where organization_id = org
and user_id = auth.uid()
);
$$;
alter table invoices enable row level security;
create policy "members read their invoices"
on invoices for select
using (public.is_member(organization_id));
create policy "members write their invoices"
on invoices for insert
with check (public.is_member(organization_id));La fonction is_member est déclarée security definer pour pouvoir lire memberships sans que cette table soit elle-même lisible par tous, et stable pour que PostgreSQL puisse réutiliser son résultat au sein d'une requête.
Ce qui change côté application
- Le frontend interroge Supabase normalement : une requête sans filtre ne renvoie que les lignes autorisées.
- Une erreur de code devient une liste vide, pas une fuite de données.
- Les règles métier plus fines (quotas, permissions hiérarchiques) restent dans des Edge Functions, côté serveur.
Les pièges à éviter
- La clé
service_rolecontourne toutes les policies : elle ne doit jamais quitter le serveur. - Une table sans
enable row level securityest ouverte : je vérifie la liste des tables à chaque migration. - Indexer
organization_idsur chaque table, sinon chaque policy devient un scan complet. - Tester les policies avec plusieurs comptes réels, pas seulement avec un administrateur.