La row level security (RLS) è una funzione di PostgreSQL che decide, riga per riga, quali righe un utente può leggere o modificare, e la regola viene applicata dentro il database. In un'app Supabase, la RLS è l'unica cosa che separa le vostre tabelle da qualsiasi visitatore che copi la chiave pubblica dal vostro browser. Se una tabella non ha la RLS, chiunque può leggerla o modificarla tramite l'API pubblica.
Questa guida è per chi costruisce web app con un builder con IA e vuole capire cosa fanno le policy generate, per poterle controllare. Copre i quattro tipi di policy, gli schemi più usati, lo storage e un test con due account che richiede dieci minuti.
Perché la RLS è obbligatoria quando la chiave è nel browser
Una web app Supabase parla con il database direttamente dal browser, quindi l'URL del progetto e la chiave pubblica (anon o publishable) viaggiano dentro il JavaScript che ogni visitatore scarica. È voluto: la chiave identifica il vostro progetto, non lo protegge. La protezione arriva dalle policy su ogni tabella.
La documentazione di Supabase descrive una policy come una clausola WHERE aggiuntiva su ogni query, e precisa che una volta abilitata la RLS su una tabella, nessun dato è accessibile tramite l'API con la chiave pubblica finché non create delle policy. Il pericolo è il contrario: una tabella nello schema public senza RLS è aperta. Chiunque può aprire la console del browser, chiamare la vostra API con la vostra chiave pubblica ed eseguire select, insert, update o delete su di essa.
La chiave segreta (service role) è un'altra cosa. Aggira la RLS, quindi non deve mai comparire nel codice client. I segreti stanno nel codice server, come le edge function. Il controllo degli accessi violato, cioè un utente che raggiunge dati non suoi, è la prima categoria della OWASP Top 10.
Cosa succede senza RLS
Senza RLS l'app funziona comunque nei vostri test, ed è per questo che il problema passa inosservato. Le schermate chiedono solo le vostre righe. All'API non importa cosa chiedono le schermate.
- Un visitatore elenca ogni riga della tabella degli ordini, dei clienti o dei messaggi.
- Un utente connesso cambia il prezzo, il ruolo o il proprietario di una riga che appartiene a qualcun altro.
- Uno script cancella un'intera tabella, senza nemmeno un account.
Nascondere un pulsante nell'interfaccia non protegge nulla, perché l'API accetta qualsiasi richiesta consentita dalla chiave. Se state confrontando i backend, la nostra guida su Supabase vs Firebase spiega come ciascuno gestisce le regole di accesso.
I quattro tipi di policy: select, insert, update, delete
Una policy si applica a una sola operazione, e ogni operazione usa una o due condizioni. USING filtra le righe già esistenti. WITH CHECK convalida le righe che vengono scritte. La documentazione di Supabase raccomanda una policy per operazione.
| Operazione | Condizione usata | Cosa controlla |
|---|---|---|
| SELECT | USING | Quali righe esistenti l'utente può leggere |
| INSERT | WITH CHECK | Se la nuova riga è consentita |
| UPDATE | USING e WITH CHECK | Quali righe si possono modificare e cosa possono diventare. Serve anche una policy SELECT |
| DELETE | USING | Quali righe si possono eliminare |
Ecco un esempio completo per una tabella todos in cui ogni riga ha una colonna user_id. Prima, attivate la RLS:
alter table public.todos enable row level security;Poi una policy per operazione, ciascuna limitata con la clausola TO agli utenti connessi:
create policy "Read own todos"
on public.todos for select
to authenticated
using ( (select auth.uid()) = user_id );
create policy "Create own todos"
on public.todos for insert
to authenticated
with check ( (select auth.uid()) = user_id );
create policy "Update own todos"
on public.todos for update
to authenticated
using ( (select auth.uid()) = user_id )
with check ( (select auth.uid()) = user_id );
create policy "Delete own todos"
on public.todos for delete
to authenticated
using ( (select auth.uid()) = user_id );Due dettagli contano. La clausola TO indica il ruolo: senza di essa, la policy viene valutata anche per le richieste anonime (il ruolo anon). E auth.uid() restituisce l'ID dell'utente connesso, oppure null se nessuno è connesso: in quel caso il confronto fallisce in silenzio e nessuna riga corrisponde. Scrivere la chiamata come (select auth.uid()) permette a Postgres di calcolarla una volta per istruzione invece che una volta per riga, cosa che Supabase raccomanda per la velocità.
Schema 1: righe che appartengono a un solo utente
Lo schema per utente è una colonna user_id riempita con l'ID dell'utente connesso, più le quattro policy qui sopra. Va bene per note, attività, profili, impostazioni e qualsiasi dato privato. Un valore predefinito utile per la colonna è auth.uid(), così il browser non deve inviarlo:
user_id uuid not null default auth.uid()
references auth.users (id) on delete cascadeCreate un indice sulla colonna su cui filtrano le policy, perché la policy viene eseguita a ogni query:
create index todos_user_id_idx on public.todos (user_id);Schema 2: team e organizzazioni
Per i dati condivisi, salvate l'appartenenza in una tabella a parte e lasciate che la policy chieda se l'utente fa parte del team della riga. Basta una tabella members con team_id e user_id:
create policy "Members read team projects"
on public.projects for select
to authenticated
using (
team_id in (
select team_id from public.team_members
where user_id = (select auth.uid())
)
);Nella maggior parte delle app le policy di scrittura sono più severe di quelle di lettura: i membri possono leggere, solo alcuni possono inserire o eliminare. Mettete una colonna role su team_members e controllatela nelle policy di insert, update e delete. Create un indice su team_members per user_id, perché una chiave primaria composta che inizia con team_id non indicizza user_id.
Schema 3: ruoli admin
Una policy per gli admin deve leggere il ruolo da un posto che l'utente non può modificare. La documentazione di Supabase avverte che raw_user_meta_data può essere cambiato dall'utente, quindi non va usato per l'autorizzazione, mentre raw_app_meta_data (app_metadata nel JWT) è affidabile. La via più semplice e più comune è una tabella di admin in cui scrive solo il proprietario del database:
create policy "Admins read all invoices"
on public.invoices for select
to authenticated
using (
exists (
select 1 from public.admins
where user_id = (select auth.uid())
)
);Le policy per la stessa operazione si combinano con OR, quindi questa policy aggiunge l'accesso degli admin a quella per utente. Tenete la tabella admins protetta con una propria RLS e nessuna policy di insert, altrimenti un utente potrebbe nominarsi admin da solo.
Policy di storage per i file caricati
I file in Supabase Storage sono protetti da policy RLS sulla tabella storage.objects, e un bucket non accetta upload finché non aggiungete una policy. Uno schema comune salva i file di ogni utente in una cartella con il nome del suo ID utente, e la policy controlla la prima cartella del percorso:
create policy "Upload to own folder"
on storage.objects for insert
to authenticated
with check (
bucket_id = 'documents'
and (storage.foldername(name))[1] = (select auth.uid())::text
);
create policy "Read own files"
on storage.objects for select
to authenticated
using (
bucket_id = 'documents'
and (storage.foldername(name))[1] = (select auth.uid())::text
);Sovrascrivere un file (upsert) richiede insieme le policy di insert, select e update. Gli oggetti in un bucket pubblico sono leggibili da chiunque abbia il link, quindi tenete fatture, contratti e documenti d'identità in bucket privati. Vedete anche la pagina di Supabase sul controllo degli accessi allo Storage.
Come testare le policy con due account
L'unico test che dimostra che la RLS funziona è accedere come un utente e provare a raggiungere i dati di un altro. L'editor SQL non vale: gira con privilegi elevati e ignora le vostre policy. Usate l'app stessa, oppure l'API con una vera sessione utente.
- Create due account, A e B, e aggiungete alcune righe con ciascuno.
- Connessi come A, verificate che l'elenco mostri solo le righe di A.
- Sempre come A, chiamate direttamente l'API per le righe di B (una select con lo user_id di B, un update sull'ID di una riga di B, una delete). Ognuna deve restituire nulla o fallire.
- Provate a inserire una riga con lo user_id di B. La policy di insert deve rifiutarla.
- Aprite l'app in una finestra privata, senza essere connessi, e richiedete le stesse tabelle. Le tabelle private non devono restituire nulla.
- Ripetete i passaggi per i file: A non deve poter scaricare il file di B tramite il suo percorso.
Errori comuni
| Errore | Risultato | Correzione |
|---|---|---|
| RLS mai attivata su una tabella | La tabella è aperta a chiunque abbia la chiave pubblica | Attivate la RLS su ogni tabella dello schema public |
| Policy senza clausola TO | Valutata anche per le richieste anonime | Aggiungete to authenticated |
| using (true) | Ogni riga è visibile al ruolo | Usatela solo per dati pensati per essere pubblici |
| Policy di update senza policy di select | Gli update non cambiano nulla, senza segnalarlo | Aggiungete una policy di select corrispondente |
| Ruolo letto dai metadati utente | Gli utenti possono assegnarsi il ruolo da soli | Usate app_metadata o una tabella admins |
| Vista su una tabella protetta | Le viste aggirano la RLS per impostazione predefinita | Da Postgres 15 in poi, createla con security_invoker = true |
| Chiave segreta nel codice client | Aggira ogni policy | Tenetela solo nel codice server |
Come se ne occupa Cadrant
Cadrant, il nostro builder, genera web app React su un progetto Supabase nel vostro account Supabase. Quando l'IA crea una tabella, scrive la migrazione con le policy RLS per le tabelle per utente, policy di storage comprese, e può ispezionare lo schema e le policy attuali prima di modificarli. Le chiavi segrete restano fuori dal codice client: integrazioni come Stripe passano da edge function, con i segreti salvati come variabili d'ambiente. La configurazione è descritta nella documentazione sulla connessione a Supabase e nella guida ai builder con IA e Supabase.
L'IA scrive le policy a partire da ciò che dite, quindi ditelo: «un cliente vede solo le proprie fatture, il responsabile le vede tutte». Gli esempi di prompt nei nostri esempi di prompt per web app mostrano come. Le policy generate sono una buona prima bozza, non una garanzia. Eseguite sempre il test con due account prima di condividere l'app con utenti reali. Cadrant, come qualsiasi builder con IA, può sbagliare una regola, e il test è il modo per scoprirlo.