---
title: "Come testare la Supabase RLS: 30 permessi su 87 inutili"
description: "Testare la Supabase RLS policy per policy non basta. Una prova ruolo per ruolo su una copia di produzione ha trovato 30 permessi su 87 che non facevano nulla."
url: "https://madsalt.tech/it/blog/how-to-test-supabase-rls/"
author: "Denys Zaichenko"
language: "it"
published: 2026-10-14
---

# Come testare la Supabase RLS: 30 permessi su 87 inutili

In breve

Testi la row level security (RLS, la sicurezza a livello di riga) di Supabase come la incontra un utente: entri come ogni ruolo, esegua l'azione reale con un permesso disattivato e poi attivato, e verifichi che il risultato cambi. Su un'app in produzione con cinque ruoli modificabili, questo ha rivelato che 30 delle 87 caselle di permesso che un amministratore poteva salvare non cambiavano nulla nel database. Un test policy per policy non può vederlo, perché verifica la policy che ha scritto, non la casella sullo schermo.

Sono Denys, ingegnere full-stack e di IA. Quest'anno ho realizzato su Supabase un gestionale di progetti e ore per una società di revisione, dove ogni regola di accesso vive nel database. Qui sotto trova il metodo che uso per dimostrare quelle regole, cosa ha trovato e quanto costa eseguirlo.

## Perché i test delle policy non bastano per testare la Supabase RLS?

Un test di policy risponde a "questa policy fa ciò che ho scritto?". I suoi utenti si fanno un'altra domanda: "se tolgo la spunta a questa casella per questo ruolo, possono ancora farlo?". Su un'app piccola le due domande hanno la stessa risposta. Su un'app con una pagina di impostazioni in cui gli amministratori modificano una matrice dei permessi, le risposte si allontanano.

L'app aveva 41 tabelle con RLS attiva e 402 istruzioni `CREATE POLICY` distribuite in 255 migrazioni. La schermata di amministrazione mostrava una matrice di aree (progetti, attività, fogli ore, file e così via) per azioni (vedere, creare, modificare, archiviare), con una colonna per ruolo. Ogni casella salvata era una promessa all'amministratore. Nulla verificava che il database la mantenesse.

