Aller au contenu
Sylvain Joffraud l4w
Retour

Postgres `permission denied for table` : GRANT, pas RLS

permission denied for table dans Postgres n’est pas un refus RLS : une policy qui refuse la lecture renvoie zéro ligne, jamais une erreur. Sur une app de signalements adossée à un Supabase self-hosted (PostgreSQL 15, PostgREST), l’annuaire des contacts d’une résidence restait vide en recette avec cette erreur, qui envoie naturellement le diagnostic vers les policies. La cause était un GRANT jamais accordé : aucune migration du dépôt ne donnait de droits de table, tout reposait sur les ALTER DEFAULT PRIVILEGES de l’instance. Et le harnais de tests posait ses propres default privileges, ce qui rendait le défaut invisible en test.

Le symptôme : une liste vide et une erreur PostgREST

Côté client, le log de l’écran était explicite :

[contacts] list failed  permission denied for table residence_contacts

La table residence_contacts venait d’être créée par une migration, avec RLS activée et ses policies. Les tests RLS passaient. Le réflexe est de relire les policies : c’est la mauvaise couche.

Pourquoi permission denied for table n’est pas un refus RLS ?

PostgreSQL contrôle l’accès en deux couches successives :

CoucheCe qu’elle décideRefus en lecture
Privilèges de table (GRANT)le rôle peut-il toucher la table ?erreur permission denied for table
Row Level Security (policies)quelles lignes le rôle voit-il ?zéro ligne, pas d’erreur

La Row Level Security (RLS) est un filtre par ligne : une policy de lecture ajoute une condition au WHERE, et une ligne refusée disparaît simplement du résultat. Le privilège de table est vérifié avant : sans droit SELECT, la requête est rejetée et les policies ne sont même pas évaluées. Avec Supabase, PostgREST se connecte avec le rôle authenticated (ou anon pour un visiteur), et c’est ce rôle qui doit avoir le GRANT.

Une policy RLS filtre, un GRANT manquant interdit : si Postgres renvoie une erreur sur une lecture, ce n’est pas la RLS.

Le test qui le montre : has_table_privilege('authenticated', 'public.residence_contacts', 'select') renvoyait false.

Les default privileges ne couvrent que le rôle visé

ALTER DEFAULT PRIVILEGES définit les droits accordés automatiquement aux objets créés plus tard par un rôle donné (doc PostgreSQL 15). Ce n’est pas une règle « toute nouvelle table du schéma » : une table créée par un autre rôle ne reçoit rien.

Les migrations de ce dépôt sont jouées à la main avec psql. Aucune n’avait jamais écrit de GRANT : les tables précédentes avaient hérité des default privileges de l’instance, les deux dernières sont passées à côté. Depuis, la doc de déploiement porte cet avertissement : le rôle qui applique les migrations compte. Des migrations appliquées par un autre rôle produisent des tables sans aucun GRANT.

Pourquoi les tests RLS étaient verts

Le harnais de tests (Vitest contre un conteneur postgres:15) crée les rôles Supabase, puis applique toutes les migrations. Son bootstrap contenait ceci :

alter default privileges in schema public
  grant select, insert, update, delete on tables to authenticated;
alter default privileges in schema public
  grant select on tables to anon;

Posé avant les migrations, par le même rôle qui les applique : chaque table créée héritait automatiquement des droits. Le harnais compensait exactement ce que la production n’avait pas. J’ai retiré ces lignes : les droits viennent désormais des migrations, comme en production, et une future table livrée sans GRANT fera échouer ses tests.

Le même schéma s’est reproduit plus tard sous une autre forme : une migration ajoutait une colonne à une table historique qu’aucune migration ne créait, et le bootstrap des tests en posait un stub. Les tests passaient pendant qu’une base neuve s’arrêtait sur relation "public.<table>" does not exist. Un harnais qui compense un manque le masque.

Le correctif : des GRANT dans une migration

La migration accorde les droits sur l’existant, pose des default privileges pour les tables suivantes (pour le rôle qui l’exécute, donc celui qui jouera les migrations suivantes), puis réaffirme chaque REVOKE antérieur pour qu’aucune fermeture ne soit desserrée :

-- 1. Objets existants (GRANT ON ALL TABLES couvre aussi les vues)
grant select, insert, update, delete
  on all tables in schema public to authenticated;
grant usage, select on all sequences in schema public to authenticated;

-- 2. Objets futurs
alter default privileges in schema public
  grant select, insert, update, delete on tables to authenticated;
alter default privileges in schema public
  grant usage, select on sequences to authenticated;

-- 3. Restrictions réaffirmées
revoke insert, update, delete on public.audit_logs from anon, authenticated;
revoke select, insert, update, delete on public.newsletter from anon, authenticated;

Le modèle reste celui de Supabase : droits larges au niveau table, cloisonnement fin par RLS. Ce modèle n’est sûr que si chaque table exposée a sa RLS activée. J’ai donc ajouté un test de garde qui liste les tables lisibles par authenticated sans RLS :

select c.relname
from pg_class c
join pg_namespace n on n.oid = c.relnamespace
where n.nspname = 'public'
  and c.relkind = 'r'
  and not c.relrowsecurity
  and has_table_privilege('authenticated', c.oid, 'select');

Seules les tables de référence publiques (catégories, catalogue d’offres) ont le droit d’y figurer. Effet visible dans les tests existants : pour un visiteur anonyme, select id from residence_contacts ne renvoie plus zéro ligne, il échoue dès le GRANT. La RLS n’a même plus à s’exprimer.

Autre angle de la sécurité d’un Supabase self-hosted, côté accès admin : le Studio Supabase exposé via l’API Kong.

Lessons learned

Questions fréquentes

Une policy RLS peut-elle provoquer l’erreur permission denied for table ?
Non. En lecture, une policy RLS qui refuse filtre les lignes : la requête réussit et renvoie zéro ligne. L’erreur permission denied for table vient de la couche en dessous, les privilèges de table accordés par GRANT. Si le rôle n’a pas le droit SELECT sur la table, PostgreSQL refuse la requête avant même d’évaluer les policies.
Pourquoi ALTER DEFAULT PRIVILEGES ne s’applique-t-il pas à ma nouvelle table ?
ALTER DEFAULT PRIVILEGES ne vise que les objets créés par un rôle donné : celui qui exécute la commande, ou celui nommé par FOR ROLE. Une table créée par un autre rôle, par exemple quand les migrations sont jouées avec un autre utilisateur, ne reçoit aucun de ces droits par défaut. Il faut alors un GRANT explicite, idéalement dans la migration elle-même.
Comment vérifier qu’un rôle Supabase peut lire une table ?
La fonction PostgreSQL has_table_privilege('authenticated', 'public.ma_table', 'select') répond true ou false sans dépendre de la RLS. En la passant sur toutes les tables du schéma public via pg_class, on obtient l’inventaire des droits réels du rôle authenticated, à comparer avec ce que le client est censé lire.
Faut-il accorder GRANT ALL aux rôles anon et authenticated de Supabase ?
Accorder des droits larges à authenticated et cloisonner finement par RLS est un modèle courant avec Supabase, à deux conditions : chaque table exposée doit avoir la RLS activée, et les tables qui doivent rester fermées ou en lecture seule doivent garder leur REVOKE. Un GRANT sans RLS rend la table entière lisible par n’importe quel compte connecté.

Partager cet article :

Article précédent
`set -e` et rsync exit 23 : la rétention ne tournait pas
Article suivant
Pattern Strategy : un moteur de facturation sans `persist()`