La RLS est activée, et pourtant le rôle propriétaire lit tout : comment le détecter
Le propriétaire d'une table et les rôles BYPASSRLS ignorent les policies. Le script pour le reproduire, les requêtes pour le détecter, le test qui échoue.
Sommaire · 6 sections
Le propriétaire d’une table passe à travers ses policies, et les superusers et rôles BYPASSRLS passent toujours à travers. FORCE ROW LEVEL SECURITY soumet le propriétaire, mais pas les deux autres. Ce qu’il faut vérifier n’est donc pas que la policy existe : c’est que le rôle avec lequel ton application se connecte lit zéro ligne quand aucun contexte d’organisation n’est posé. Les requêtes et le test sont plus bas.
Le 27 mai 2026, mon webhook Stripe a lu zéro ligne sur asap.cool. Sur le moment c’était un bug ; en y repensant, ça prouvait que le rôle de mon application était soumis à la RLS. Le cas est détaillé dans le webhook qui répondait 200 et n’écrivait rien. Ce qui m’intéresse ici, c’est le cas inverse, celui où le rôle n’est pas soumis, et où rien ne te le dit.
Petite précision avant de commencer : tout ce que je cite de la doc PostgreSQL concerne la version 18.6, lue le 26 octobre 2026, et la doc Supabase est lue le même jour. Si tu lis ça dans six mois, revérifie.
Ce qui passe à travers la RLS, et ce que ça coûte
La doc PostgreSQL l’écrit sans détour : « Superusers and roles with the BYPASSRLS attribute always bypass the row security system when accessing a table. Table owners normally bypass row security as well, though a table owner can choose to be subject to row security with ALTER TABLE ... FORCE ROW LEVEL SECURITY. » La même page précise que les opérations sur la table entière comme TRUNCATE et REFERENCES, ainsi que les contrôles d’intégrité référentielle, ne sont pas soumis à la RLS.
Le problème, c’est que rien ne plante. Si ton application se connecte avec le rôle qui a joué les migrations, ce rôle est propriétaire de ses tables, tes policies sont écrites, relrowsecurity vaut true, et chaque requête d’un client revient avec toutes les lignes de tous les autres. Une fuite entre organisations ne se lit pas dans les logs. Elle se lit le jour où un client voit les données d’un autre. Si ces données sont personnelles, c’est une violation au sens du RGPD : l’article 33 impose de la notifier à la CNIL « dans les meilleurs délais et, si possible, 72 heures au plus tard après en avoir pris connaissance », sauf si elle n’engendre pas de risque pour les personnes.
Le reproduire dans un psql vide
Deux rôles, aucun n’est superuser, sinon le test ne prouve rien.
CREATE ROLE demo_owner LOGIN;
CREATE ROLE demo_app LOGIN;
GRANT CREATE, USAGE ON SCHEMA public TO demo_owner;
GRANT USAGE ON SCHEMA public TO demo_app;
SET ROLE demo_owner;
CREATE TABLE notes (id serial primary key, tenant_id uuid, body text);
INSERT INTO notes (tenant_id, body) VALUES
('11111111-1111-1111-1111-111111111111', 'client A'),
('22222222-2222-2222-2222-222222222222', 'client B');
ALTER TABLE notes ENABLE ROW LEVEL SECURITY;
CREATE POLICY tenant_isolation ON notes
USING (tenant_id = current_setting('app.current_tenant', true)::uuid);
GRANT SELECT ON notes TO demo_app;
SELECT count(*) FROM notes; -- propriétaire, RLS activée -> 2
ALTER TABLE notes FORCE ROW LEVEL SECURITY;
SELECT count(*) FROM notes; -- propriétaire, RLS forcée -> 0
SET ROLE demo_app;
SELECT count(*) FROM notes; -- rôle applicatif, sans SET -> 0
SET app.current_tenant = '11111111-1111-1111-1111-111111111111';
SELECT count(*) FROM notes; -- rôle applicatif, avec SET -> 1
Le premier count est celui qui compte : sans FORCE, le propriétaire lit tout, policy ou pas. Les valeurs en commentaire sont celles que psql m’a renvoyées le 5 novembre 2026 sur PostgreSQL 18.6, dans cet ordre : 2, 0, 0, 1.
Les rôles qui passent toujours
FORCE ne règle que le propriétaire. Un superuser et un rôle créé avec BYPASSRLS passent toujours : la colonne rolbypassrls de pg_roles le dit, « Role bypasses every row-level security policy ». Sur Supabase, c’est le cas de service_role, que la doc décrit en une phrase : il contourne la RLS, donc garde-le côté serveur. Une clé de ce rôle dans un bundle JavaScript annule toute la RLS de la base, sans qu’une seule policy ait besoin d’être fausse.
Ces rôles ne se voient pas depuis l’application : la requête réussit, les lignes reviennent, tout a l’air normal. Le contrôle n’a donc de sens qu’avec la connexion de l’application, jamais avec celle d’administration.
Sur asap.cool, un rôle qui ne possède rien
Sur asap.cool, je n’ai rien eu à rattraper : c’est posé depuis le premier jour du dépôt, le 19 mai 2026. Le commit qui monte Postgres crée le rôle de l’application, asap_api, en NOBYPASSRLS. La première migration, celle des organisations, met ses tables en FORCE ROW LEVEL SECURITY, et un test le même jour prouve qu’une organisation ne lit pas l’autre. Depuis, chaque migration qui crée une table d’organisation l’active et la force dans le même fichier.
Le rôle de l’application ne possède aucune table : ce n’est pas ma discipline de développeur qui garantit l’isolation, c’est ce rôle. Le contexte d’organisation est posé par un type, OrgTx, dans son constructeur, depuis le 12 juillet 2026, et lu avec NULLIF(current_setting(…), '')::uuid. Un traitement sans utilisateur, comme le webhook, passe par une policy système qui n’ouvre que la lecture dont il a besoin plutôt que par un rôle qui contourne.
Le test qui échoue si la RLS ne protège pas
Le script du dessus est une démonstration. Pour qu’il garde ton projet, il faut qu’il fasse échouer la CI quand le rôle de l’application lit trop, et qu’il vérifie aussi le cas utile : une assertion « zéro ligne » passe sur une table vide, donc elle ne prouve rien toute seule. Sans ce test, le premier à découvrir un trou dans l’isolation est un client.
-- à lancer avec la chaîne de connexion de l'application, pas celle des migrations
BEGIN;
SET LOCAL ROLE demo_app; -- inutile si tu te connectes déjà avec ce rôle
DO $$
DECLARE n int;
BEGIN
SELECT count(*) INTO n FROM notes;
IF n <> 0 THEN
RAISE EXCEPTION 'RLS : % lignes lisibles sans contexte', n;
END IF;
END $$;
SET LOCAL app.current_tenant = '11111111-1111-1111-1111-111111111111';
DO $$
DECLARE visibles int; etrangeres int;
BEGIN
SELECT count(*), count(*) FILTER (WHERE tenant_id <> '11111111-1111-1111-1111-111111111111')
INTO visibles, etrangeres FROM notes;
IF visibles = 0 OR etrangeres > 0 THEN
RAISE EXCEPTION 'RLS : % lignes visibles dont % hors tenant', visibles, etrangeres;
END IF;
END $$;
ROLLBACK;
Sur la table notes du script précédent, lancé dans une session neuve avec psql -v ON_ERROR_STOP=1, il se termine sans erreur. J’ai ensuite remplacé la policy par USING (true) et relancé :
ERROR: RLS : 2 lignes lisibles sans contexte
CONTEXT: PL/pgSQL function inline_code_block line 6 at RAISE
psql sort alors avec le code 3, ce qui suffit à faire échouer une CI. Si tu es sur Supabase, la doc décrit le même exercice avec supabase test db : des assertions d’accès et de refus par rôle.
Sur asap.cool, le test équivalent vit au niveau de l’API plutôt qu’en SQL. Il crée une ressource de chaque famille dans une organisation A, puis appelle chaque route qui prend un identifiant en tant qu’utilisateur de l’organisation B, et en tant que clé d’API de B : chaque réponse doit être un refus, et A doit tout posséder à la fin. Une seconde partie, sans base de données, tourne à chaque push : elle échoue si une route avec un identifiant n’a ni cas d’isolation, ni exemption justifiée. Une nouvelle route ne passe donc pas sans son test. La partie qui demande Postgres se lance à la main depuis la CI.
Ce que je lance avant d’ouvrir à de vrais utilisateurs
D’abord, depuis la connexion de l’application, ce qu’est son rôle :
SELECT rolname, rolsuper, rolbypassrls FROM pg_roles WHERE rolname = current_user;
Deux false, sinon la RLS ne s’applique pas à lui. Ensuite, qui possède quoi et ce qui est forcé :
SELECT c.relname, r.rolname AS proprietaire,
c.relrowsecurity AS rls, c.relforcerowsecurity AS forcee
FROM pg_class c
JOIN pg_roles r ON r.oid = c.relowner
JOIN pg_namespace n ON n.oid = c.relnamespace
WHERE c.relkind = 'r' AND n.nspname = 'public'
ORDER BY c.relname;
Je cherche les tables dont le propriétaire est le rôle de l’application, et celles où forcee vaut false. Enfin le test du dessus, avec ce même rôle.
Sur une base à plusieurs schémas, je regroupe plutôt par propriétaire. Voici ce que donne ma base de développement d’asap.cool, migrée jusqu’au 21 octobre 2026, d’abord avec le rôle de l’application :
rolname | rolsuper | rolbypassrls
----------+----------+--------------
asap_api | f | f
puis pour les tables de tous les schémas applicatifs :
proprietaire | tables | rls | forcee
--------------+--------+-----+--------
postgres | 179 | 162 | 162
Aucune table n’appartient à asap_api, et toutes celles qui ont la RLS sont forcées. Les 17 sans RLS ne portent pas de données d’organisation : 14 tables du schéma auth, rattachées à un utilisateur, et 3 tables de passage d’une migration de données.
Moi, je ne regarde pas si une policy existe : je regarde ce que le rôle de l’application lit sans contexte. La suite, c’est ce qui arrive quand le contexte est posé mais vide, avec l’erreur 22P02 de set_config.





