# Cinq façons dont la sécurité au niveau des lignes laisse tout passer

La sécurité au niveau des lignes est la meilleure défense contre une clause WHERE oubliée, jusqu’à ce qu’elle cesse de s’appliquer sans bruit. Cinq façons dont elle laisse tout passer sur PostgreSQL 17, chacune avec une reproduction, un correctif et une requête de catalogue qui la détecte en CI.

**La sécurité au niveau des lignes (row-level security, RLS) est la meilleure défense que Postgres vous offre contre le `WHERE user_id = …` oublié. Elle est aussi facile à désactiver sans s’en apercevoir : un rôle doté d’un seul attribut, une vue créée par le mauvais utilisateur, un paramètre qui revient sous la forme d’une chaîne vide au lieu de NULL. Cet article passe en revue cinq façons dont RLS cesse silencieusement de protéger vos données. Chacune est accompagnée d’une reproduction que vous pouvez coller dans psql, d’un correctif et, à la fin, d’une requête de catalogue unique qui en détecte beaucoup en CI. Chaque extrait a été exécuté sur PostgreSQL 17.11, la même version majeure que celle de Relyt One.**

Si vous avez suivi [Premiers pas avec l’authentification gérée de Relyt One](/blog/fr/managed-auth-getting-started), vous utilisez déjà le modèle que nous testons ici : l’application définit `app.user_id` à partir d’un jeton vérifié au début de chaque transaction, et une politique compare le propriétaire de chaque ligne à cette valeur. Ce modèle est solide. Les défaillances ci-dessous ne sont pas des bugs de Postgres. Ce sont les endroits où le modèle cesse discrètement de s’appliquer, et elles échouent toutes de la même façon : aucune erreur, aucune ligne de journal, simplement des lignes qui partent vers quelqu’un qui ne devrait pas les voir.

![Une politique, cinq façons de cesser de s’appliquer : contourner la barrière (un rôle qui contourne RLS, un propriétaire sans FORCE, des vues et des fonctions SECURITY DEFINER) ou laisser passer les mauvaises lignes (un paramètre vide ou périmé, des politiques plus larges ou des tables sans RLS)](/blog/assets/f8bb560720d0d050.svg)

## La mise en place

Tout ce qui suit s’exécute dans une seule session psql en tant que superutilisateur, en changeant d’identité avec `SET ROLE`. Les instructions qui créent ou modifient des objets (tables, politiques, vues) sont censées s’exécuter en tant que `demo_owner` ou superutilisateur ; celles qui agissent comme l’application commencent par `SET ROLE demo_app`. Trois rôles représentent ce qu’un vrai projet contient :

- `demo_owner` possède les tables et exécute les migrations ;
- `demo_app` est le rôle sous lequel l’application se connecte, avec `NOBYPASSRLS` ;
- `demo_admin` possède `BYPASSRLS`. Sur Relyt One, il s’agit de `relytdb_owner`, le rôle propriétaire avec lequel démarre chaque projet.

```sql
CREATE ROLE demo_owner NOLOGIN;
CREATE ROLE demo_app   NOLOGIN;
CREATE ROLE demo_admin NOLOGIN BYPASSRLS;
CREATE SCHEMA demo AUTHORIZATION demo_owner;
GRANT USAGE ON SCHEMA demo TO demo_app;
GRANT USAGE, CREATE ON SCHEMA demo TO demo_admin;
```

Une table, une politique, quatre lignes. La dernière ligne a un propriétaire vide ; vous verrez pourquoi dans la section 4.

```sql
SET ROLE demo_owner;
CREATE TABLE demo.notes (id serial PRIMARY KEY, user_id text NOT NULL, body text NOT NULL);
INSERT INTO demo.notes (user_id, body)
  VALUES ('alice', 'a1'), ('alice', 'a2'), ('bob', 'b1'), ('', 'orphan');

ALTER TABLE demo.notes ENABLE ROW LEVEL SECURITY;
ALTER TABLE demo.notes FORCE ROW LEVEL SECURITY;
CREATE POLICY own ON demo.notes FOR ALL
  USING      (user_id = current_setting('app.user_id', true))
  WITH CHECK (user_id = current_setting('app.user_id', true));
RESET ROLE;

GRANT SELECT, INSERT, UPDATE, DELETE ON demo.notes TO demo_app;
GRANT USAGE, SELECT ON ALL SEQUENCES IN SCHEMA demo TO demo_app;
GRANT SELECT ON demo.notes TO demo_admin;
```

