Supabase RLS in scala: 7 pattern per query che restano veloci
RLS rallenta le query da 2x a 11x su tabelle grandi quando le policy saltano il wrapper SELECT, gli indici o nascondono join. 7 pattern per risolvere.
Row Level Security (RLS) è una funzione di Postgres che filtra le righe in base a policy collegate alle tabelle. Su Supabase è il modo standard per imporre l'accesso per utente e per tenant senza scrivere middleware lato server. Il problema è che una policy viene eseguita su ogni riga toccata da ogni query, quindi una policy scritta senza pensare alle prestazioni può trasformare una query da 5 ms in una da 500 ms. In produzione vediamo policy RLS complesse rallentare le query da 2x a 11x su tabelle grandi, con i controlli di proprietà semplici che restano vicini al baseline solo quando la colonna sottostante è correttamente indicizzata.
Questa guida raccoglie 7 pattern che applichiamo su ogni progetto Supabase che mandiamo in produzione. Vanno applicati in ordine: ognuno si appoggia al precedente. Alla fine avrai policy che scalano su tabelle multi-tenant da milioni di righe senza bruciare il budget di query.
Cosa serve prima di iniziare
- Un progetto Supabase con almeno una tabella che ha RLS abilitata.
- Accesso al SQL editor del dashboard Supabase, o una connessione tramite
psqlcon il service role. - Uno schema con almeno un confine di tenant (utente, team o organizzazione).
- Familiarità con l'output di
EXPLAIN ANALYZE. Lo usiamo per verificare che le modifiche abbiano avuto effetto.
1. Avvolgi auth.uid() e le funzioni helper in una SELECT
Per default Postgres rivaluta una funzione una volta per riga. Avvolgere la chiamata in una subquery la promuove a InitPlan, quindi viene eseguita una volta per istruzione e il risultato viene messo in cache. Supabase documenta questa come la modifica con il maggior impatto che puoi fare sulla maggior parte delle policy.
-- Lento: auth.uid() viene eseguita su ogni riga
create policy "owner can read"
on public.invoices for select
using (auth.uid() = user_id);
-- Veloce: auth.uid() viene eseguita una volta per istruzione
create policy "owner can read"
on public.invoices for select
using ((select auth.uid()) = user_id);Funziona con qualsiasi funzione il cui valore di ritorno non dipenda dalla riga in esame. auth.uid(), auth.jwt() e i tuoi helper SECURITY DEFINER rientrano tutti. Le funzioni per-riga, come una che fa hash di una colonna, no.
2. Indicizza ogni colonna che la policy legge
La policy (select auth.uid()) = user_id resta veloce solo se Postgres può cercare in modo binario nella colonna user_id invece di scansionare ogni riga. Su una tabella da un milione di righe, aggiungere un indice btree su user_id trasforma una scansione sequenziale che tocca ogni riga in un lookup di indice che restituisce in millisecondi. I benchmark reali mostrano oltre 100x di miglioramento su tabelle grandi dopo l'indicizzazione della colonna usata in policy.
create index invoices_user_id_idx
on public.invoices (user_id);Per le tabelle multi-tenant, aggiungi un indice composito sulle colonne che filtri davvero insieme: (tenant_id, status) batte due indici a singola colonna quando entrambe fanno parte di ogni query. Per carichi parziali, un indice parziale è ancora meglio: create index ... where status = 'active' resta piccolo e resta caldo in cache.
3. Aggiungi un filtro esplicito lato client anche quando RLS lo farebbe
RLS aggiunge una clausola WHERE implicita, quindi una query come .from('invoices').select() restituirà solo le righe che l'utente può vedere. Il problema è che Postgres ha meno hint di indice con cui lavorare quando il filtro è solo da policy. Un .eq('user_id', userId) esplicito sul client permette al planner di usare lo stesso indice su cui si appoggia la policy, invece di valutare la policy come passaggio finale.
// Male: RLS filtra ma il planner non ha hint
const { data } = await supabase.from('invoices').select();
// Bene: il filtro esplicito rispecchia la condizione RLS
const { data } = await supabase.from('invoices').select().eq('user_id', userId);È una di quelle regole che la prima volta sembra ridondante. Non lo è. I test interni di Supabase e diversi report di produzione mostrano riduzioni del tempo di query tra il 15% e il 60% su carichi in cui questa è l'unica modifica applicata.
4. Sostituisci i join nelle policy con subquery IN o ANY dal lato utente
Una policy multi-tenant ingenua fa join dalla tabella sorgente alla tabella di membership:
-- Lento: join valutato per riga
create policy "team members read"
on public.documents for select
using (
auth.uid() in (
select user_id from team_members
where team_members.team_id = documents.team_id
)
);Riscrivila per recuperare i team dell'utente una sola volta e usare IN contro team_id della tabella sorgente:
-- Veloce: la subquery si risolve una volta, diventa un lookup di set
create policy "team members read"
on public.documents for select
using (
team_id in (
select team_id from team_members
where user_id = (select auth.uid())
)
);La prima forma costringe Postgres a consultare team_members per ogni riga candidata. La seconda risolve i team dell'utente in un piccolo set, poi il planner usa l'indice su documents.team_id per filtrare direttamente. Su una tabella documents da 200.000 righe che serve 500 team, abbiamo misurato che questa sola riscrittura porta la query da 380 ms a 22 ms.
5. Sposta i lookup costosi in funzioni SECURITY DEFINER
Le policy RLS si propagano a cascata. Se la tua policy interroga un'altra tabella che ha a sua volta RLS, ogni controllo di riga innesca un altro set di policy e il costo si moltiplica. Le funzioni SECURITY DEFINER vengono eseguite con il ruolo che le ha definite (tipicamente postgres), che può avere BYPASSRLS. Una chiamata di funzione da dentro una policy quindi cortocircuita la cascata.
create or replace function public.user_team_ids()
returns setof uuid
language sql
security definer
stable
set search_path = public
as $$
select team_id from team_members
where user_id = (select auth.uid());
$$;
create policy "team members read"
on public.documents for select
using (team_id in (select * from public.user_team_ids()));Questo pattern è anche il modo per uscire dalla ricorsione RLS: una policy sulla tabella A che deve leggere la tabella B che ha la propria policy dipendente da A. Un helper SECURITY DEFINER spezza il loop. Verifica con cura queste funzioni perché bypassano il modello di sicurezza che stai cercando di imporre: limita il loro search_path e non accettare mai input non fidato.
6. Marca le funzioni helper stabili come STABLE
Il volatility hint STABLE dice a Postgres che la funzione restituisce lo stesso risultato per gli stessi argomenti all'interno di una singola istruzione. Il planner lo usa per memorizzare in cache e riutilizzare il risultato, e questo si combina con il pattern (select fn()) dello step 1. Una funzione marcata VOLATILE (il default) non viene messa in cache nemmeno se avvolta.
create or replace function public.current_org_id()
returns uuid
language sql
stable -- non volatile, non immutable
security definer
as $$
select org_id from memberships
where user_id = (select auth.uid())
limit 1;
$$;Usa IMMUTABLE solo se il risultato dipende davvero solo dagli argomenti (una funzione di hash, ad esempio). Per qualsiasi cosa che legga da una tabella STABLE è l'hint corretto e dà al planner la cache di cui ha bisogno.
7. Limita le policy con TO authenticated
Per default una policy si applica a ogni ruolo, incluso anon. Anche quando nessun utente anonimo potrebbe mai soddisfare la clausola USING, Postgres valuta comunque la policy per ogni richiesta anonima. Aggiungere TO authenticated dice al planner di saltare del tutto la valutazione per il ruolo anon.
create policy "owner can read"
on public.invoices for select
to authenticated
using ((select auth.uid()) = user_id);Affianca a questa una policy separata per le letture service-role o admin se ti servono. Suddividere le policy per ruolo mantiene ognuna economica.
Come verificare che funzioni
Esegui EXPLAIN ANALYZE su una query rappresentativa prima e dopo ogni modifica. Le metriche che contano:
- Nodo del piano: un
Seq Scandopo lo step 2 significa che l'indice è mancante o inutilizzabile; vuoi unIndex Scano unIndex Only Scan. - Presenza di InitPlan: dopo lo step 1 dovresti vedere un
InitPlanperauth.uid()o per le tue funzioni helper, non un Function Scan a livello di riga. - Actual rows vs. Plan rows: uno scarto di 100x significa che le statistiche sono datate; esegui
ANALYZEsulla tabella. - Total execution time: alla fine è l'unico numero che conta. Se non rientra nel budget della tua applicazione dopo tutti e 7 i pattern, la policy stessa va riprogettata.
Per vedere il piano dall'API PostgREST che il client JS usa, abilita la feature explain in sviluppo con alter role authenticator set pgrst.db_plan_enabled to true; notify pgrst, 'reload config';, poi chiama .explain() su una query. Disabilitala in produzione.
Errori comuni e correzioni
La policy è corretta ma ogni query va in timeout
Verifica che la colonna referenziata dalla policy sia indicizzata e che l'indice includa le colonne di cui il planner ha bisogno. Esegui EXPLAIN con BUFFERS on: un valore alto di Buffers: shared read indica un indice mancante. Se l'indice esiste ma non viene usato, le statistiche sono datate o il tipo della colonna non corrisponde (una colonna text confrontata con un uuid via cast implicito non userà l'indice).
Il pattern di funzione avvolta restituisce la riga sbagliata
Ricontrolla che la funzione non dipenda dalla riga. L'ottimizzazione si applica solo quando la funzione non legge la riga in esame. Una funzione che prende la riga come input viene comunque valutata per riga.
Aggiungere TO authenticated restituisce zero righe
Probabilmente avevi una query che girava come service role o anon. Limitarla ad authenticated esclude entrambi. Aggiungi una policy separata per il service role o usa il client service-role dove bypassare RLS è intenzionale.
Una funzione SECURITY DEFINER fa leak di dati
Una funzione SECURITY DEFINER con BYPASSRLS restituisce qualunque cosa il suo body selezioni, indipendentemente da chi l'ha chiamata. Filtra sempre dentro la funzione su (select auth.uid()) o su un altro valore derivato dal chiamante, mai su un argomento che il chiamante potrebbe falsificare.
Per andare oltre
RLS è uno strato in un'architettura Supabase multi-tenant. Il gruppo di decisioni intorno a esso merita la sua lista di lettura. Abbiamo trattato perché ogni SaaS dovrebbe nascere multi-tenant, dove RLS è l'approccio di default a livello di schema per l'isolamento dei tenant. Per il livello di runtime che consuma queste policy, edge runtime vs Node runtime copre il trade-off che le deploy Supabase in produzione incontrano più spesso.
Studio
Inizia un progetto.
Un partner unico per aziende, PA, startup e SaaS. Produzione più veloce, tecnologie moderne, costi ridotti. Un team, una fattura.