Supabase documenta bene gli strumenti standard: i [test pgTAP](https://supabase.com/docs/guides/database/extensions/pgtap) e i [test helper](https://supabase.com/docs/guides/local-development/testing/pgtap-extended) permettono di creare utenti, passare a quegli utenti e verificare cosa possono leggere. Li usi. Il metodo qui sotto si appoggia su di essi: parte dalla matrice che vede l'amministratore, non dall'elenco delle policy.

## Come funziona la prova sulla matrice?

Ogni casella della matrice diventa una sonda. Una sonda entra come membro del ruolo, esegue un'azione reale e registra l'esito. Lo fa due volte: una con la casella disattivata e una con la casella attivata, a parità di tutto il resto. Se l'esito cambia, la casella è applicata. Se non cambia, la casella non fa nulla, per quanto convincente appaia sullo schermo.

1. **Una casella della matrice**. Esempio: Standard Member · Attività · Creare
2. **Casella disattivata: agire come membro del ruolo**. Creare un'attività con lo stesso percorso che usa l'app. Registrare l'esito.
3. **Casella attivata: ripetere la stessa azione**. Stesso utente, stessi dati. È cambiata solo la casella.
4. **Confrontare i due esiti**. Il verdetto dipende solo da ciò che ha fatto il database.
- **Esito cambiato**. Applicata. Rifiutata se disattivata, consentita se attivata.
- **Uguale in entrambi i casi**. Non fa nulla. Lo schermo promette un controllo che non esiste.
- **Schermo e database non concordano**. Un bug. La pagina mostra un pulsante che il database rifiuta, o il contrario.

*Figura 1. Ogni verdetto viene da una coppia di esiti reali, mai dalla lettura del codice.*

"Esito" qui significa ciò che fa chi consuma il permesso: una policy restituisce righe o rifiuta una scrittura, una funzione del database solleva un'eccezione, un trigger blocca, oppure una edge function risponde 403. Una casella può proteggere più di uno di questi, per questo le 87 caselle hanno richiesto 279 controlli per ruolo. Ogni controllo è finito con uno di questi verdetti:

| Verdetto                      | Controlli per ruolo | Cosa significa                                                     |
| ----------------------------- | ------------------- | ------------------------------------------------------------------ |
| Effetto                       | 227                 | L'esito si è invertito con la casella                              |
| Morta                         | 30                  | L'esito è rimasto uguale con la casella disattivata e attivata     |
| Irraggiungibile               | 19                  | L'azione non è raggiungibile per questo ruolo, dimostrato a parte  |
| Edge function                 | 1                   | Applicata da una edge function, verificata via HTTP                |
| Solo schermo                  | 1                   | Cambia solo ciò che mostra la pagina, coperta da un test front-end |
| Schermo e database concordano | 1                   | Ciò che la pagina offre corrisponde a ciò che il database consente |
| Fallito                       | 0                   | Un controllo il cui esito non corrispondeva all'atteso             |

## Cosa ha trovato la prova?

L'esecuzione del 15 settembre 2026 ha coperto tutti e cinque i ruoli che un amministratore può modificare (Admin, Senior, Standard, Restricted e un ruolo personalizzato clonato). I risultati sono stati identici per tutti i ruoli.

![87 caselle di permesso che un amministratore poteva salvare. Per ciò che applicava davvero ogni casella. Uguale per tutti e cinque i ruoli. Applicate dal database: 55; Applicate altrove: 1 edge function, 1 schermo: 2; Non cambiavano nulla: 30.](https://madsalt.tech/charts/it/how-to-test-supabase-rls.svg)

*Figura 2. Le 87 caselle di permesso che un amministratore poteva salvare, per ciò che le applicava davvero. Stesso risultato per ognuno dei cinque ruoli.*

Nessuna delle 30 faceva entrare qualcuno. Per 29 di esse nessuna policy, funzione o trigger leggeva mai la casella; l'ultima ha smesso di contare dopo la riscrittura di una policy. Resta comunque un problema reale: un amministratore che toglie la spunta a "Eliminare" per un ruolo crede che quel ruolo non possa eliminare. Le abbiamo nascoste tutte e 30 invece di collegarle, perché ognuna avrebbe richiesto una decisione di prodotto, e una casella che non fa nulla non deve restare sullo schermo mentre la si prende.

Lo stesso lavoro ha fatto emergere altri quattro difetti:

| Cosa non andava                                                                                                            | Come si è visto                                                            | Correzione                                                                         |
| -------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------- | ---------------------------------------------------------------------------------- |
| 30 caselle non facevano nulla                                                                                              | Verdetto "morta", esito identico con casella disattivata e attivata        | Nascoste dalla matrice                                                             |
| Tre funzioni del database lasciavano passare un'azione quando un'attività non aveva un assegnatario                        | Una sonda che doveva essere rifiutata è stata consentita                   | Ora le funzioni rifiutano quando il valore manca                                   |
| Riportare un foglio ore in bozza dipendeva dalla casella sbagliata                                                         | Schermo e database non concordavano per un ruolo                           | Legato al permesso di riapertura a cui appartiene                                  |
| Un membro vedeva i nuovi permessi solo dopo un ricaricamento                                                               | Il database era cambiato, la pagina no                                     | I permessi si aggiornano ogni 60 secondi e quando la finestra torna in primo piano |
| Il valore predefinito di una colonna violava il proprio check constraint, quindi gli insert senza quella colonna fallivano | Trovato dalla revisione di sicurezza della correzione, nello stesso lavoro | Predefinito cambiato in un valore che il constraint consente                       |

Il bug dell'assegnatario mancante era quello che contava. Le protezioni erano scritte così in PL/pgSQL:

```sql
if not (has_permission or assignee_id = auth.uid()) then
  raise exception 'forbidden';
end if;
```

Quando `assignee_id` è null, `assignee_id = auth.uid()` è null, `false or null` è null, `not null` è ancora null, e `if` tratta null come false. L'eccezione non scatta mai. In produzione nessuna attività aveva un assegnatario, quindi non era un caso limite: valeva per ogni attività coinvolta. La correzione avvolge la condizione in `coalesce(…, false)`, così un valore mancante porta a un rifiuto, ed è stata rilasciata per prima, da sola, prima del resto del lotto.

## Come si esegue su una copia di produzione?

Una prova su uno schema diverso da quello di produzione non dimostra nulla sulla produzione. L'harness gira sullo stack Supabase locale, in un database separato che è una copia dello schema di produzione più le migrazioni in revisione. Prima di ogni esecuzione confronta un'impronta dei due database locali: 799 righe che descrivono tabelle, policy, funzioni e grant. Qualsiasi differenza ferma l'esecuzione.

1. **Schema di produzione**. Copiato su una sola macchina fidata. Non va mai su un runner CI in hosting.
2. **Stack locale: database dell'app**. Servito dall'API e dall'auth locali, usato dai controlli end-to-end.
3. **Stack locale: database isolato per la prova**. Stesso schema, usato solo dalla prova sulla matrice.
4. **Controllo dell'impronta**. 799 righe per database. Qualsiasi differenza ferma l'esecuzione prima di una sola sonda.
5. **Sonde eseguite come ogni ruolo**. Come membri reali autenticati, con gli stessi percorsi che usa l'app.
6. **Nessuna traccia lasciata**. Il numero di righe alla fine deve essere uguale a quello all'inizio.

*Figura 3. La prova conta solo quando il database su cui è girata corrisponde alla produzione.*

Due dettagli hanno reso l'harness affidabile:

- **Prima lo rompa di proposito.** Prima di fidarmi di un'esecuzione verde, ho sostituito una policy di insert con una che accetta tutto e ho rieseguito la prova. Doveva diventare rossa sulle caselle protette da quella policy. Un harness che resta verde con una policy rotta è solo decorazione.
- **Verifichi anche lo stato salvato.** Il primo passaggio parte per ogni ruolo da una base pulita. Un secondo passaggio parte dalla matrice salvata attuale di ogni ruolo, perché una casella può comportarsi diversamente accanto alle caselle che un amministratore ha davvero spuntato.

## Come restare verdi dopo il rilascio?

Una prova eseguita una sola volta invecchia con la migrazione successiva. Ora la prova gira prima di ogni push che tocca le migrazioni, il codice di autenticazione o la matrice stessa.

1. **git push**. La modifica tocca migrazioni, autenticazione o la matrice.
2. **Hook pre-push: prova sulla matrice**. Gira in locale sul database della prova. Il rosso blocca il push.
3. **Il push arriva su main**. Il deploy procede come sempre.
4. **Esecuzione CI self-hosted**. La stessa prova di nuovo, sulla macchina che conserva la copia dello schema di produzione.

*Figura 4. Il blocco è locale, perché la copia dello schema di produzione resta su una sola macchina.*

La prova è composta da 135 test in 11 file, accanto a circa 3.100 normali unit test nello stesso progetto.

## Cosa non ha funzionato?

- **La CI in hosting non poteva eseguirla.** La prova richiede la copia dello schema di produzione, e non la volevo su un runner in hosting. Per questo il blocco è un hook pre-push, e la CI su una macchina self-hosted è un secondo controllo che il deploy non aspetta.
- **I conteggi fissi di righe si sono rotti.** Il primo controllo "nessuna traccia" confrontava con numeri fissi. La migrazione legittima successiva ha aggiunto una riga di permesso e l'ha fatto diventare rosso. Ora confronta i conteggi alla fine con quelli all'inizio.
- **Saltato non vuol dire superato.** I controlli end-to-end richiedono l'edge runtime locale. Quando non è attivo vengono saltati, ed è facile leggere un salto come verde.
- **È lenta su un portatile carico.** I passaggi per ruolo hanno richiesto timeout più lunghi su una macchina sotto carico.
- **Dimostra ciò che descrivono le sonde.** Una casella per cui nessuno ha scritto una sonda viene segnalata come lacuna di copertura, non come sicura.

## Come ripetere tutto questo sulla sua app Supabase

1. Elenchi ogni permesso che lo schermo permette di salvare: ruolo × area × azione. Quell'elenco è la specifica, non le sue policy.
2. Per ogni casella, annoti l'azione reale che dovrebbe controllare e come si manifesta un rifiuto (nessuna riga, un errore, un 403).
3. Costruisca un database identico alla produzione e ne calcoli l'impronta, così una deriva dello schema ferma l'esecuzione.
4. Esegua ogni azione come membro autenticato, casella disattivata e poi attivata, e confronti.
5. Rompa di proposito una policy e verifichi che la prova diventi rossa.
6. Nasconda o colleghi ogni casella che non fa nulla, poi faccia della prova un blocco prima del push.

Se la sua app ha già la RLS ma non è sicuro che faccia ciò che dice la schermata delle impostazioni, lo stesso metodo funziona come audit di un codice esistente, il primo passo di un [recupero di progetto](https://madsalt.tech/it/services/project-rescue/). Ho scritto del progetto dietro questi numeri nel [caso del time tracking per una società di revisione](https://madsalt.tech/it/work/audit-firm-time-tracking/).

Non è sicuro che la sua RLS corrisponda alla schermata delle impostazioni?

Mi confronto volentieri su come la testa. Nessuna proposta commerciale.

[Mi contatti](https://madsalt.tech/it/#contact)