Une requête normale, telle que l’application l’enverrait :

```sql
SET ROLE demo_app;
BEGIN;
SELECT set_config('app.user_id', 'alice', true);  -- true : local à cette transaction
SELECT body FROM demo.notes;                      -- a1, a2
COMMIT;
```

Alice voit ses deux lignes. Maintenant, cassons tout.

## 1. Le rôle contourne entièrement RLS

La documentation de PostgreSQL (rédigée en anglais ; les citations de cet article en sont des traductions) est sans détour : « Les superutilisateurs et les rôles dotés de l’attribut `BYPASSRLS` contournent toujours le système de sécurité au niveau des lignes lorsqu’ils accèdent à une table. » Les politiques ne sont pas consultées du tout.

```sql
SET ROLE demo_app;
BEGIN; SELECT set_config('app.user_id', 'alice', true);
SELECT count(*) FROM demo.notes;                       -- 2   (la politique s’applique)
COMMIT;
SET ROLE demo_admin; SELECT count(*) FROM demo.notes;  -- 4   (BYPASSRLS : toutes les lignes)
RESET ROLE;          SELECT count(*) FROM demo.notes;  -- 4   (superutilisateur : toutes les lignes)
```

L’attribut appartient au rôle, pas à ses membres. Si `demo_app` est membre de `demo_admin`, il reste filtré, jusqu’à ce qu’un morceau de code exécute `SET ROLE demo_admin` :

```sql
GRANT demo_admin TO demo_app;
SET ROLE demo_app;
BEGIN; SELECT set_config('app.user_id', 'alice', true);
SELECT count(*) FROM demo.notes;                       -- 2   (l’appartenance seule ne contourne pas)
COMMIT;
SET ROLE demo_admin; SELECT count(*) FROM demo.notes;  -- 4
```

**Sur Relyt One**, c’est plus important qu’il n’y paraît. Les utilisateurs de base de données créés depuis la console ont `BYPASSRLS`, tout comme `relytdb_owner`. Nous l’avons vérifié sur une vraie branche : une table avec FORCE et une politique correcte, interrogée en tant que `relytdb_owner`, a renvoyé toutes les lignes. Une application qui se connecte avec l’un de ces rôles n’a aucune sécurité au niveau des lignes. Créez un rôle dédié avec SQL et connectez-vous avec lui ; [le guide de l’authentification gérée](/blog/fr/managed-auth-getting-started) montre comment faire, et nous avons vérifié qu’un tel rôle se connecte aussi bien avec la chaîne de connexion directe qu’avec celle du pool.

**Le test :** exécutez ceci avec le rôle sous lequel votre application se connecte. Le résultat doit être `false`.

```sql
SET ROLE demo_app;
SELECT rolsuper OR rolbypassrls AS bypasses_rls FROM pg_roles WHERE rolname = current_user;  -- f
```

## 2. Le propriétaire sans FORCE

Par défaut, « les propriétaires de table contournent normalement eux aussi la sécurité au niveau des lignes, bien qu’un propriétaire de table puisse choisir d’y être soumis avec `ALTER TABLE ... FORCE ROW LEVEL SECURITY`. » `ENABLE` seul protège tout le monde, sauf le rôle qui possède la table.

```sql
SET ROLE demo_owner;
CREATE TABLE demo.notes2 (id serial PRIMARY KEY, user_id text NOT NULL, body text NOT NULL);
INSERT INTO demo.notes2 (user_id, body) VALUES ('alice', 'x'), ('bob', 'y');
ALTER TABLE demo.notes2 ENABLE ROW LEVEL SECURITY;          -- activée, mais pas forcée
CREATE POLICY own ON demo.notes2 FOR ALL
  USING      (user_id = current_setting('app.user_id', true))
  WITH CHECK (user_id = current_setting('app.user_id', true));

SELECT count(*) FROM demo.notes2;   -- 2  (celles d’alice et de bob : la politique a été ignorée)

ALTER TABLE demo.notes2 FORCE ROW LEVEL SECURITY;
SELECT count(*) FROM demo.notes2;   -- 0  (maintenant le propriétaire est soumis à la politique)
RESET ROLE;
```

