La riga che manca: perché un portale multi-azienda va protetto nel database, non nell'applicazione

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 ·

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.

1 · Il problema che RLS risolve, in una query

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.

Che cos'è, in una frase

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.

2 · Le policy vere: due aziende, la stessa query, righe diverse

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.

002_rls_documenti.sql

-- 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.

Utente di Azienda A — SELECT * FROM documenti

337 righe di altre aziende. Non nascoste dall'interfaccia: mai uscite dal database.

Utente di Azienda B — SELECT * FROM documenti

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.

3 · La tensione vera: il GDPR chiede di cancellare, la legge fiscale chiede di conservare

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

Il dato ha un obbligo di conservazione per legge?

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.

Il vincolo fiscale è ancora attivo?

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.

156 anonimizzate — Che cosa resta

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.

cancella-organizzazione.sql

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.

4 · Verificare che le policy tengano, prima di scoprirlo da un cliente

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.

VerificaComandoRisultato atteso
Membro A legge i propri documentiSET request.jwt.claim.sub = utente_A; SELECT * FROM documentiSolo le righe con org_id dell'azienda A
Membro A prova a leggere quelli di BSET request.jwt.claim.sub = utente_A; SELECT * FROM documenti WHERE org_id = org_BZero righe — non un errore, zero risultati
Membro senza ruolo editor prova a inserireSET 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 documentiZero righe: RLS nega tutto senza un'identità valida

Perché questo test conta più della copertura del codice applicativo

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.

Mezz'ora sul vostro schema, prima del primo cliente enterprise

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.

Il servizio dietro questo articolo

Sviluppo applicazioni web su misura

Se vi è servito questo, continuate da qui

FAQ

Che cos'è Row Level Security in PostgreSQL e a che cosa serve?

È 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.

RLS rallenta le query su un database multi-tenant?

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.

Come si concilia la cancellazione GDPR con l'obbligo di conservare le fatture?

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.

Come si verifica che le policy RLS funzionino davvero?

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.

Nadia Maar