← Scritti

11 luglio 2026 · PostgreSQL · RLS · Supabase · Multi-tenant · Security · Testing

RLS su Postgres: testarla sul serio

Su un'app multi-tenant la Row-Level Security è la sicurezza — e si rompe in silenzio, perché i test della tua app non la vedono. Il metodo: una matrice ruolo × tabella × operazione, un fuzz cross-tenant, e la CI che rompe la build.

Ho passato troppe notti a fissare le stesse policy di Row-Level Security chiedendomi se reggessero davvero. Il problema della RLS non è che è difficile da scrivere. È che si rompe in silenzio: la tua app continua a funzionare, i test passano, e intanto il tenant A può leggere i dati del tenant B. Nessun errore, nessun allarme. Lo scopri quando è troppo tardi, o non lo scopri affatto.

Su un’applicazione multi-tenant la RLS non è un livello di comodità: è la sicurezza. Ed è esattamente il tipo di cosa che va testata come si testa un impianto frenante — non “sembra che tenga”, ma provato a rompere di proposito.

I tre modi in cui si rompe

Quasi tutti i leak che ho visto ricadono in tre categorie, e nessuna delle tre lancia un’eccezione:

  • Una WITH CHECK dimenticata. Metti la USING sulla lettura e ti scordi la WITH CHECK sulla scrittura: un tenant può inserire o aggiornare righe intestandole a un altro. La SELECT è blindata, la INSERT è aperta.
  • Un FOR ALL dove intendevi FOR SELECT. Una policy pensata per la lettura che copre anche INSERT/UPDATE/DELETE. La svista di una parola, e la superficie d’attacco raddoppia.
  • Un service_role che sfugge lato client. La chiave che bypassa la RLS by design finisce in un contesto raggiungibile dal browser. A quel punto le policy non contano più niente: stai girando come super-tenant.

Nessuno di questi è un bug “rumoroso”. Sono tutti configurazioni che funzionano — fanno esattamente quello che hai scritto, solo che non è quello che intendevi.

Perché i tuoi test non lo prendono

Qui sta il punto dolente, ed è controintuitivo: i test della tua app testano il tuo codice, non le tue policy. Verificano che l’endpoint restituisca la lista giusta quando l’utente è quello giusto. Non verificano cosa succede quando la richiesta arriva con il ruolo sbagliato, o quando la policy ha un buco che il tuo codice applicativo, per come è scritto, non attraversa mai.

Peggio: spesso la suite di test gira con un ruolo che bypassa la RLS (il proprietario delle tabelle, o un service_role), perché “così i fixture sono più comodi da montare”. Risultato: stai testando un mondo in cui la RLS non esiste, e poi la spedisci in un mondo in cui è l’unica cosa che ti protegge. I due mondi non si parlano.

La RLS vive nel database. Va testata nel database, con i ruoli veri, non sopra, con il ruolo comodo.

Il metodo: una matrice, non un’ispezione a occhio

Guardare le policy una per una e convincersi che tengano non scala e non regge. La superficie vera è combinatoria: ogni ruolo × tabella × operazione. Per ogni cella la domanda è secca — quel ruolo, su quella tabella, per quell’operazione: allow, deny, conditional (passa solo se la riga è sua) o unrestricted (passa tutto, campanello d’allarme)?

Messa in tabella, la superficie diventa leggibile in un colpo d’occhio, e i buchi saltano fuori da soli: la cella che ti aspettavi conditional e invece è unrestricted, la INSERT che risulta allow dove doveva essere deny. È il primo passo che ho automatizzato in rlsgrid: mappare l’intera matrice invece di fidarsi dell’occhio.

Il fuzz cross-tenant: prova a rubare davvero

La matrice ti dice cosa dovrebbero fare le policy. Il passo che ti dice cosa fanno davvero è il fuzzing dell’isolamento, ed è la parte che mi fa dormire.

L’idea è semplice e brutale: semini due tenant sintetici, poi apri una sessione come il primo e provi a leggere e scrivere i dati del secondo. Non ispezioni la policy — la aggredisci. Se dalla sessione del tenant A riesci a toccare una riga del tenant B, non è una policy “forse debole”: è un leak vero, dimostrato, riproducibile. Nessuna interpretazione, un fatto.

È la differenza tra “ho letto le regole e mi sembrano giuste” e “ho provato a passare e non sono passato”.

In CI: la regressione rompe la build, non la produzione

Un check che fai a mano una volta è un check che smetterai di fare. Il valore vero arriva quando l’isolamento diventa parte della pipeline: la matrice e i test di leak si traducono in una suite pgTAP che gira in CI, così una modifica che indebolisce una policy rompe la build invece di finire in produzione.

È lo spostamento che conta: la RLS smette di essere qualcosa che “controlli quando ti ricordi” e diventa un invariante che il sistema difende da solo a ogni push. Una WITH CHECK cancellata per sbaglio in una migration non arriva al deploy — muore rossa nella pipeline.

Sicurezza operativa dello strumento stesso

Un dettaglio che sembra pignoleria e non lo è: uno strumento che semina tenant finti e prova a scrivere dati incrociati non deve poter girare su produzione. Deve rifiutarsi. rlsgrid si rifiuta di partire su URL che sembrano di produzione — lo punti su staging e lì lavora. Chi testa la sicurezza deve essere il primo a non poter fare danni.

E niente magia dove basta SQL: nessun LLM, nessuna euristica probabilistica su una cosa che deve essere deterministica. L’isolamento tra tenant o c’è o non c’è, e la risposta la dà una query, non un modello.

In sintesi

La RLS multi-tenant fallisce in silenzio, e i test applicativi non la vedono perché testano il codice e spesso girano con un ruolo che la bypassa. Il modo per testarla sul serio è tre mosse: mappa la matrice ruolo × tabella × operazione, fuzza l’isolamento seminando due tenant e provando a rubare tra loro, e inchiodala in CI con pgTAP così la regressione rompe la build. Se gestisci Postgres o Supabase multi-tenant, la domanda da cui parto sempre è una: come testi le tue policy oggi — o le stai solo leggendo?