Si votre outil de migration et votre application partagent un seul rôle, ce rôle est le propriétaire, et votre application contourne RLS. Utilisez `FORCE` sur chaque table qui contient des données d’utilisateurs, et gardez le propriétaire hors du chemin des requêtes. Sur Relyt One, le propriétaire a `BYPASSRLS` : `FORCE` ne peut donc rien pour lui ; le correctif, là-bas, est celui de la section 1.

## 3. Du code qui s’exécute sous l’identité de quelqu’un d’autre

Une vue s’exécute avec les permissions de son propriétaire. La documentation de `CREATE VIEW` : « Par défaut, l’accès aux relations de base sous-jacentes référencées dans la vue est déterminé par les permissions du propriétaire de la vue », et il en va de même pour les politiques de sécurité au niveau des lignes. Une fonction `SECURITY DEFINER` fait la même chose avec l’identité du propriétaire de la fonction. Les deux sont utiles, et les deux sont exactement ce que RLS devait empêcher lorsque le propriétaire la contourne.

Reprenons `notes2`, remettons-la à « activée, mais pas forcée », ajoutons une vue et une fonction definer, toutes deux possédées par `demo_owner`, et demandons les lignes d’alice :

```sql
ALTER TABLE demo.notes2 NO FORCE ROW LEVEL SECURITY;
GRANT SELECT ON demo.notes2 TO demo_app, demo_admin;

SET ROLE demo_owner;
CREATE VIEW demo.v_notes2 AS SELECT * FROM demo.notes2;
CREATE FUNCTION demo.count_notes2() RETURNS bigint
  LANGUAGE sql SECURITY DEFINER SET search_path = demo, pg_temp
  AS $$ SELECT count(*) FROM demo.notes2 $$;
RESET ROLE;
GRANT SELECT ON demo.v_notes2 TO demo_app;
GRANT EXECUTE ON FUNCTION demo.count_notes2() TO demo_app;

SET ROLE demo_app;
BEGIN;
SELECT set_config('app.user_id', 'alice', true);
SELECT count(*) FROM demo.notes2;     -- 1
SELECT count(*) FROM demo.v_notes2;   -- 2   fuite
SELECT demo.count_notes2();           -- 2   fuite
COMMIT;
```

Ajouter `FORCE` à la table referme les deux brèches, car le propriétaire est désormais lui aussi soumis à la politique. Mais si le propriétaire lui-même contourne RLS, `FORCE` ne sert à rien. Créez une vue en tant que `demo_admin` (notre substitut de `relytdb_owner`) et elle fuit sur une table forcée :

| Où s’exécute la requête d’alice                                         | Lignes qu’elle voit |
| ----------------------------------------------------------------------- | ------------------- |
| Directement sur la table                                                | 1                   |
| Via une vue possédée par le propriétaire de la table (table non forcée) | 2                   |
| Via une fonction `SECURITY DEFINER` (table non forcée)                  | 2                   |
| Même vue et même fonction après `FORCE`                                 | 1                   |
| Via une vue possédée par un rôle `BYPASSRLS`, table forcée              | 2                   |
| Même vue avec `security_invoker = true`                                 | 1                   |

Pour voir les deux dernières lignes, créez une vue en tant que `demo_admin` et forcez la table :

```sql
RESET ROLE;
ALTER TABLE demo.notes2 FORCE ROW LEVEL SECURITY;
SET ROLE demo_admin;
CREATE VIEW demo.v_admin AS SELECT * FROM demo.notes2;     -- possédée par un rôle BYPASSRLS
RESET ROLE;
GRANT SELECT ON demo.v_admin TO demo_app;

SET ROLE demo_app;
BEGIN; SELECT set_config('app.user_id', 'alice', true);
SELECT count(*) FROM demo.v_admin;    -- 2   fuite, bien que la table soit forcée
COMMIT;
```

Le vrai correctif est `security_invoker`, ajouté dans PostgreSQL 15 (« Permettre que les accès aux tables effectués par une vue soient, en option, contrôlés par les privilèges de l’appelant de la vue »). Avec cette option, ce sont les politiques de l’utilisateur qui exécute la requête qui s’appliquent :

