---
title: La RLS est activée, et pourtant le rôle propriétaire lit tout : comment le détecter
description: 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.
category: Sécurité
format: article
pillar: audit-technique
tags: rls, postgresql, supabase, securite, multi-tenant
author: Charlie Dallier-Wood
publishedAt: 2026-11-06
canonical: https://sycode.fr/blog/rls-contournee-par-le-role
---
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](/blog/webhook-stripe-200-sans-ecriture). 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](https://www.postgresql.org/docs/current/ddl-rowsecurity.html) 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](https://www.cnil.fr/fr/reglement-europeen-protection-donnees/chapitre4#Article33) 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.

```sql
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](https://supabase.com/docs/guides/database/postgres/row-level-security) 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](/case-studies/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.

```sql
-- à 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é :

```text
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](https://supabase.com/docs/guides/database/postgres/row-level-security) 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 :

```sql
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é :

```sql
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 :

```text
 rolname  | rolsuper | rolbypassrls 
----------+----------+--------------
 asap_api | f        | f
```

puis pour les tables de tous les schémas applicatifs :

```text
 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`](/blog/set-config-pooler-uuid-vide).
