En bref
Une politique comme auth_in_org(organization_id) demande : « l’appelant est-il membre de l’organisation à laquelle appartient cette ligne ? ». Pour un utilisateur qui appartient à l’org A et à l’org B, la réponse est oui dans les deux cas. Dans ma sonde, la RLS seule donnait à cet utilisateur un accès en lecture et en écriture à un document de l’autre organisation. En ajoutant à la requête l’organisation de la requête en cours, on obtenait 0 ligne.
Je l’ai découvert en durcissant la plateforme de documents de chantier, une application Supabase multi-tenant. Chaque table de locataire avait la row level security activée et forcée, tout semblait donc terminé.
Qu’est-ce qui semblait sûr ?
Chaque politique avait la même forme : l’organization_id de la ligne doit être une organisation dont l’appelant est membre. À cause de cela, quelques server actions cherchaient un document par son seul identifiant. Le raisonnement était « la RLS va filtrer de toute façon ».
const { data } = await supabase
.from('documents')
.select('*')
.eq('document_id', id)
.single()
Pour un utilisateur qui n’a qu’une appartenance, cela fonctionne, et un test avec un tel utilisateur passe.
Pourquoi la RLS laisse-t-elle passer l’autre organisation ?
La politique est un test d’appartenance. Postgres l’évalue pour chaque ligne, comme le décrit la documentation sur la row security, et il ne sait pas pour quelle organisation la requête agit, car cela vit dans l’application : dans l’URL, un cookie ou le sélecteur d’organisation. Pour un utilisateur membre des deux organisations, toutes les lignes des deux passent.
Le filtre organization_id de l’application n’est donc pas une seconde couche au-dessus de la RLS. Pour cet utilisateur, c’est le seul contrôle.
Pendant que vous êtes dans ces helpers, regardez s’ils tiennent compte de organizations.is_active. Les helpers d’appartenance ne le font souvent pas, et une organisation désactivée garde alors l’accès en lecture et en écriture au niveau de la RLS. Quand le seul contrôle is_active se trouve dans le résolveur de locataire de l’application, toute requête qui contourne le résolveur contourne aussi la désactivation.
Qu’est-ce qui l’a corrigé ?
L’organisation de la requête est devenue un argument obligatoire de chaque fonction de service qui lit ou écrit des données de locataire. Un appel ne peut pas l’oublier, car la fonction ne compile pas sans elle.
export async function getDocument(orgId: string, id: string) {
return supabase
.from('documents')
.select('*')
.eq('organization_id', orgId)
.eq('document_id', id)
.single()
}
Deux règles de plus sont sorties de la revue :
- Ne prenez jamais l’organisation de la ligne que vous allez écrire.
update ... where id = $1 and organization_id = <that row's own organization_id>est toujours vrai, donc ne protège rien. - Une clé étrangère composite sur
(id, organization_id)fait refuser par la base un lien entre des lignes d’organisations différentes. Vérifiez chaque table enfant : il est facile de l’ajouter à trois et d’oublier la quatrième.
Comment vérifier votre propre application ?
Créez un utilisateur avec une appartenance active dans deux organisations. Ensuite, dans l’éditeur SQL d’une copie locale ou de préproduction, agissez en tant que cet utilisateur et demandez une ligne de l’autre organisation :
begin;
set local role authenticated;
set local request.jwt.claims = '{"sub": "<user id>", "role": "authenticated"}';
-- RLS alone: returns the org B document
select document_id from documents where document_id = '<org B document id>';
-- with the org of the request: 0 rows
select document_id from documents
where document_id = '<org B document id>'
and organization_id = '<org A id>';
rollback;
Refaites-le avec un update, puis une fois de plus après avoir désactivé la seconde organisation. Si la première requête renvoie une ligne, regardez tous les endroits qui interrogent des données de locataire par l’identifiant seul.
Quelle est la gravité ?
Évaluez-la honnêtement. L’attaquant doit avoir une vraie appartenance aux deux organisations ; il s’agit donc d’intégrité et de pistes d’audit : quelqu’un qui a accès à deux clients modifie le mauvais, et le journal dit que c’était autorisé. Ce n’est pas un moyen pour un inconnu de lire tous les locataires.
La leçon générale est celle de ma façon de tester la RLS de Supabase : testez ce qu’un vrai utilisateur peut faire, avec des données réalistes, et pas seulement ce que dit chaque politique. Trois notes liées : un rôle tiré des métadonnées d’inscription, un NULL qui a sauté un contrôle de permission et les schémas que sert la Data API. Le test des deux entreprises est la première vérification de ma checklist de sécurité d’une application Lovable. Pour les politiques, droits et paramètres autour, l’Audit de sécurité Supabase gratuit applique 46 règles OWASP Top 10:2025 ; savoir si une politique laisse passer un utilisateur de deux organisations est une question de forme des données à laquelle seule la requête ci-dessus répond.