```sql
CREATE VIEW demo.v_safe WITH (security_invoker = true) AS SELECT * FROM demo.notes2;
ALTER VIEW demo.v_admin SET (security_invoker = true);   -- corrige une vue existante ; alice voit maintenant 1 ligne
```

Pour les fonctions `SECURITY DEFINER` sur des tables contenant des données d’utilisateurs, mieux vaut ne pas en avoir. Si vous ne pouvez pas faire autrement, passez l’utilisateur en argument et filtrez explicitement par lui à l’intérieur, et fixez toujours `search_path`, comme dans l’exemple ci-dessus.

> **Sur Relyt One, les vues ont besoin de security_invoker**
>
> `relytdb_owner` est le rôle propriétaire avec lequel démarre chaque projet, et il possède `BYPASSRLS`. Une vue que vous créez avec ce rôle lui appartient : d’après le comportement montré plus haut, elle fait donc fuiter toutes les lignes des tables qu’elle lit, sauf si elle a `security_invoker = true`, même lorsque les tables ont `FORCE`. (Nous l’avons montré avec le substitut `demo_admin` plutôt que sur une branche réelle.)

## 4. Le paramètre qui n’est pas ce que vous croyez

Le modèle repose sur `current_setting('app.user_id', true)`. Le second argument signifie « renvoyer NULL au lieu de lever une erreur si le paramètre n’existe pas », et c’est ce que renvoie une session neuve. Ensuite, une transaction définit la valeur avec `set_config(…, true)` puis se termine. Que voit la requête suivante ?

Commencez par une session neuve, car les requêtes ci-dessus ont déjà laissé le paramètre défini :

```sql
-- psql : reconnectez-vous pour obtenir une session neuve
\c
SET ROLE demo_app;
SELECT coalesce(current_setting('app.user_id', true), '<NULL>');       -- <NULL>

BEGIN; SELECT set_config('app.user_id', 'alice', true); COMMIT;

SELECT '[' || coalesce(current_setting('app.user_id', true), '<NULL>') || ']';  -- []
SELECT user_id || '/' || body FROM demo.notes;                         -- /orphan
```

Pas NULL : une chaîne vide. Nous l’avons constaté sur PostgreSQL 15, 16 et 17 : le paramètre reste défini après la transaction, avec une valeur vide. Ce comportement n’est pas documenté ; vérifiez-le donc sur votre propre version. Une politique qui compare `user_id = ''` correspond à toute ligne dont le propriétaire est la chaîne vide, et notre table en contient une. Ce genre de lignes vient de bugs et de migrations : un `sub` vide qui a échappé à la validation, une valeur par défaut que personne n’avait voulue. Ce sont les lignes que vous voulez le moins exposer.

L’autre moitié de cette section est l’erreur sur le troisième argument. Avec `false`, le paramètre dure toute la session, et avec un pool de connexions, la requête suivante sur cette connexion en hérite :

```sql
SET ROLE demo_app;
SELECT set_config('app.user_id', 'alice', false);   -- valable pour toute la session
SELECT count(*) FROM demo.notes;                    -- 2, pour quiconque utilisera ensuite cette connexion
```

**Le correctif** consiste à faire refuser à la politique un paramètre vide, et à empêcher l’existence de propriétaires vides :

```sql
RESET ROLE;
ALTER POLICY own ON demo.notes
  USING      (nullif(current_setting('app.user_id', true), '') IS NOT NULL
              AND user_id = current_setting('app.user_id', true))
  WITH CHECK (nullif(current_setting('app.user_id', true), '') IS NOT NULL
              AND user_id = current_setting('app.user_id', true));

ALTER TABLE demo.notes ADD CONSTRAINT user_id_not_empty CHECK (user_id <> '') NOT VALID;
```

Vérifiez-le depuis une session neuve :

```sql
-- psql : reconnectez-vous pour obtenir une session neuve
\c
SET ROLE demo_app;
BEGIN; SELECT set_config('app.user_id', 'alice', true); COMMIT;
SELECT count(*) FROM demo.notes;                         -- 0   (le paramètre vide ne correspond plus à rien)
BEGIN; SELECT set_config('app.user_id', 'alice', true);
SELECT count(*) FROM demo.notes;                         -- 2   (la requête normale d’alice fonctionne toujours)
COMMIT;
```

