Come Row Level Security in PostgreSQL rende impossibile — non improbabile — che un'azienda veda i dati di un'altra, il codice reale delle policy, e la tensione vera fra la cancellazione GDPR e la fattura che la legge vi obbliga a conservare.
di Nadia Maar ·
Ogni portale SaaS multi-azienda nasconde la stessa domanda scomoda: che cosa succede il giorno in cui qualcuno dimentica un controllo? In un'applicazione normale, «l'azienda A non vede i dati dell'azienda B» è una riga di codice — un `WHERE org_id = ?` — ripetuta in ogni endpoint, ogni query, ogni report che qualcuno scriverà da qui a tre anni. Basta dimenticarla UNA volta, in UN endpoint, perché un cliente veda i dati di un altro. Non è un'ipotesi: è la causa più comune delle violazioni di dati che finiscono sui giornali specializzati, e non ha niente a che fare con hacker sofisticati.
Row Level Security sposta quel controllo dall'applicazione al database: la riga sbagliata non arriva mai, qualunque codice la interroghi, qualunque endpoint qualcuno scriva domani senza ricordarsi della regola. Non è più difendibile: è impossibile per costruzione.
Questo pezzo mostra come si progetta un database multi-azienda con RLS, il codice reale delle policy — quello che decide chi vede che cosa — e la parte che quasi nessun articolo su RLS affronta: che cosa succede quando il GDPR chiede di cancellare un'azienda che ha ancora fatture da conservare per legge.
Immaginate un portale dove più aziende clienti gestiscono i propri documenti. La tabella `documenti` ha una colonna `org_id`. Il modo normale di isolare i dati è che ogni query nell'applicazione aggiunga `WHERE org_id = $1`, con l'id dell'organizzazione dell'utente loggato. Funziona, finché funziona sempre — ed è lì il problema: dipende dalla disciplina di chiunque scriva codice su quella tabella, per sempre.
Nessuno di questi casi è negligenza grave. Sono il modo normale in cui il codice cresce in tre anni, con persone diverse che ci lavorano. RLS non chiede a nessuno di ricordarsi niente: la regola vive una volta, nel database, e si applica a ogni query che arriva — dall'applicazione, da uno script, da un amministratore con accesso diretto, da un endpoint scritto fra sei mesi da chi non ha letto questo articolo.
Row Level Security è una funzionalità di PostgreSQL che filtra automaticamente le RIGHE che una query può vedere o modificare, in base a chi sta eseguendo la query — non in base a che cosa l'applicazione ha chiesto di fare. Si attiva per tabella e si definisce con delle POLICY: regole scritte in SQL che restituiscono vero o falso per ogni riga.
Ecco lo schema minimo e le policy che lo proteggono. `memberships` collega ogni utente Supabase (`auth.uid()`) alle organizzazioni di cui fa parte; `documenti` porta un `org_id`. La policy non si scrive nell'applicazione: si scrive una volta, sul database, e vale per sempre.
-- Attiva RLS: da questo momento, senza policy esplicite,
-- NESSUNA riga è visibile a nessuno. È il default sicuro:
-- si dimentica una policy, non un filtro.
alter table documenti enable row level security;
-- Un membro vede solo i documenti della propria organizzazione.
create policy "membri_leggono_propria_org"
on documenti for select
using (
org_id in (
select org_id from memberships
where user_id = auth.uid()
)
);
-- Solo chi ha ruolo "editor" o superiore può inserire,
-- e solo dentro la propria organizzazione — non basta
-- essere loggati, serve il ruolo giusto sulla riga giusta.
create policy "editor_inseriscono_propria_org"
on documenti for insert
with check (
org_id in (
select org_id from memberships
where user_id = auth.uid() and ruolo in ('editor', 'admin')
)
);
-- Il personale interno (staff) vede TUTTE le organizzazioni:
-- una policy aggiuntiva, non un'eccezione nel codice applicativo.
create policy "staff_legge_tutto"
on documenti for select
using (
exists (select 1 from staff where user_id = auth.uid())
);La riga più importante non è nelle policy: è enable row level security. Da quel momento il comportamento predefinito è negare tutto — una tabella con RLS attivo e zero policy è invisibile a chiunque, admin del database escluso. È il contrario del rischio normale del software, dove dimenticare un controllo lo lascia troppo permissivo: qui dimenticarlo lo lascia troppo restrittivo, e un bug troppo restrittivo si scopre al primo test, non alla prima violazione.
337 righe di altre aziende. Non nascoste dall'interfaccia: mai uscite dal database.
338 righe di altre aziende, comprese le tre di Azienda A. Stessa query, nessun WHERE scritto a mano.
Stessa query, stesso istante: SELECT * FROM documenti eseguita da due utenti diversi restituisce due risultati diversi — senza che una sola riga di applicazione sappia che esistono due aziende.
Questo è il punto che vale l'intero articolo: la query è identica, byte per byte. Nessun `WHERE` scritto a mano, nessun parametro passato dall'applicazione. PostgreSQL riscrive la query per ciascun utente prima di eseguirla, in base alle policy attive — un livello sotto a qualunque bug che l'applicazione potrà mai avere.
Qui la maggior parte degli articoli su RLS si ferma, e qui comincia il problema che i clienti fanno davvero. Un'azienda chiude il contratto e chiede la cancellazione dei propri dati, come il GDPR le permette. Ma le fatture emesse per quell'azienda vanno conservate per obbligo fiscale — in Italia, dieci anni. Le due richieste sono legittime e opposte, e un `DELETE FROM organizzazioni WHERE id = ?` le tratta come se fossero la stessa cosa.
1.000 richieste di cancellazione all'anno
180 hanno un vincolo attivo; 820 cancellazione reale.
La maggioranza dei dati di un'azienda non ha nessun obbligo di conservazione: profilo, preferenze, messaggi, allegati di progetto. Per questi il GDPR vince senza eccezioni, e la cancellazione è quella vera — un DELETE con la cascata su tutte le tabelle collegate, non un flag nascosto che lascia i dati dove sono.
156 hanno un vincolo attivo; 24 cancellazione reale.
Ventiquattro richieste riguardano fatture ormai fuori dal periodo di conservazione: nessuna legge le protegge più, e vanno cancellate davvero come le altre. È il controllo che quasi nessun sistema fa, ed è la ragione per cui certi archivi conservano per sempre dati che potevano sparire da anni.
Per queste la legge fiscale prevale sul diritto alla cancellazione — è un'eccezione esplicita nel GDPR stesso (art. 17, comma 3, lettera b). Ragione sociale e indirizzo diventano «Cliente rimosso #4471»; importo, aliquota e data restano, perché sono quelli che l'Agenzia delle Entrate può chiedere di verificare. Alla scadenza del periodo, una funzione schedulata cancella davvero anche queste.
Il punto tecnico è che questa distinzione va scritta nello schema, non nella testa di chi scrive lo script di cancellazione. Una colonna `soggetto_a_conservazione boolean` sulla tabella `documenti`, popolata alla creazione in base al tipo di documento, rende la funzione di cancellazione una query che si spiega da sola invece di un `if` nascosto in un service da qualche parte.
create or replace function gdpr_cancella_organizzazione(org uuid)
returns void
language plpgsql
security definer -- gira coi permessi della funzione, non dell'utente:
-- una cancellazione GDPR non passa per RLS del chiamante
as $
begin
-- I dati senza vincolo fiscale: cancellazione reale.
delete from documenti
where org_id = org and soggetto_a_conservazione = false;
delete from messaggi where org_id = org;
delete from allegati where org_id = org;
-- Le fatture: anonimizzazione, non cancellazione.
-- Restano importo, data, aliquota; sparisce chi le ha ricevute.
update fatture
set ragione_sociale = 'Cliente rimosso #' || org::text,
indirizzo = null,
partita_iva = null,
anonimizzata_il = now()
where org_id = org;
-- L'organizzazione stessa: soft-delete con data,
-- non un DELETE — serve a provare CHE la richiesta
-- è stata evasa, se mai qualcuno lo chiedesse.
update organizzazioni
set cancellata_il = now(), nome = 'Organizzazione rimossa'
where id = org;
end;
$;«security definer» è la riga più delicata della funzione: fa sì che la cancellazione GDPR non sia soggetta alle stesse policy RLS che proteggono i dati normalmente. È corretto — una richiesta di cancellazione arriva DOPO che l'identità è già stata verificata, non deve ripassare dal filtro pensato per l'uso quotidiano — ma è anche l'unico punto del sistema dove un errore bypassa la protezione descritta nella sezione due, e per questo la funzione va testata più di ogni altra riga del portale.
Una policy scritta male è invisibile nei test normali dell'applicazione, perché l'applicazione chiama sempre con l'utente giusto. Il modo in cui si scopre una falla è impersonare ogni ruolo e provare a leggere quello che quel ruolo non dovrebbe vedere — non una volta alla scrittura della policy, ma ad ogni migrazione che tocca quella tabella.
| Verifica | Comando | Risultato atteso |
|---|---|---|
| Membro A legge i propri documenti | SET request.jwt.claim.sub = utente_A; SELECT * FROM documenti | Solo le righe con org_id dell'azienda A |
| Membro A prova a leggere quelli di B | SET request.jwt.claim.sub = utente_A; SELECT * FROM documenti WHERE org_id = org_B | Zero righe — non un errore, zero risultati |
| Membro senza ruolo editor prova a inserire | SET request.jwt.claim.sub = utente_viewer; INSERT INTO documenti (...) | Rifiutato dalla policy with check |
| Nessun utente (query anonima) | RESET request.jwt.claim.sub; SELECT * FROM documenti | Zero righe: RLS nega tutto senza un'identità valida |
Un test dell'applicazione verifica che l'endpoint chiami la query giusta. Un test come questo verifica che, SE qualcuno chiamasse una query sbagliata — oggi o fra due anni, da un endpoint o da uno script — il database rifiuterebbe comunque la riga fuori posto. È l'unico livello di test che protegge anche dal codice che non è ancora stato scritto.
Se state costruendo — o avete già costruito senza RLS — un portale dove più aziende condividono lo stesso database, portate lo schema: in mezz'ora guardiamo dove passa oggi il confine fra un'azienda e l'altra, e quanto ci vuole a spostarlo dall'applicazione al database.
Se il vostro portale ha un database per ogni cliente, ve lo dico: probabilmente non vi serve RLS, vi serve solo la disciplina che avete già. RLS risolve un problema specifico — un database condiviso fra tenant — non è un requisito universale di sicurezza.
Sviluppo applicazioni web su misura
È una funzionalità che filtra automaticamente le righe che una query può leggere o modificare, in base a chi la esegue — non in base a quello che l'applicazione ha chiesto di fare. Su un database condiviso fra più aziende, sposta il controllo «questa azienda vede solo i propri dati» dall'applicazione — dove va ripetuto in ogni endpoint e si può dimenticare — al database, dove si scrive una volta e vale per sempre.
Nella pratica no, se gli indici sulle colonne usate nelle policy (tipicamente org_id) sono già al loro posto: PostgreSQL integra la condizione della policy nel piano di esecuzione della query, non la applica come filtro separato dopo. Il costo aggiuntivo è comparabile a quello di un WHERE scritto a mano con lo stesso indice.
Con l'anonimizzazione invece della cancellazione per i soli dati soggetti a vincolo fiscale: ragione sociale e indirizzo del cliente si sostituiscono con un identificativo, mentre importo, data e aliquota restano perché sono quello che l'Agenzia delle Entrate può chiedere di verificare. È un'eccezione esplicita nel GDPR stesso (art. 17, comma 3, lettera b), non un modo per aggirare la richiesta di cancellazione.
Impersonando ogni ruolo con SET request.jwt.claim.sub e provando esplicitamente a leggere quello che quel ruolo non dovrebbe vedere: la query deve restituire zero righe, non un errore. Va ripetuto a ogni migrazione che tocca la tabella, non solo alla scrittura iniziale della policy — è l'unico test che protegge anche dal codice non ancora scritto.