---
title: "Supabase multi-tenant RLS: únik dat u uživatele ve dvou org"
description: "Politika RLS kontrolující členství v organizaci platí v obou organizacích pro uživatele, který patří do dvou. Co našla sonda, oprava a dvouminutová kontrola."
url: "https://madsalt.tech/cs/notes/supabase-rls-user-in-two-organizations/"
author: "Denys Zaichenko"
language: "cs"
published: 2026-10-18
---

# Supabase multi-tenant RLS: únik dat u uživatele ve dvou org

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](https://madsalt.tech/cs/work/construction-document-platform/), 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“.

```ts
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](https://www.postgresql.org/docs/current/ddl-rowsecurity.html), 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.

![Do které org požadavek patří, v politice není. Jeden uživatel, člen org A i org B, pracuje v org A. Samotná politika RLS: Požadavek v org A → Člen org řádku? → Ano, A i B → Vrácen řádek org B; RLS plus org tohoto požadavku: Požadavek v org A → Člen org řádku? → Řádek v org A? → 0 řádků z org B.](https://madsalt.tech/charts/cs/supabase-rls-user-in-two-organizations.svg)

*Moje sonda na založeném uživateli ve dvou org, září 2026: samotné RLS dalo čtení i zápis k dokumentu org B; po přidání org požadavku se vrátilo 0 řádků.*

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.

```ts
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:

```sql
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](https://madsalt.tech/cs/blog/how-to-test-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](https://madsalt.tech/cs/notes/supabase-raw-user-meta-data-role/), [NULL, které přeskočilo kontrolu oprávnění](https://madsalt.tech/cs/notes/plpgsql-if-not-null-fails-open/) a [která schémata Data API nabízí](https://madsalt.tech/cs/notes/supabase-exposed-schemas/). Test dvou firem je první kontrola mého [checklistu zabezpečení aplikace z Lovable](https://madsalt.tech/cs/blog/lovable-app-security-checklist/). Pro politiky, granty a nastavení kolem nich spustí bezplatná [Kontrola zabezpečení Supabase](https://madsalt.tech/cs/tools/supabase-security-check/) 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.