(`NOT VALID` permet d’ajouter la contrainte alors qu’une ancienne ligne vide existe encore ; nettoyez la ligne, puis lancez `VALIDATE CONSTRAINT`.) Gardez `set_config(…, true)` comme seule forme que votre fonction d’accès aux données appelle jamais.

## 5. Une politique plus large, ou aucune politique

**Les politiques s’additionnent.** « Lorsque plusieurs politiques s’appliquent à une requête donnée, elles sont combinées avec `OR` (pour les politiques permissives, qui sont la valeur par défaut) ou avec `AND` (pour les politiques restrictives). » Une seule politique permissive supplémentaire, ajoutée pour un rapport ou un outil de support, élargit l’accès pour tout le monde :

```sql
RESET ROLE;
CREATE POLICY pub ON demo.notes FOR SELECT USING (true);

SET ROLE demo_app;
BEGIN; SELECT set_config('app.user_id', 'alice', true);
SELECT count(*) FROM demo.notes;    -- 4, alice voit tout
COMMIT;
```

Une politique `RESTRICTIVE` est combinée avec `AND` : elle maintient donc un plancher sous les politiques permissives ajoutées plus tard :

```sql
RESET ROLE;
CREATE POLICY tenant_guard ON demo.notes AS RESTRICTIVE FOR SELECT
  USING (nullif(current_setting('app.user_id', true), '') IS NOT NULL
         AND user_id = current_setting('app.user_id', true));

SET ROLE demo_app;
BEGIN; SELECT set_config('app.user_id', 'alice', true);
SELECT count(*) FROM demo.notes;    -- de nouveau 2, même avec `pub` en place
COMMIT;
```

**Les nouvelles tables démarrent sans RLS.** Si vous avez aussi accordé des privilèges par défaut au rôle de l’application, la première table que vous créez et oubliez est lisible par tout le monde :

```sql
RESET ROLE;
ALTER DEFAULT PRIVILEGES FOR ROLE demo_owner IN SCHEMA demo GRANT SELECT ON TABLES TO demo_app;

SET ROLE demo_owner;
CREATE TABLE demo.notes_new (user_id text NOT NULL, body text NOT NULL);   -- personne n’exécute ENABLE ROW LEVEL SECURITY
INSERT INTO demo.notes_new VALUES ('alice', 'n1'), ('bob', 'n2');
RESET ROLE;

SET ROLE demo_app;
BEGIN; SELECT set_config('app.user_id', 'alice', true);
SELECT count(*) FROM demo.notes_new;    -- 2, alice voit aussi la ligne de bob
COMMIT;
```

L’inverse est sans danger : une table avec RLS activée et sans aucune politique est en « refus par défaut » : aucune ligne n’est visible ni modifiable.

## Deux choses qui ne sont pas des failles

- **Une politique avec seulement `USING`.** Pour les politiques `ALL` et `UPDATE`, « si aucune expression `WITH CHECK` n’est définie, l’expression `USING` sera utilisée » aussi pour les nouvelles lignes. Les politiques `INSERT` ne peuvent pas avoir de clause `USING` du tout. Sur une vraie branche Relyt One, notre test d’un utilisateur qui écrit une ligne appartenant à quelqu’un d’autre a été rejeté par la politique.
- **Une commande sans politique.** Lorsque RLS est activée et qu’aucune politique ne s’applique, la documentation indique qu’une politique de « refus par défaut » est supposée. Une table qui n’a qu’une politique `FOR SELECT` rejette les insertions.

## Deux choses que RLS ne couvre jamais

**Les contraintes d’unicité.** « Les vérifications d’intégrité référentielle, comme les contraintes d’unicité ou de clé primaire et les références de clé étrangère, contournent toujours la sécurité au niveau des lignes afin de préserver l’intégrité des données. » La documentation appelle la conséquence un « canal caché » (covert channel). Bob ne peut pas lire le slug d’alice, mais il peut apprendre qu’il existe :

