TL;DR
Politika typu auth_in_org(organization_id) se ptá „je volající členem organizace, které řádek patří?“. Pro uživatele z organizace A i B je odpověď v obou případech ano. V mé sondě mělo samotné RLS takového uživatele čtení i zápis k dokumentu druhé organizace. Po přidání organizace aktuálního požadavku do dotazu se vrátilo 0 řádků.
Narazil jsem na to při zpevňování platformy pro stavební dokumentaci, multi-tenant aplikace na Supabase. Každá tenantní tabulka měla zapnuté a vynucené row level security, takže to vypadalo hotově.
Co vypadalo bezpečně?
Každá politika měla stejný tvar: organization_id řádku musí být organizace, jejímž členem volající je. Proto několik serverových akcí vyhledávalo dokument jen podle id. Úvaha zněla „RLS to stejně vyfiltruje“.
const { data } = await supabase
.from('documents')
.select('*')
.eq('document_id', id)
.single()
U uživatele s jedním členstvím to funguje a test s takovým uživatelem projde.
Proč RLS pustí druhou organizaci?
Politika je test členství. Postgres ji vyhodnocuje pro každý řádek, jak popisuje dokumentace row security, a neví, za kterou organizaci požadavek jedná, protože to žije v aplikaci: v URL, cookie nebo přepínači organizací. U uživatele, který je členem obou organizací, projdou všechny řádky obou.
Filtr organization_id v aplikaci tedy není druhá vrstva nad RLS. Pro tohoto uživatele je to jediná kontrola.
Když už jste v těch helperech, podívejte se, jestli kontrolují organizations.is_active. Helpery pro členství to často nedělají a pak deaktivovaná organizace na vrstvě RLS dál umožňuje čtení i zápis. Pokud jediná kontrola is_active žije v tenant resolveru aplikace, každý dotaz, který resolver obejde, obejde i deaktivaci.
Co to opravilo?
Organizace požadavku se stala povinným argumentem každé servisní funkce, která čte nebo zapisuje tenantní data. Volající na ni nemůže zapomenout, protože funkce bez ní nejde přeložit.
export async function getDocument(orgId: string, id: string) {
return supabase
.from('documents')
.select('*')
.eq('organization_id', orgId)
.eq('document_id', id)
.single()
}
Z revize vyšla ještě dvě pravidla:
- Nikdy neberte organizaci z řádku, který se chystáte zapsat.
update ... where id = $1 and organization_id = <organization_id téhož řádku>platí vždy, takže nehlídá nic. - Složený cizí klíč na
(id, organization_id)donutí databázi odmítnout vazbu mezi řádky různých organizací. Zkontrolujte každou podřízenou tabulku, snadno ho přidáte ke třem a čtvrtou zapomenete.
Jak zkontrolovat vlastní aplikaci?
Vytvořte jednoho uživatele s aktivním členstvím ve dvou organizacích. Pak v SQL editoru lokální nebo stagingové kopie jednejte jako on a vyžádejte si řádek druhé organizace:
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;
Zopakujte to s update a ještě jednou po nastavení druhé organizace jako neaktivní. Pokud první dotaz vrátí řádek, projděte všechna místa, která dotazují tenantní data jen podle id.
Jak je to vážné?
Posuďte to poctivě. Útočník potřebuje skutečné členství v obou organizacích, takže jde o integritu a auditní stopy: někdo s přístupem ke dvěma klientům upraví toho špatného a log říká, že to bylo povolené. Není to způsob, jak by cizí člověk četl všechny tenanty.
Obecné ponaučení je to z článku jak testuji Supabase RLS: testujte, co skutečný uživatel může, se skutečnými tvary dat, nikoli jen to, co říká každá politika. Tři související poznámky: role převzatá z metadat při registraci, NULL, které přeskočilo kontrolu oprávnění a která schémata Data API nabízí. Test dvou firem je první kontrola mého checklistu zabezpečení aplikace z Lovable. Pro politiky, granty a nastavení kolem nich spustí bezplatná Kontrola zabezpečení Supabase 46 pravidel OWASP Top 10:2025; zda politika pustí uživatele ze dvou org, je otázka tvaru dat, na kterou odpoví jen dotaz výše.