Pattern di Row-Level Security di PostgreSQL per applicazioni multi-tenant
Impara la row-level security di PostgreSQL con pattern pratici per l'isolamento dei tenant e le regole di ruolo, così l'accesso viene applicato nel database e non solo nell'app.

Perché l'accesso imposto dal database conta nelle app business
Le app business di solito hanno regole come “gli utenti possono vedere solo i record della loro azienda” oppure “solo i manager possono approvare rimborsi”. Molti team applicano queste regole nell'interfaccia o nell'API e pensano sia sufficiente. Il problema è che ogni percorso in più verso il database diventa un'occasione in più di perdita di dati: uno strumento amministrativo interno, un job in background, una query di analytics, un endpoint dimenticato o un bug che salta un controllo.
L'isolamento dei tenant significa che un cliente (tenant) non può mai leggere o modificare i dati di un altro cliente, nemmeno per errore. L'accesso basato sui ruoli significa che persone all'interno dello stesso tenant hanno comunque poteri diversi, come agenti vs manager vs finanza. Queste regole sono facili da descrivere, ma difficili da mantenere perfettamente coerenti quando sono sparpagliate in più punti.
La Row-Level Security (RLS) di PostgreSQL è una funzione del database che permette al database stesso di decidere quali righe una richiesta può vedere o modificare. Invece di sperare che ogni query nell'app ricordi il giusto WHERE, il database applica automaticamente le policy.
RLS non è uno scudo magico per tutto. Non progetta il tuo schema, non sostituisce l'autenticazione e non ti protegge da qualcuno che ha già un ruolo privilegiato nel database (come un superuser). Inoltre non evita errori logici come “qualcuno può aggiornare una riga che non può selezionare” a meno che tu non scriva policy sia per la lettura sia per la scrittura.
Quello che ottieni è una solida rete di sicurezza:
- Un unico set di regole per ogni percorso che tocca il database
- Meno momenti “ops” quando viene rilasciata una nuova funzione
- Audit più chiari, perché le regole di accesso sono visibili in SQL
- Migliore difesa se un bug dell'API sfugge
C'è un piccolo costo di configurazione. Serve un modo coerente per passare al database “chi è questo utente” e “a quale tenant appartiene”, e bisogna mantenere le policy man mano che l'app cresce. Il ritorno è grande, specialmente per SaaS e strumenti interni dove sono in gioco dati sensibili dei clienti.
Fondamenti di Row-Level Security senza gergo
La Row-Level Security filtra automaticamente quali righe una query può vedere o modificare. Invece di affidarsi a ogni schermo, endpoint API o report perché “ricordi” le regole, è il database ad applicarle per te.
Con la RLS di PostgreSQL scrivi policy che vengono verificate ad ogni SELECT, INSERT, UPDATE e DELETE. Se la policy dice “questo utente può vedere solo le righe del tenant A”, allora una pagina amministrativa dimenticata, una nuova query o un hotfix frettoloso avranno comunque gli stessi guardrail.
La RLS è diversa da GRANT/REVOKE. GRANT decide se un ruolo può toccare una tabella (o colonne specifiche). La RLS decide quali righe all'interno di quella tabella sono permesse. Nella pratica si usano spesso entrambi: GRANT per limitare chi può accedere alla tabella, e RLS per limitare ciò che possono accedere.
Regge anche nel mondo reale più caotico. Le view generalmente rispettano la RLS perché l'accesso alla tabella sottostante fa scattare comunque la policy. Join e subquery vengono filtrati, quindi un utente non può “aggirare” il controllo unendosi ad altre tabelle. E la policy si applica indipendentemente dal client che esegue la query: codice dell'app, console SQL, job in background o tool di reportistica.
La RLS è adatta quando hai forti esigenze di isolamento dei tenant, più modi per interrogare gli stessi dati o molti ruoli che condividono tabelle (comune in SaaS e strumenti interni). Può invece essere eccessiva per app minuscole con un solo backend di fiducia, o per dati non sensibili che non escono mai da un servizio controllato. Nel momento in cui hai più di un punto d'ingresso (tool admin, esportazioni, BI, script), la RLS di solito ripaga l'investimento.
Inizia mappando tenant, ruoli e proprietà dei dati
Prima di scrivere una singola policy, chiarisci chi possiede cosa. La RLS di PostgreSQL funziona meglio quando il tuo modello dati riflette già tenant, ruoli e ownership.
Inizia dai tenant. Nella maggior parte delle app SaaS la regola più semplice è: ogni tabella condivisa che contiene dati del cliente ha una tenant_id. Questo include tabelle “ovvie” come le fatture, ma anche elementi che spesso si dimenticano, come allegati, commenti, log di audit e job in background.
Poi dai un nome ai ruoli che le persone usano davvero. Mantieni il set piccolo e umano: owner, manager, agent, read-only. Sono ruoli di business che poi mapperai ai controlli nelle policy (non sono la stessa cosa dei ruoli del database).
Decidi poi come sono posseduti i record. Alcune tabelle sono di proprietà di un singolo utente (per esempio, una nota privata). Altre sono di proprietà del team (per esempio, una casella condivisa). Mescolare i due senza un piano porta a policy difficili da leggere e facili da aggirare.
Un modo semplice per documentare le regole è rispondere alle stesse domande per ogni tabella:
- Qual è il confine del tenant (quale colonna lo impone)?
- Chi può leggere le righe (per ruolo e per ownership)?
- Chi può creare e aggiornare le righe (e in quali condizioni)?
- Chi può eliminare le righe (di solito la regola più severa)?
- Quali eccezioni sono permesse (supporto, automazione, esportazioni)?
Esempio: “Invoices” potrebbe permettere ai manager di vedere tutte le fatture del tenant, agli agenti di vedere le fatture dei clienti loro assegnati, e agli utenti read-only di visualizzare ma mai modificare. Decidi in anticipo quali regole devono essere rigide (isolamento tenant, cancellazioni) e quali possono essere flessibili (visibilità extra per i manager). Se costruisci con uno strumento no-code come AppMaster, questa mappatura aiuta anche a mantenere allineate le aspettative UI e le regole del database.
Pattern di design per tabelle multi-tenant
La RLS multi-tenant funziona meglio quando le tue tabelle hanno un aspetto prevedibile. Se ogni tabella memorizza il tenant in modo diverso, le policy diventano un puzzle. Una forma coerente rende la RLS di PostgreSQL più facile da leggere, testare e mantenere corretta nel tempo.
Inizia scegliendo un identificatore di tenant e usalo ovunque. Gli UUID sono comuni perché sono difficili da indovinare e facili da generare in molti sistemi. Gli interi vanno bene per app interne. Gli slug (come "acme") sono leggibili, ma possono cambiare, quindi trattali come campo di visualizzazione, non come chiave fondamentale.
Per i dati scoping per tenant, aggiungi una colonna tenant_id a ogni tabella che appartiene a un tenant, e rendila NOT NULL quando possibile. Se una riga può esistere senza tenant, di solito è un cattivo odore: spesso significa che stai mescolando dati globali e tenant nella stessa tabella, il che rende le policy RLS più difficili e fragili.
L'indicizzazione è semplice ma importante. La maggior parte delle query in un'app SaaS filtra prima per tenant, poi per un campo business come stato o data. Un buon default è un indice su tenant_id, e per tabelle ad alto traffico un indice composito come (tenant_id, created_at) o (tenant_id, status) in base ai tuoi filtri comuni.
Decidi presto quali tabelle sono globali e quali sono tenant-scoped. Tabelle globali comuni includono countries, currency codes o plan definitions. Tabelle tenant-scoped includono customers, invoices, tickets e tutto ciò che il tenant possiede.
Se vuoi un set di regole mantenibile, mantienilo stretto:
- Tabelle tenant-scoped:
tenant_id NOT NULL, RLS abilitata, le policy controllano sempretenant_id. - Tabelle di riferimento globali: nessun
tenant_id, nessuna policy tenant, read-only per la maggior parte dei ruoli. - Tabelle condivise ma controllate: separa le tabelle per concetto (evita di mescolare righe globali e tenant).
Se costruisci con uno strumento come AppMaster, questa coerenza ripaga anche nel modello dati. Una volta che tenant_id è un campo standard, puoi riutilizzare gli stessi pattern attraverso i moduli senza sorprese.
Passo dopo passo: crea la tua prima policy per tenant
Un buon primo risultato con la RLS di PostgreSQL è una singola tabella leggibile solo all'interno del tenant corrente. Il punto è semplice: anche se qualcuno dimentica un WHERE nell'API, il database rifiuta di restituire righe di altri tenant.
Inizia con una tabella che include una colonna tenant_id:
ALTER TABLE invoices ENABLE ROW LEVEL SECURITY;
Una volta abilitata la RLS, il comportamento di default spesso sorprende: se un ruolo è soggetto a RLS e non esiste una policy corrispondente, SELECT restituisce zero righe (e le scritture falliscono). È proprio quello che vuoi all'inizio.
Ora aggiungi una policy minima di lettura. Questo esempio presume che la tua app imposti una variabile di sessione come app.tenant_id dopo il login:
CREATE POLICY invoices_tenant_read
ON invoices
FOR SELECT
USING (tenant_id = current_setting('app.tenant_id')::uuid);
Poi aggiungi regole di scrittura. In RLS, USING controlla quali righe esistenti puoi toccare, e WITH CHECK controlla quali nuovi valori sei autorizzato a scrivere.
CREATE POLICY invoices_tenant_insert
ON invoices
FOR INSERT
WITH CHECK (tenant_id = current_setting('app.tenant_id')::uuid);
CREATE POLICY invoices_tenant_update
ON invoices
FOR UPDATE
USING (tenant_id = current_setting('app.tenant_id')::uuid)
WITH CHECK (tenant_id = current_setting('app.tenant_id')::uuid);
CREATE POLICY invoices_tenant_delete
ON invoices
FOR DELETE
USING (tenant_id = current_setting('app.tenant_id')::uuid);
Le policy sono PERMISSIVE per default, il che significa che qualsiasi policy può permettere l'accesso. Scegli RESTRICTIVE quando vuoi regole che devono passare tutte (utile per aggiungere un secondo guardiano come “solo account attivi”).
Mantieni le policy piccole e focalizzate sul ruolo. Invece di una regola enorme con tanti OR, crea policy separate per audience (per esempio, invoices_tenant_read_app_user e invoices_tenant_read_support_agent). È più facile da testare, rivedere e più sicuro da modificare in seguito.
Passare il contesto di tenant e utente in modo sicuro
Per far funzionare la RLS, il database deve sapere “chi sta chiamando” e “a quale tenant appartiene”. Le policy RLS possono solo confrontare righe con valori che il database può leggere a runtime, quindi devi passare quel contesto nella sessione.
Un pattern comune è impostare variabili di sessione dopo l'autenticazione, poi lasciare che le policy le leggano con current_setting(). L'app verifica l'identità (per esempio validando un JWT), poi copia nel collegamento al database solo i campi necessari (tenant_id, user_id, role).
-- Run once per request (or per transaction)
SELECT set_config('app.tenant_id', '3f2a0c3e-9c7b-4d3f-9c5c-3c5e9c5d1a11', true);
SELECT set_config('app.user_id', '8d9c6b1a-6b6d-4e32-9c0d-2bfe6f6c1111', true);
SELECT set_config('app.role', 'support_agent', true);
-- In a policy
-- tenant_id column is a UUID
USING (tenant_id = current_setting('app.tenant_id', true)::uuid);
Usare il terzo argomento true lo rende “locale” alla transazione corrente. Questo è importante se usi connection pooling: una connessione in pool può essere riutilizzata da un'altra richiesta, quindi non vuoi che il contesto tenant di ieri rimanga.
Popolare il contesto da claim JWT
Se la tua API usa JWT, tratta i claim come input, non come verità assoluta. Verifica prima la firma e la scadenza del token, poi copia solo i campi necessari (tenant_id, user_id, role) nelle impostazioni di sessione. Evita di permettere ai client di inviare direttamente questi valori tramite header o query string.
Contesto mancante o non valido: nega per default
Progetta le policy in modo che impostazioni mancanti risultino in zero righe.
Usa current_setting('app.tenant_id', true) così valori mancanti restituiscono NULL. Cast al tipo giusto (per esempio ::uuid) così formati non validi falliscono subito. E fai fallire la richiesta se non è possibile impostare il contesto tenant/user, invece di indovinare un valore di default.
Questo mantiene il controllo d'accesso coerente anche quando una query bypassa l'interfaccia o viene aggiunto un nuovo endpoint.
Pattern pratici di ruolo che restano manutenibili
Il modo più semplice per mantenere le policy RLS leggibili è separare identità e permessi. Una base solida è una tabella users più una tabella memberships che collega un utente a un tenant e a un ruolo (o a più ruoli). Le tue policy possono così rispondere a una domanda: “L'utente corrente ha la membership giusta per questa riga?”
Mantieni i nomi dei ruoli legati ad azioni reali, non a titoli di lavoro. “invoice_viewer” e “invoice_approver” invecchiano meglio di “manager”, perché la policy può essere scritta in termini chiari.
Ecco alcuni pattern di ruolo che restano semplici man mano che l'app cresce:
- Owner-only: la riga ha
created_by_user_id(oowner_user_id) e l'accesso verifica quella corrispondenza. - Team-only: la riga ha
team_id, e la policy verifica che l'utente sia membro di quel team nello stesso tenant. - Approved-only: le letture sono permesse solo quando
status = 'approved', e le scritture sono limitate agli approvatori. - Regole miste: parti rigido, poi aggiungi piccole eccezioni (per esempio, “support può leggere, ma solo all'interno del tenant”).
Gli admin cross-tenant sono dove molti team sbagliano. Gestiscili in modo esplicito, non come una scorciatoia “superuser” nascosta. Crea un concetto separato come platform_admin (globale) e richiedi un controllo deliberato nella policy. Ancora meglio, mantieni l'accesso cross-tenant in sola lettura per default e richiedi un requisito più alto per le scritture.
La documentazione conta più di quanto sembri. Metti un breve commento sopra ogni policy che spieghi l'intento, non solo il SQL. “Approvers possono cambiare lo status. Viewers possono solo leggere fatture approvate.” Sei mesi dopo, quella nota è ciò che rende sicure le modifiche.
Se costruisci con uno strumento no-code come AppMaster, questi pattern si applicano comunque. La UI e l'API possono muoversi velocemente, ma le regole del database restano stabili perché si basano su memberships e significato chiaro dei ruoli.
Scenario di esempio: un semplice SaaS con fatture e supporto
Immagina un piccolo SaaS che serve più aziende. Ogni azienda è un tenant. L'app ha fatture (soldi) e ticket di supporto (assistenza quotidiana). Gli utenti possono essere agenti, manager o support.
Modello dati (semplificato): ogni riga di invoice e ticket ha un tenant_id. I ticket hanno anche assignee_user_id. L'app imposta il tenant e l'utente correnti nella sessione del database immediatamente dopo il login.
Ecco come la RLS di PostgreSQL cambia il rischio quotidiano.
Un utente del Tenant A apre la schermata delle fatture e prova a indovinare un ID fattura del Tenant B (o l'interfaccia invia per errore quell'ID). La query viene comunque eseguita, ma il database restituisce zero righe perché la policy richiede invoice.tenant_id = current_tenant_id. Non c'è una perdita esplicita “access denied”, solo un risultato vuoto.
All'interno di un tenant, i ruoli restringono ulteriormente l'accesso. Un manager può vedere tutte le fatture e tutti i ticket del suo tenant. Un agente può vedere solo i ticket assegnati a lui, più forse le sue bozze. Qui molte squadre sbagliano nell'API, specialmente quando i filtri sono opzionali.
Il support è un caso speciale. Potrebbero aver bisogno di vedere le fatture per aiutare i clienti, ma non dovrebbero poter cambiare campi sensibili come amount, bank_account o tax_id. Un pattern pratico è:
- Permettere
SELECTsulle fatture per il ruolo support (sempre scoping tenant). - Permettere
UPDATEsolo tramite un percorso “sicuro” (per esempio, una view che espone colonne editabili, o una policy di update rigorosa che rifiuta modifiche a campi protetti).
Ora lo scenario del “bug accidentale dell'API”: un endpoint dimentica di applicare il filtro tenant durante un refactor. Senza RLS può risultare in un leak di fatture cross-tenant. Con RLS il database rifiuta di restituire righe fuori dal tenant di sessione, quindi il bug si traduce in uno schermo rotto, non in una fuga di dati.
Se costruisci questo tipo di SaaS in AppMaster, vuoi comunque queste regole nel database. I controlli nell'interfaccia sono utili, ma le regole del database sono ciò che tengono quando qualcosa sfugge.
Errori comuni e come evitarli
La RLS è potente, ma piccole distrazioni possono trasformare “sicuro” in “sorprendente”. La maggior parte dei problemi appare quando si aggiunge una nuova tabella, cambia un ruolo o qualcuno testa con l'utente database sbagliato.
Un errore comune è dimenticare di abilitare la RLS su una tabella nuova. Potresti scrivere policy accurate per le tabelle core, poi aggiungere una tabella “notes” o “attachments” e pubblicarla con accesso completo. Fai diventare un'abitudine: nuova tabella significa RLS abilitata, più almeno una policy.
Un'altra trappola frequente è policy disallineate tra le azioni. Una policy che permette INSERT ma blocca SELECT può far sembrare che “i dati scompaiono” dopo la creazione. L'opposto è pure fastidioso: utenti leggono righe che non possono creare, quindi aggirano il problema nell'UI. Pensa ai flussi: “crea poi visualizza”, “aggiorna poi riapri”, “cancella poi lista”.
Stai attento con le funzioni SECURITY DEFINER. Esse eseguono con i privilegi del proprietario della funzione, il che può aggirare la RLS se non sei rigoroso. Se le usi, mantienile piccole, valida gli input e evita SQL dinamico a meno che non sia davvero necessario.
Evita anche di affidarti al solo filtraggio lato app lasciando l'accesso al database aperto. Anche le API ben costruite crescono con nuovi endpoint, job in background e script amministrativi. Se il ruolo del database può leggere tutto, prima o poi qualcosa succederà.
Per catturare i problemi presto, mantieni i controlli pratici:
- Testa usando lo stesso ruolo DB che usa la tua app in produzione, non il tuo utente admin personale.
- Aggiungi un test negativo per tabella: un utente di un altro tenant deve vedere zero righe.
- Conferma che ogni tabella supporti le azioni attese:
SELECT,INSERT,UPDATE,DELETE. - Revisiona l'uso di
SECURITY DEFINERe documentane il motivo. - Includi “RLS abilitata?” nelle checklist di code review e nelle migrazioni.
Esempio: se un agente di support crea una nota su una fattura ma non riesce a leggerla, spesso è una policy INSERT senza la corrispondente SELECT (o il contesto tenant non viene impostato per quella sessione).
Checklist rapida per validare la tua configurazione RLS
La RLS può sembrare corretta in revisione e poi fallire nell'uso reale. La convalida riguarda meno la lettura delle policy e più il tentare di romperle con account e query realistici. Testala come la userà l'app, non come speri che funzioni.
Crea un piccolo set di identità di test. Usa almeno due tenant (Tenant A e Tenant B). Per ogni tenant aggiungi un utente normale e un admin o manager. Se supporti ruoli come “support agent” o “read-only”, aggiungine uno anche per i test.
Poi metti sotto pressione la RLS con un piccolo set ripetibile di controlli:
- Esegui le operazioni core per ogni ruolo: lista righe, recupera una singola riga per id, insert, update e delete. Per ogni operazione prova casi “permesse” e “da bloccare”.
- Dimostra i confini dei tenant: come Tenant A prova a leggere o modificare dati di Tenant B usando id che sai esistere. Dovresti ottenere zero righe o un errore di permesso, mai “alcune righe”.
- Testa join per fughe: fai join di tabelle protette con altre tabelle (inclusi lookup). Conferma che un join non possa portare dentro righe di un altro tenant tramite foreign key o view.
- Verifica che contesto mancante o errato neghi accesso: cancella il contesto tenant/user e ritenta. “Nessun contesto” dovrebbe fallire chiuso. Prova anche un tenant id non valido.
- Conferma le prestazioni di base: guarda i piani di esecuzione e assicurati che gli indici supportino il pattern di filtro per tenant (comunemente
tenant_idpiù quello che usi per ordinare o cercare).
Se un test ti sorprende, sistema prima la policy o l'impostazione del contesto. Non rattoppare nel UI o nell'API sperando che le regole del database “tengano abbastanza”.
Prossimi passi: rollout sicuro e mantenimento della coerenza
Tratta la RLS di PostgreSQL come un sistema di sicurezza: introduttela con cura, verifica spesso e mantieni le regole abbastanza semplici perché il team le segua.
Parti in piccolo. Scegli le tabelle dove una fuga farebbe più danno (pagamenti, fatture, dati HR, messaggi dei clienti) e abilita prima la RLS lì. I primi successi valgono più di un grande rollout che nessuno comprende appieno.
Un ordine di rollout pratico spesso è:
- Tabelle core “owned” prima (righe chiaramente appartenenti a un tenant)
- Tabelle con dati personali (PII)
- Tabelle condivise ma filtrate per tenant (report, analytics)
- Tabelle di join e casi limite (many-to-many)
- Tutto il resto quando le basi sono stabili
Rendi i test obbligatori. I test automatici dovrebbero eseguire le stesse query come tenant e ruoli diversi e confermare i cambiamenti. Includi controlli “deve permettere” e “deve negare”, perché i bug più costosi sono i permessi eccessivi silenziosi.
Tieni un punto chiaro nella tua flow di richiesta dove il contesto di sessione viene impostato prima che qualsiasi query venga eseguita. Tenant id, user id e role dovrebbero essere applicati una sola volta, presto, e mai indovinati dopo. Se imposti il contesto a metà di una transazione, prima o poi eseguirai una query con valori mancanti o obsoleti.
Quando costruisci con AppMaster, prevedi coerenza tra le API generate e le policy PostgreSQL. Standardizza come il contesto tenant e ruolo viene passato al database (per esempio le stesse variabili di sessione per ogni endpoint) così le policy si comportano allo stesso modo ovunque. Se usi AppMaster su appmaster.io, la RLS resta l'autorità finale per l'isolamento dei tenant, anche se filtri l'accesso nell'interfaccia.
Infine, osserva cosa fallisce. I rifiuti di autorizzazione sono segnali utili, specialmente subito dopo il rollout. Monitora i dinieghi ripetuti e indaga se indicano un attacco reale, un flusso client rotto o una policy troppo severa.
Una breve lista di abitudini che aiuta la RLS a restare sana:
- Mentalità di default-deny, con eccezioni aggiunte intenzionalmente
- Nomi di policy chiari (tabella + azione + audience)
- Le modifiche alle policy revisionate come modifiche al codice
- Denial loggati e rivisti durante il rollout iniziale
- Un piccolo set di test aggiunto per ogni nuova tabella con RLS