```sql
SET ROLE demo_owner;
CREATE TABLE demo.slugs (id serial PRIMARY KEY, user_id text NOT NULL, slug text NOT NULL UNIQUE);
INSERT INTO demo.slugs (user_id, slug) VALUES ('alice', 'secret-project');
ALTER TABLE demo.slugs ENABLE ROW LEVEL SECURITY;
ALTER TABLE demo.slugs FORCE ROW LEVEL SECURITY;
CREATE POLICY own ON demo.slugs FOR ALL
  USING      (user_id = current_setting('app.user_id', true))
  WITH CHECK (user_id = current_setting('app.user_id', true));
RESET ROLE;
GRANT SELECT, INSERT ON demo.slugs TO demo_app;
GRANT USAGE, SELECT ON ALL SEQUENCES IN SCHEMA demo TO demo_app;

SET ROLE demo_app;
BEGIN; SELECT set_config('app.user_id', 'bob', true);
SELECT count(*) FROM demo.slugs WHERE slug = 'secret-project';              -- 0
INSERT INTO demo.slugs (user_id, slug) VALUES ('bob', 'secret-project');
-- ERROR:  duplicate key value violates unique constraint "slugs_slug_key"
ROLLBACK;
```

Rendez l’unicité propre à chaque tenant et la fuite disparaît : l’insertion de bob réussit. Si une valeur doit être unique pour tous les utilisateurs, acceptez que son existence soit publique.

```sql
RESET ROLE;
ALTER TABLE demo.slugs DROP CONSTRAINT slugs_slug_key, ADD UNIQUE (user_id, slug);
```

**Les vues matérialisées.** La documentation n’en parle pas ; testez donc les vôtres. Une vue matérialisée stocke les lignes telles qu’elles étaient lorsque quelqu’un l’a rafraîchie, et la lecture des lignes stockées n’exécute pas les politiques de la table. Dans notre test, une vue construite pendant que le paramètre d’alice était actif a montré les deux lignes d’alice à bob :

```sql
SET ROLE demo_owner;
BEGIN; SELECT set_config('app.user_id', 'alice', true);
CREATE MATERIALIZED VIEW demo.m_notes AS SELECT * FROM demo.notes;   -- stocke les lignes d’alice
COMMIT;
RESET ROLE;
GRANT SELECT ON demo.m_notes TO demo_app;

SET ROLE demo_app;
BEGIN; SELECT set_config('app.user_id', 'bob', true);
SELECT count(*) FROM demo.m_notes;    -- 2   bob lit les lignes d’alice
COMMIT;
```

N’accordez pas aux rôles de l’application l’accès aux vues matérialisées portant sur des données d’utilisateurs. Mettez la colonne de tenant dans la vue et filtrez par elle dans la requête, ou lisez la table de base.

Encore une remarque tirée de la documentation : une expression de politique est évaluée pour chaque ligne « avant toute condition ou fonction provenant de la requête de l’utilisateur », à une exception près : « les fonctions `leakproof`, dont il est garanti qu’elles ne divulguent aucune information ; l’optimiseur peut choisir d’appliquer ces fonctions avant la vérification de sécurité des lignes. » Considérez un marquage `LEAKPROOF` comme une promesse que vous devez être capable de tenir.

## Un auto-audit à lancer en CI

L’essentiel de la liste ci-dessus laisse des empreintes dans le catalogue. Cette requête renvoie une ligne par constat. Elle examine le schéma `public` (changez le nom pour les autres). Exécutez-la avec le rôle sous lequel votre application se connecte, et faites échouer le build si elle renvoie quoi que ce soit :

