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 :
| Couche | Ce qu’elle décide | Refus 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
permission denied for tabledésigne un GRANT manquant, pas une policy. En lecture, une policy RLS qui refuse renvoie zéro ligne sans erreur.ALTER DEFAULT PRIVILEGESne vaut que pour les objets créés par le rôle visé. Changer de rôle pour jouer les migrations suffit à livrer des tables sans droits.- Les droits de table doivent vivre dans les migrations. Une base reconstruite depuis le dépôt doit avoir exactement les mêmes GRANT que la production.
- Un harnais de tests qui compense un manque le rend invisible. Default privileges ou stub de table posés par le bootstrap : les tests passent là où l’environnement réel échoue.
- Un GRANT large exige une RLS partout. Un test qui liste les tables lisibles sans RLS transforme cette règle en garde automatique.