Vai al contenuto

Supabase RLS multi-tenant: utente in due org, dati esposti

Supabase

Visualizza come MarkdownApri in ClaudeApri in ChatGPTApri in Perplexity

Anche la domanda viene copiata. Se la chat si apre vuota, la incolli.

In breve

Una policy come auth_in_org(organization_id) chiede “il chiamante è membro dell’org proprietaria di questa riga?”. Per un utente che appartiene all’org A e all’org B, la risposta è sì in entrambe. Nella mia sonda, con la sola RLS un utente così aveva accesso in lettura e scrittura a un documento dell’altra org. Aggiungendo alla query l’org della richiesta corrente, il risultato è stato 0 righe.

L’ho scoperto mentre rafforzavo la piattaforma documentale per l’edilizia, un’app Supabase multi-tenant. Ogni tabella dei tenant aveva la row level security attiva e forzata, quindi sembrava finita.

Cosa sembrava sicuro?

Ogni policy aveva la stessa forma: l’organization_id della riga deve essere un’org di cui il chiamante è membro. Per questo alcune server action cercavano un documento solo per id. Il ragionamento era “la RLS filtra comunque”.

const { data } = await supabase
  .from('documents')
  .select('*')
  .eq('document_id', id)
  .single()

Per un utente con una sola appartenenza funziona, e un test con quell’utente passa.

Perché la RLS lascia passare l’altra org?

La policy è un test di appartenenza. Postgres la valuta per ogni riga, come descrive la documentazione sulla row security, e non sa per conto di quale organizzazione agisce la richiesta, perché questo vive nell’app: nell’URL, in un cookie o nel selettore di org. Per un utente membro di entrambe le org, tutte le righe di entrambe passano.

L'org della richiesta non è nella policy. Un utente, membro dell'org A e dell'org B, che lavora nell'org A. Solo la policy RLS: Richiesta in org A → Membro della sua org? → Sì, A o B → Riga org B restituita; RLS più l'org di questa richiesta: Richiesta in org A → Membro della sua org? → Riga in org A? → 0 righe org B.
La mia sonda su un utente di prova in due org, settembre 2026: con la sola RLS lettura e scrittura sul documento dell'org B; con l'org della richiesta, 0 righe.

Quindi il filtro per organization_id nell’app non è un secondo livello sopra la RLS. Per questo utente è l’unico controllo.

Mentre è su quegli helper, controlli se guardano organizations.is_active. Gli helper di appartenenza spesso non lo fanno, e allora un’org disattivata mantiene accesso in lettura e scrittura a livello RLS. Quando l’unico controllo di is_active vive nel resolver di tenant dell’app, qualsiasi query che salta il resolver salta anche la disattivazione.

Cosa l’ha risolto?

L’org della richiesta è diventata un argomento obbligatorio di ogni funzione di servizio che legge o scrive dati dei tenant. Un punto di chiamata non può dimenticarla, perché senza non compila.

export async function getDocument(orgId: string, id: string) {
  return supabase
    .from('documents')
    .select('*')
    .eq('organization_id', orgId)
    .eq('document_id', id)
    .single()
}

Dalla revisione sono uscite altre due regole:

  • Non prenda mai l’org dalla riga che sta per scrivere. update ... where id = $1 and organization_id = <l'organization_id della riga stessa> è sempre vero, quindi non protegge nulla.
  • Una chiave esterna composta su (id, organization_id) fa rifiutare al database un collegamento tra righe di org diverse. Controlli ogni tabella figlia: è facile aggiungerla a tre e dimenticare la quarta.

Come controllare la sua app?

Crei un utente con un’appartenenza attiva in due org. Poi, nell’editor SQL di una copia locale o di staging, agisca come quell’utente e chieda una riga dell’altra org:

begin;
set local role authenticated;
set local request.jwt.claims = '{"sub": "<user id>", "role": "authenticated"}';

-- RLS alone: returns the org B document
select document_id from documents where document_id = '<org B document id>';

-- with the org of the request: 0 rows
select document_id from documents
where document_id = '<org B document id>'
  and organization_id = '<org A id>';
rollback;

Lo ripeta con un update, e ancora dopo aver reso inattiva la seconda org. Se la prima query restituisce una riga, guardi ogni punto che interroga i dati dei tenant solo per id.

Quanto è grave?

Lo dimensioni onestamente. L’attaccante ha bisogno di un’appartenenza reale in entrambe le org, quindi si tratta di integrità e tracce di audit: chi ha accesso a due clienti modifica quello sbagliato, e il log dice che era consentito. Non è un modo per uno sconosciuto di leggere tutti i tenant.

La lezione generale è quella di come testo la Supabase RLS: si testi ciò che un utente reale può fare, con dati reali, non solo ciò che dice ogni policy. Tre note collegate: un ruolo preso dai metadati di registrazione, un NULL che ha saltato un controllo dei permessi e quali schemi serve la Data API. Il test delle due aziende è il primo punto della mia checklist di sicurezza per le app Lovable. Per policy, grant e impostazioni intorno, il Controllo sicurezza Supabase gratuito esegue 46 regole OWASP Top 10:2025; se una policy lascia passare un utente di due org è una questione di forma dei dati a cui risponde solo la query qui sopra.

© 2026 Denys Zaichenko. Articolo originale: https://madsalt.tech/it/notes/supabase-rls-user-in-two-organizations/