```sql
-- rls_audit.sql : ce qui peut laisser les lignes d’un utilisateur atteindre un autre (schéma "public")
WITH tbl AS (
  SELECT c.oid, c.relname, c.relrowsecurity, c.relforcerowsecurity
  FROM pg_class c JOIN pg_namespace n ON n.oid = c.relnamespace
  WHERE n.nspname = 'public' AND c.relkind IN ('r', 'p')
)
SELECT 'connected role bypasses RLS' AS problem, current_user::text AS object
  FROM pg_roles WHERE rolname = current_user AND (rolsuper OR rolbypassrls)
UNION ALL
SELECT 'table without RLS', relname FROM tbl WHERE NOT relrowsecurity
UNION ALL
SELECT 'RLS enabled but not forced', relname FROM tbl WHERE relrowsecurity AND NOT relforcerowsecurity
UNION ALL
SELECT 'view without security_invoker', c.relname
  FROM pg_class c JOIN pg_namespace n ON n.oid = c.relnamespace
  WHERE n.nspname = 'public' AND c.relkind = 'v'
    AND NOT EXISTS (SELECT 1 FROM unnest(coalesce(c.reloptions, '{}')) o
                    WHERE o ~* '^security_invoker=(true|t|yes|y|on|1)$')
UNION ALL
SELECT 'materialized view or foreign table (RLS does not apply)', c.relname
  FROM pg_class c JOIN pg_namespace n ON n.oid = c.relnamespace
  WHERE n.nspname = 'public' AND c.relkind IN ('m', 'f')
UNION ALL
SELECT 'SECURITY DEFINER function', p.proname
  FROM pg_proc p JOIN pg_namespace n ON n.oid = p.pronamespace
  WHERE n.nspname = 'public' AND p.prosecdef
UNION ALL
SELECT 'permissive policy that allows every row', p.polname
  FROM pg_policy p
  WHERE p.polrelid IN (SELECT oid FROM tbl) AND p.polpermissive AND pg_get_expr(p.polqual, p.polrelid) = 'true'
ORDER BY 1, 2;
```

```bash
set -euo pipefail
out=$(psql "$DATABASE_URL" -X -A -t -v ON_ERROR_STOP=1 -f rls_audit.sql)   # une erreur de connexion ou de SQL fait échouer le build
if [ -n "$out" ]; then echo "$out"; exit 1; fi
```

Nous l’avons exécutée sur un schéma construit pour contenir tous les problèmes ci-dessus. Elle les a tous signalés (le rôle, la nouvelle table sans RLS, la table non forcée, la vue simple, la fonction definer, la vue matérialisée, la table distante et la politique qui autorise tout), a ignoré les vues dont `security_invoker` est activé sous n’importe laquelle de ses écritures, et n’a rien renvoyé une fois le schéma corrigé. Elle ne peut pas voir si le paramètre est vide, ni si des politiques se combinent en quelque chose de plus large que prévu (elle ne détecte qu’une politique littéralement égale à `true`). Cela demande des tests qui exécutent des requêtes en tant qu’utilisateur et comptent les lignes, comme ceux de l’[exemple TODO](https://github.com/relytcloud/relyt-auth-examples/tree/main/todo). Les tables qui ne contiennent légitimement aucune donnée d’utilisateur, comme les tables de référence, apparaîtront sous « table without RLS » ; ajoutez-leur une liste d’exceptions.

## En bref

- [ ] L’application se connecte avec un rôle `NOBYPASSRLS` et non superutilisateur, et la première vérification de l’audit est vide. Sur Relyt One, ce n’est jamais `relytdb_owner` ni un utilisateur créé depuis la console.
- [ ] Chaque table contenant des données d’utilisateurs a `ENABLE` et `FORCE ROW LEVEL SECURITY`, et le propriétaire reste hors du chemin des requêtes.
- [ ] Les vues sur ces tables utilisent `security_invoker = true` ; les fonctions `SECURITY DEFINER` exposées aux utilisateurs sont évitées ou filtrent explicitement.
- [ ] Les politiques refusent un paramètre vide, et un `CHECK (user_id <> '')` les complète. `set_config` n’est jamais appelée qu’avec `true`.
- [ ] Une politique `RESTRICTIVE` se place sous les politiques permissives, et l’audit tourne en CI afin qu’une nouvelle table ne puisse pas être livrée sans RLS.
- [ ] Les contraintes d’unicité sur les données d’utilisateurs incluent la colonne de tenant, et les rôles de l’application n’ont pas accès aux vues matérialisées portant sur des données d’utilisateurs.

La sécurité au niveau des lignes mérite d’être testée comme n’importe quel code de contrôle d’accès : connectez-vous avec chaque type d’utilisateur, lancez chaque type de requête, comptez les lignes. Si vous construisez sur l’authentification gérée, l’[article de préversion](/blog/fr/managed-auth-preview) explique comment l’identifiant de l’utilisateur parvient jusqu’à la base de données, et l’[exemple TODO](https://github.com/relytcloud/relyt-auth-examples/tree/main/todo) contient les tests.

[Créer un projet gratuit](/auth/signin?ref=blog-rls-fails-open)
