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.

Zhan Lu15 min de lecture
Sur cette page

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, 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)

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.
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.

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 :

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.

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 :

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 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.

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.

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 :

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’aliceLignes qu’elle voit
Directement sur la table1
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 FORCE1
Via une vue possédée par un rôle BYPASSRLS, table forcée2
Même vue avec security_invoker = true1

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

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 :

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.

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 :

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

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 :

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 :

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

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 :

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 :

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 :

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.

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 :

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 :

-- 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;
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. 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 explique comment l’identifiant de l’utilisateur parvient jusqu’à la base de données, et l’exemple TODO contient les tests.

Créer un projet gratuit

ÉtiquettesPostgresSécuritérls

Rédigé par

Zhan Lu

Votre prochaine base de données n’est qu’à une branche.

Créez un projet gratuit en moins d’une minute. Sans carte bancaire, sans configuration.

Retour au blog