Supabase RLS ile Multi-Tenant Yetkilendirme: Doğru ve Hızlı Politikalar

Tenant'ı kullanıcı seçmez
Özet (TL;DR)
- Tenant kimliğini kullanıcının yazamadığı bir yerden okuyun: üyelik tablosu ya da sunucunun yazdığı
app_metadata.user_metadataistemciden değiştirilebilir. - JWT'ye yazılan tenant listesi token yenilenene kadar eski kalır. Supabase'te varsayılan süre bir saat, yani tenant'tan çıkarılan biri bir saate kadar okumaya devam edebilir.
- Politikadaki fonksiyonu
(select ...)ile sarın, tenant listesini= any(array(select ...))biçiminde verin vetenant_id'ye indeks koyun. Bu denemede aynı sorgu 1.341 ms'den 0,094 ms'ye indi. - View'lar varsayılan olarak sahibinin yetkisiyle çalışır ve RLS'i atlar. Postgres 15 ve sonrasında
security_invoker = truebunu düzeltir ama sütun gizlemez. - INSERT politikasında
with checkyazmamak kapıyı kapalı bırakır. Açığı yaratan, tenant'ı denetlemeyen birwith check'tir. Bunu ancak rol değiştiren bir test yakalar.
Aynı kullanıcı, aynı oturum, aynı tablo. Biri 1.000 satır sayıyor, öteki 500.000:
-- authenticated rolü, iki tenant'a üye bir kullanıcının JWT'siyleselect count(*) as tablo from public.contracts;-- tablo-- --------- 1000 select count(*) as view from public.contracts_ozet;-- view-- ---------- 500000İkinci sorgu, sözleşme tablosunun üstüne kurulmuş sıradan bir view'dan okuyor. Tabloda RLS açık, politika doğru, kullanıcı yalnızca üyesi olduğu iki tenant'ın satırlarını görmeli. View ise politikaya hiç sormuyor, çünkü Postgres onu sahibinin yetkisiyle çalıştırıyor ve sahibi postgres.
Supabase'te multi-tenant bir uygulamada, yani birçok müşterinin (tenant) verisinin aynı tablolarda durduğu bir yapıda, yetkilendirme hataları çoğunlukla böyle sessiz olur. Hata mesajı çıkmaz, log temiz kalır, yalnızca fazladan veri döner. Tersi de var: politika doğrudur ama her satırda bir fonksiyon çağırdığı için liste sayfası bir saniyeyi geçer.
Novadesk CRM'deki rol ayrımı tam bu türden bir problem. Admin her şeyi görür, muhasebeci yalnızca yönettiği şirketleri, sekreter de kişileri ve rezervasyonları yönetir ama finans alanlarını görmez. Böyle bir ayrımı arayüze bırakmak yetmez. Kural veritabanında durmalı ki arayüz atlansa bile geçerli olsun. Satırın kime ait olduğunu sorguyu kimin yaptığıyla karşılaştırmak, tenant izolasyonuyla aynı sorudur.
Aşağıdaki örneklerin hepsi Docker'daki düz bir Postgres 17'de çalıştı, çıktılar oradan.
Tenant kimliği nereden gelir
Bir RLS politikası iki değeri karşılaştırır: satırdaki tenant_id ile isteği yapanın hangi tenant'lara ait olduğu. Birincisi tabloda durur. İkincisi için Supabase'te üç kaynak var ve aralarındaki fark doğrudan güvenlik farkı.
| Kaynak | Kim değiştirebilir | Ne zaman güncellenir | Politikadaki maliyet |
|---|---|---|---|
user_metadata (raw_user_meta_data) | Kullanıcının kendisi, supabase.auth.updateUser() ile | Kullanıcı istediğinde | Düşük ama değer güvenilmez |
app_metadata ya da hook ile eklenen claim | Yalnızca sunucu (Admin API, hook) | Token yeniden verildiğinde | JSON okuma, tablo yok |
| Üyelik tablosu | Politikalarınızın izin verdiği kadar | Anında | Sorgu başına bir indeks araması |
İlk satırı hemen eleyin. user_metadata kayıt formundaki ad soyad gibi bilgiler için var ve oturum açmış kullanıcı onu istemciden değiştirebilir. Politikaya auth.jwt() -> 'user_metadata' ->> 'tenant_id' yazmak, kullanıcıya hangi tenant'ın verisini göreceğini seçme hakkı vermektir. Supabase belgeleri bu konuda açıkça uyarıyor, Database Advisor'da da bunu yakalayan ayrı bir kural var (rls_references_user_metadata).
Custom access token hook ile claim eklemek
Custom access token hook, Supabase Auth'un bir access token imzalamadan hemen önce çağırdığı bir Postgres fonksiyonu. Girişte de token yenilemede de çalışır. Fonksiyon claims nesnesini alır, değiştirip geri verir. Aşağıdaki sürüm kullanıcının üyeliklerini app_metadata.tenant_ids dizisine yazıyor:
create or replace function public.custom_access_token_hook(event jsonb)returns jsonblanguage plpgsqlstableset search_path = ''as $$declare claims jsonb := event -> 'claims'; tenant_ids jsonb;begin select coalesce(jsonb_agg(m.tenant_id order by m.tenant_id), '[]'::jsonb) into tenant_ids from public.memberships m where m.user_id = (event ->> 'user_id')::uuid; if jsonb_typeof(claims -> 'app_metadata') is null then claims := jsonb_set(claims, '{app_metadata}', '{}'); end if; claims := jsonb_set(claims, '{app_metadata,tenant_ids}', tenant_ids); return jsonb_set(event, '{claims}', claims);end;$$; grant usage on schema public to supabase_auth_admin;grant execute on function public.custom_access_token_hook to supabase_auth_admin;revoke execute on function public.custom_access_token_hook from authenticated, anon, public;grant select on public.memberships to supabase_auth_admin;create policy memberships_auth_admin on public.memberships for select to supabase_auth_admin using (true);Hook supabase_auth_admin rolüyle çalıştığı için bu role üyelik tablosunu okuma izni ve ona özel bir politika gerekiyor. authenticated ve anon rollerinden execute yetkisini geri almayı atlamayın. Panelde Authentication altındaki Hooks bölümünden fonksiyonu seçince devreye girer. Fonksiyonu elle, Auth sunucusunun göndereceği türden bir olayla çağırınca dönen claim'ler şöyle:
{ "sub": "11111111-0000-0000-0000-000000000001", "role": "authenticated", "app_metadata": { "provider": "email", "tenant_ids": [ "00000000-0000-0000-0000-000000000001", "00000000-0000-0000-0000-000000000002" ] }}Gerçek olayda Auth'un zorunlu saydığı claim'lerin hepsi (iss, aud, exp, iat, sub, role, aal, session_id, email, phone, is_anonymous) zaten gelir. Hook bunlardan birini silerse token verilmez, bu yüzden yalnızca ekleme yapın. Ortaya çıkan token'ı JWT çözücüde açıp claim'leri görebilirsiniz. İmzayı doğrulamadan çözmenin neden yetkilendirme sayılmadığını JWT doğrulama hataları yazısında anlatmıştım.
Token yenilenene kadar eski kalır
JWT'ye yazılan her şey imzalandığı anın fotoğrafıdır. Kullanıcıyı bir tenant'tan çıkardığınızda token'daki dizi değişmez. Aşağıda aynı token'la, üyelik silindikten sonra iki ayrı politika deneniyor:
begin;drop policy contracts_select on public.contracts;create policy contracts_select on public.contracts for select to authenticated using (tenant_id = any (array( select jsonb_array_elements_text(auth.jwt() -> 'app_metadata' -> 'tenant_ids')::uuid )));-- :'jwt' iki tenant içeren app_metadata.tenant_ids taşıyan claim'lerselect set_config('request.jwt.claims', :'jwt', true) \gset set local role authenticated;select count(*) as once from public.contracts; -- 1000reset role; -- kullanıcı 2 numaralı tenant'tan çıkarıldı, token henüz yenilenmedidelete from public.memberships where tenant_id = '00000000-0000-0000-0000-000000000002' and user_id = '11111111-0000-0000-0000-000000000001'; set local role authenticated;select count(*) as jwt_ile from public.contracts; -- 1000reset role; drop policy contracts_select on public.contracts;create policy contracts_select on public.contracts for select to authenticated using (tenant_id = any (array(select private.my_tenant_ids())));set local role authenticated;select count(*) as tablo_ile from public.contracts; -- 500rollback;JWT'ye bakan politika, silinen tenant'ın 500 satırını göstermeye devam ediyor. Bu gecikmenin üst sınırı access token'ın ömrü. Supabase'te varsayılan bir saat ve belgeler beş dakikanın altına inmemeyi öneriyor, çünkü yenileme yükü artıyor ve saat kayması kaynaklı hatalar başlıyor. Rolü değişen kullanıcı refreshSession() çağırırsa yeni claim'leri hemen alır, ama tenant'tan çıkarılan kullanıcının bunu yapmak için hiçbir nedeni yok.
Ben şöyle ayırıyorum. Yetkiyi daraltan bir değişiklik (tenant'tan çıkarma, rol düşürme) anında geçerli olmalıysa kaynak tablo olur. JWT claim'i menüde hangi tenant'ların listeleneceğini göstermek ya da seçili tenant'ı hatırlamak için yeterli. Sözleşme tutarı ve TC kimlik numarası tutan bir CRM'de bir saatlik pencere kabul edilecek bir risk değil.
Politika satır başına mı, sorgu başına mı çalışıyor
Tabloyu kaynak seçince ilk itiraz performanstan gelir: her sorguda üyelik tablosuna gitmek pahalı değil mi? Doğru yazılırsa değil. Bunu görmek için Supabase'in kendisine gerek yok. Docker'da düz bir postgres:17 konteyneri, birkaç rol ve auth şemasının küçük bir taklidi yetiyor (Docker temelleri yazısındaki komutlar burada da geçerli):
create role anon nologin;create role authenticated nologin;create role service_role nologin bypassrls;create role supabase_auth_admin nologin; create schema auth;create table auth.users ( id uuid primary key, raw_app_meta_data jsonb not null default '{}', raw_user_meta_data jsonb not null default '{}'); -- supabase/auth deposundaki tanımın aynısıcreate function auth.uid() returns uuid language sql stable as $$ select nullif( coalesce( current_setting('request.jwt.claim.sub', true), (current_setting('request.jwt.claims', true)::jsonb ->> 'sub') ), '' )::uuid$$; create function auth.jwt() returns jsonb language sql stable as $$ select coalesce( nullif(current_setting('request.jwt.claim', true), ''), nullif(current_setting('request.jwt.claims', true), '') )::jsonb$$; grant usage on schema auth to anon, authenticated, service_role;auth.uid() ve auth.jwt() tanımları supabase/auth deposundaki migration'ların aynısı. PostgREST her istekte rolü authenticated yapar ve JWT claim'lerini request.jwt.claims ayarına yazar. Testte aynısını bir transaction içinde elle yapıyoruz:
begin;set local role authenticated;set local request.jwt.claims to '{"sub":"11111111-0000-0000-0000-000000000001","role":"authenticated"}';select count(*) from public.contracts;rollback;Veri seti 1.000 tenant, 5.000 kullanıcı ve 500.000 sözleşmeden oluşuyor, test kullanıcısı da iki tenant'a üye olduğu için 1.000 satır görmeli. Süreler explain analyze çıktısındaki Execution Time değerleri, her sorgu ikinci kez çalıştırılarak alındı.
auth.uid() her satırda yeniden hesaplanıyor
Tenant'ı bir kenara bırakıp önce en basit politikaya bakın: created_by = auth.uid(). created_by sütununda indeks yok. Kısaltılmış planlar:
-- politika: created_by = auth.uid() Aggregate (actual time=221.639..221.639 rows=1 loops=1) -> Seq Scan on contracts (actual time=1.508..221.620 rows=100 loops=1) Filter: (created_by = (NULLIF(COALESCE(current_setting('request.jwt.claim.sub'::text, true), ((current_setting('request.jwt.claims'::text, true))::jsonb ->> 'sub'::text)), ''::text))::uuid) Rows Removed by Filter: 499900 Execution Time: 221.654 ms -- politika: created_by = (select auth.uid()) Aggregate (actual time=16.589..16.590 rows=1 loops=1) InitPlan 1 -> Result (actual time=0.012..0.012 rows=1 loops=1) -> Seq Scan on contracts (actual time=0.224..16.574 rows=100 loops=1) Filter: (created_by = (InitPlan 1).col1) Rows Removed by Filter: 499900 Execution Time: 16.622 msauth.uid() basit bir SQL fonksiyonu olduğundan planlayıcı onu inline ediyor, yani gövdesini politikanın içine yerleştiriyor. Sonuçta 500.000 satırın her birinde current_setting() çağrılıp JSON baştan parse ediliyor. Parantez içindeki select ise bir InitPlan'a dönüşüyor. Değer sorgu başında bir kez hesaplanıyor, filtre tek bir karşılaştırmaya iniyor ve süre 221 ms'den 16,6 ms'ye düşüyor. Bu numara yalnızca sonucu satıra bağlı olmayan ifadelerde işe yarar: auth.uid(), auth.jwt() ve parametre almayan yardımcı fonksiyonlar.
Üyelik kontrolünün dört yazımı
Asıl politika tenant kontrolü. Üyelik sorgusunu private adlı, Data API'nin erişimine açık olmayan bir şemadaki iki fonksiyona taşıdım:
create schema private;grant usage on schema private to authenticated; -- kullanıcı yalnızca kendi üyelik satırlarını görürcreate policy memberships_select on public.memberships for select to authenticated using (user_id = (select auth.uid())); -- satır başına çağrılan üyelik kontrolücreate function private.is_member(t uuid) returns booleanlanguage plpgsql stable security definer set search_path = '' as $$begin return exists ( select 1 from public.memberships m where m.tenant_id = t and m.user_id = auth.uid() );end $$; -- kullanıcının bütün tenant'ları, sorgu başına bir kezcreate function private.my_tenant_ids() returns setof uuidlanguage sql stable security definer set search_path = '' as $$ select m.tenant_id from public.memberships m where m.user_id = auth.uid()$$; revoke execute on all functions in schema private from public;grant execute on all functions in schema private to authenticated;Aynı select count(*) from public.contracts sorgusu, dört ayrı politikayla:
| Politika ifadesi | İndeks yok | tenant_id indeksli | Plandaki biçim |
|---|---|---|---|
private.is_member(tenant_id) | 1.337 ms | 1.341 ms | Satır başına fonksiyon çağrısı |
tenant_id in (select tenant_id from public.memberships where user_id = auth.uid()) | 22,3 ms | 16,9 ms | hashed SubPlan, filtre |
tenant_id in (select private.my_tenant_ids()) | 18,9 ms | 16,5 ms | hashed SubPlan, filtre |
tenant_id = any (array(select private.my_tenant_ids())) | 14,5 ms | 0,094 ms | InitPlan, Index Cond |
İki sonuç dikkat istiyor. İlki satır başına çağrılan fonksiyon: is_member(tenant_id) doğru cevap veriyor ama 500.000 kez çalışıyor ve indeks hiçbir şeyi kurtarmıyor. İkincisi daha şaşırtıcı. tenant_id in (select ...) indeks eklendiğinde bile 16 ms civarında kalıyor. Nedeni planda:
-- politika: tenant_id in (select private.my_tenant_ids()) Aggregate (actual time=16.540..16.540 rows=1 loops=1) -> Index Only Scan using contracts_tenant_idx on contracts (actual time=0.043..16.517 rows=1000 loops=1) Filter: (ANY (tenant_id = (hashed SubPlan 1).col1)) Rows Removed by Filter: 499000 Heap Fetches: 0 SubPlan 1 -> ProjectSet (actual time=0.037..0.039 rows=2 loops=1) -> Result (actual time=0.000..0.000 rows=1 loops=1) Execution Time: 16.547 ms -- politika: tenant_id = any (array(select private.my_tenant_ids())) Aggregate (actual time=0.091..0.091 rows=1 loops=1) InitPlan 1 -> ProjectSet (actual time=0.026..0.027 rows=2 loops=1) -> Result (actual time=0.000..0.000 rows=1 loops=1) -> Index Only Scan using contracts_tenant_idx on contracts (actual time=0.033..0.068 rows=1000 loops=1) Index Cond: (tenant_id = ANY ((InitPlan 1).col1)) Heap Fetches: 0 Execution Time: 0.094 msIN alt sorgusu politikanın içinde hashed SubPlan olarak kalıyor. Postgres tenant listesinden bir hash tablosu kuruyor, sonra indeksin 500.000 girdisinin hepsini okuyup her birini o tabloda arıyor. Aynı alt sorguyu politikaya değil doğrudan WHERE içine yazdığımda planlayıcı onu semi-join'e çevirdi ve indeksle 0,2 ms'de bitirdi. Politikadaki alt sorguda bu dönüşüm olmadı. = any(array(select ...)) biçimi ise listeyi InitPlan'da bir diziye topluyor ve diziyi Index Cond olarak indekse veriyor. 1.000 satırı bulmak için 1.000 satır okunuyor.
Supabase'in RLS performans önerileri de aynı yeri gösteriyor: politika sütunlarına indeks, fonksiyonları select ile sarmak ve dizi döndüren fonksiyonlar için team_id = any(array(select user_teams())) kalıbı. Database Advisor sarılmamış auth.uid() çağrılarını auth_rls_initplan kuralıyla işaretliyor.
security definer ve search_path
Üyelik sorgusunu neden bir fonksiyona taşıdık? Çünkü üyelik tablosunun politikası kendi tablosuna bakan bir alt sorguyla yazılırsa Postgres bunu sonsuz döngü sayıyor:
create policy memberships_select_kotu on public.memberships for select to authenticated using (tenant_id in (select tenant_id from public.memberships where user_id = auth.uid())); -- authenticated rolüyle:select count(*) from public.memberships;-- ERROR: infinite recursion detected in policy for relation "memberships"security definer bir fonksiyon, sahibinin yetkisiyle çalıştığı için üyelik tablosunu RLS'e takılmadan okur ve döngü kırılır. Bedeli şu: fonksiyonun içindeki nitelenmemiş tablo adları çağıranın search_path'ine göre çözülür ve Postgres geçici tabloların şemasına (pg_temp) başka her şeyden önce bakar. search_path sabitlenmeden yazılmış aynı fonksiyonla:
-- search_path sabitlenmemiş, tablo adı nitelenmemişcreate function private.my_tenant_ids_kotu() returns setof uuidlanguage sql stable security definer as $$ select tenant_id from memberships where user_id = auth.uid()$$;grant execute on function private.my_tenant_ids_kotu() to authenticated; begin;drop policy contracts_select on public.contracts;create policy contracts_select on public.contracts for select to authenticated using (tenant_id = any (array(select private.my_tenant_ids_kotu())));set local role authenticated;set local request.jwt.claims to '{"sub":"11111111-0000-0000-0000-000000000001","role":"authenticated"}';select count(*) as once from public.contracts; -- 1000 create temp table memberships (tenant_id uuid, user_id uuid);insert into memberships select ('00000000-0000-0000-0000-' || lpad(g::text, 12, '0'))::uuid, auth.uid() from generate_series(1, 1000) g;select count(*) as sonra from public.contracts; -- 500000rollback;Kullanıcı kendi oturumunda memberships adında geçici bir tablo açıp bin tenant'ın kimliğiyle doldurunca fonksiyon gerçeği yerine bu tabloyu okudu ve politika bütün sözleşmeleri açtı. PostgREST üzerinden gelen bir istek tablo oluşturamaz, yani bu Data API'den yapılabilecek bir saldırı değil. Ama veritabanına SQL ile bağlanabilen her rol için açık bir kapı. Çaresi iki adım: set search_path = '' ve her nesneyi şemasıyla yazmak (public.memberships). Advisor bunu function_search_path_mutable kuralıyla yakalıyor. Fonksiyonu Data API'nin erişimine açık bir şemaya koymamak da şart, yoksa herkes onu RPC olarak çağırabilir.
RLS'i sessizce aşan yollar
service_role ve secret anahtarlar
service_role rolü BYPASSRLS özniteliğiyle gelir. Eski JWT tabanlı service_role anahtarı da yeni sb_secret_... anahtarları da bu role bağlanır ve hiçbir politikaya bakmaz. Denemedeki aynı sayım bu rolle 500.000 döndü. Yeni secret anahtarın küçük bir ek koruması var: tarayıcıdan gelen istekleri User-Agent başlığına bakarak 401 ile reddediyor. Sunucu kodundaki en sık hata ise kullanıcı adına yapılan bir isteği kolaylık olsun diye bu anahtarla atmak. Kullanıcı adına yapılan her istek kullanıcının JWT'siyle gitmeli.
Ters yönde bir sürpriz de var. Servis anahtarıyla oluşturulmuş bir istemcide bir kullanıcı oturum açarsa Supabase o kullanıcının politikalarını uygular. Yönetim işleri için ayrı, hiç oturum açılmayan bir istemci tutun.
security_invoker olmayan view'lar
Girişteki fark buradan geliyordu. Postgres'te bir view taban tabloya varsayılan olarak sahibinin yetkileriyle erişir. Supabase'te view'ları çoğunlukla postgres oluşturur, tablonun sahibi de odur ve sahip RLS'ten muaf olduğu için view politikasız çalışır. Postgres 15'ten beri bunu tek bir seçenek düzeltiyor:
alter view public.contracts_ozet set (security_invoker = true); -- authenticated rolüyle, aynı kullanıcı:select count(*) as view_invoker from public.contracts_ozet;-- view_invoker-- ---------------- 1000Bir ayrıntıyı kaçırmayın: security_invoker satır güvenliğini geri getirir, sütun gizlemez. Invoker view'ı sorgulayan kullanıcının taban tabloda da yetkisi olmalı, yani o kullanıcı tabloyu doğrudan da okuyabilir. Supabase'te oturum açmış herkes aynı authenticated Postgres rolünü kullandığı için sütun düzeyindeki GRANT'ler sekreteri muhasebeciden ayıramaz. Finans sütunlarını bir uygulama rolünden gizlemek istiyorsanız ben o sütunları ayrı bir tabloya alıp o tabloya üyelikteki rolü denetleyen ayrı bir politika yazmayı tercih ederim. Advisor, security_invoker olmayan view'ları security_definer_view kuralıyla listeliyor.
Tenant'ı denetlemeyen with check
Okuma politikası doğru olsa bile yazma ayrı bir kapıdır. INSERT politikası yalnızca with check alır ve Postgres eklenen her satırı bu ifadeyle sınar. İfadeyi hiç yazmazsanız kapı kapalı kalır, denemede with check içermeyen bir INSERT politikası her eklemeyi reddetti. Tehlike, ifadeyi yazıp tenant'ı denetlememekte:
-- 1) yalnızca giriş yapılmış mı diye bakan politikacreate policy contracts_insert on public.contracts for insert to authenticated with check ((select auth.uid()) is not null); insert into public.contracts (tenant_id, company, amount)values ('00000000-0000-0000-0000-000000000999', 'Başkasının kiracısı', 1);-- INSERT 0 1 -- 2) tenant'ı denetleyen politikadrop policy contracts_insert on public.contracts;create policy contracts_insert on public.contracts for insert to authenticated with check (tenant_id = any (array(select private.my_tenant_ids()))); insert into public.contracts (tenant_id, company, amount)values ('00000000-0000-0000-0000-000000000999', 'Başkasının kiracısı', 1);-- ERROR: new row violates row-level security policy for table "contracts"İlk politika yalnızca giriş yapılıp yapılmadığına baktığı için kullanıcı başka bir tenant'a kayıt ekleyebiliyor, ikincisi ise okuma politikasıyla aynı tenant listesini kullanıyor ve satırı reddediyor. UPDATE'te with check yazılmazsa using ifadesi yeni satıra da uygulanır, bu yüzden bir satırı başka tenant'a taşımak engellenir. with check (true) yazarsanız bu koruma gider.
Bir tuzak daha var ve çoğu zaman tenant oluştururken ortaya çıkar. insert ... returning, supabase-js'te de .insert().select(), eklenen satırın SELECT politikasından da geçmesini ister. Yeni tenant'ın henüz üyesi olmadığınız için:
create policy tenants_insert on public.tenants for insert to authenticated with check (true);create policy tenants_select on public.tenants for select to authenticated using (id = any (array(select private.my_tenant_ids()))); insert into public.tenants (name) values ('Yeni Ofis');-- INSERT 0 1 insert into public.tenants (name) values ('Yeni Ofis') returning id;-- ERROR: new row violates row-level security policy for table "tenants"Kayıt reddedilmedi, döndürülemedi. Postgres bunu sessizce yutmak yerine hata veriyor. Çözüm, üyelik tablosuna "kullanıcı kendini ekleyebilir" diyen bir politika yazmak değil, çünkü o politika herkesin her tenant'a katılmasına izin verir. Tenant'ı ve kurucunun admin üyeliğini aynı transaction'da yazan tek bir fonksiyon daha güvenli.
Storage politikaları
Supabase Storage dosya erişimini storage.objects tablosundaki RLS politikalarıyla yönetir ve politika yoksa hiçbir bucket'a yükleme yapılamaz. Multi-tenant bir yapıda en temiz yol, dosya yolunun ilk klasörünü tenant kimliği yapmak ve politikada o klasörü storage.foldername() ile okumak. Tabloyu ve fonksiyonu Supabase'in tanımına benzer biçimde taklit edip aynı politikaları denedim:
create policy contracts_files_select on storage.objects for select to authenticated using ( bucket_id = 'contracts' and (storage.foldername(name))[1] = any (array(select private.my_tenant_ids()::text)) );create policy contracts_files_insert on storage.objects for insert to authenticated with check ( bucket_id = 'contracts' and (storage.foldername(name))[1] = any (array(select private.my_tenant_ids()::text)) ); -- iki dosya var: biri kullanıcının tenant'ında, biri 3 numaralı tenant'taselect name from storage.objects;-- name-- ------------------------------------------------------------ 00000000-0000-0000-0000-000000000001/2025/sozlesme-1.pdf insert into storage.objects (bucket_id, name)values ('contracts', '00000000-0000-0000-0000-000000000003/2025/sahte.pdf');-- ERROR: new row violates row-level security policy for table "objects"Okumada yalnızca kullanıcının tenant'ındaki dosya göründü, başka tenant'ın klasörüne yükleme reddedildi. Üzerine yazmak (upsert) için SELECT ve UPDATE politikaları da gerekir. Storage API'ye servis anahtarıyla gelen istek bu politikaların hepsini atlar. İmzalı URL ise süresi dolana kadar bağlantıyı bilen herkese açıktır. Süreyi kısa tutun.
Politikayı rol değiştirerek test etmek
Yanlış bir politika derleme hatası üretmez, o yüzden test ister. İlke yukarıdaki denemelerle aynı: transaction açın, rolü authenticated yapın, request.jwt.claims ayarına test kullanıcısının claim'lerini yazın, sorguyu çalıştırın ve sonunda rollback ile her şeyi geri alın. Supabase CLI'daki supabase test db komutu supabase/tests/database/ altındaki SQL dosyalarını pgTAP ile çalıştırıyor. Aynı dosya düz Postgres'te pgTAP eklentisiyle de koşuyor:
-- supabase/tests/database/contracts_rls.test.sqlbegin;select plan(4); set local role authenticated;set local request.jwt.claims to '{"sub":"11111111-0000-0000-0000-000000000001","role":"authenticated"}'; select is( (select count(*) from public.contracts), 1000::bigint, 'kullanıcı yalnızca iki kiracısının sözleşmelerini görür'); select is_empty( $$ select 1 from public.contracts where tenant_id = '00000000-0000-0000-0000-000000000003' $$, 'başka kiracının satırı görünmez'); select throws_ok( $$ insert into public.contracts (tenant_id, company, amount) values ('00000000-0000-0000-0000-000000000003', 'Sızma', 1) $$, '42501', 'new row violates row-level security policy for table "contracts"', 'başka kiracıya kayıt eklenemez'); select is( (select count(*) from public.contracts_ozet), 1000::bigint, 'özet view de RLS''e uyar'); select * from finish();rollback;1..4ok 1 - kullanıcı yalnızca iki kiracısının sözleşmelerini görürok 2 - başka kiracının satırı görünmezok 3 - başka kiracıya kayıt eklenemezok 4 - özet view de RLS'e uyarHer tenant tablosu için en az üç test öneririm: kendi satırlarını görmek, başka tenant'ın satırını görmemek ve başka tenant'a yazamamak. View'lar ve Storage politikaları da aynı listeye girer. Kod incelemesinde gözden kaçan bir security_invoker eksikliği dördüncü testte hemen kırmızı yanar. Gerçek bir Supabase projesinde testi bir de supabase_auth_admin rolüyle hook için yazmak, hook'un yanlış tenant listesi üretmesini erkenden yakalar.
Sonuç
Bir politikayı birleştirmeden önce iki soru sorun. Karşılaştırdığınız değeri kullanıcı yazabiliyor mu? Yazabiliyorsa elinizde bir politika değil, bir rica var. İfade satır başına mı çalışıyor? Öyleyse tablo büyüdükçe sorun da büyüyecek. Birinci sorunun cevabı üyelik tablosu ya da sunucunun yazdığı app_metadata, ikincisininki = any(array(select ...)) ve bir indeks. View'lar, Storage ve servis anahtarı aynı iki kuralın başka kapılardan sınanması. Onları kod incelemesi değil, rolü değiştirip sayıya bakan bir test yakalar.
Sıkça Sorulan Sorular
- Her tenant'a ayrı şema ya da ayrı bir Supabase projesi açmak daha güvenli değil mi?
- İzolasyon açısından daha güçlü, ama bedeli tenant sayısıyla büyür: her şema ya da proje için ayrı migration, ayrı izleme ve ayrı bağlantı. Aulbach ve arkadaşlarının 2008 SIGMOD çalışması, tenant başına ayrı tablo tutan yaklaşımda ölçeklenmenin asıl sınırının veritabanının taşıyabildiği tablo sayısı olduğunu, iyi bir konsolidasyon için bazı tabloların tenant'lar arasında paylaşılması gerektiğini gösteriyor. Ben paylaşılan tablo artı RLS ile başlar, sözleşmesinde fiziksel izolasyon isteyen bir müşteri için ayrı proje açarım.
- Kullanıcı birden fazla tenant'a üyeyse seçili tenant'ı nerede tutmalıyım?
- Politikayı kullanıcının bütün üyeliklerini kapsayacak şekilde bırakın, seçili tenant'ı ise sorguya filtre olarak ekleyin (
.eq('tenant_id', secili)). Tenant değiştirmek token yenilemeyi gerektirmez ve filtre planlayıcıya da yardım eder. Bu denemede yavaşINpolitikası bilewhere tenant_id = ...eklenince indeksle 0,086 ms'de bitti. Seçili tenant'ı JWT'ye yazarsanız onu yalnızca bir ipucu sayın, politika yine üyeliği denetlemeli. - Politikaya `to authenticated` yazmak anonim kullanıcıları dışarıda bırakır mı?
anonrolüyle gelen istekleri bırakır. Bu rol için uygun politika olmadığında Postgres ifadeyi hiç değerlendirmeden satırı reddeder, bu da hem güvenli hem ucuzdur. Ama Supabase Auth'taki anonim girişlerauthenticatedrolünü kullanır ve JWT'deis_anonymousclaim'i taşır. Anonim oturumların bir tabloya erişmemesi gerekiyorsa bunu(select auth.jwt() ->> 'is_anonymous')::boolean is not truegibi bir koşulla ayrıca yazmanız gerekir.- Aynı tabloya birden fazla politika yazmak performansı etkiler mi?
- Etkiler. Aynı rol ve komut için yazılmış permissive politikalar OR ile birleşir ve her biri değerlendirilir, Advisor bunu
multiple_permissive_policieskuralıyla uyarıyor. Mümkünse tek politikada birleştirin. Her durumda geçmesi gereken bir şart varsa (örneğin MFA içinaal2) onuas restrictiveile ayrı yazın. Restrictive politikalar AND ile bağlanır ve permissive politikaların genişlettiği alanı daraltır.
Kaynakça
- Aulbach, S., Grust, T., Jacobs, D., Kemper, A., ve Rittinger, J. (2008). Multi-tenant databases for software as a service: Schema-mapping techniques. Proceedings of the 2008 ACM SIGMOD International Conference on Management of Data içinde (ss. 1195–1206). ACM. https://doi.org/10.1145/1376616.1376736
- OWASP Foundation. (t.y.). Authorization cheat sheet. OWASP Cheat Sheet Series. https://cheatsheetseries.owasp.org/cheatsheets/Authorization_Cheat_Sheet.html
- PostgreSQL Global Development Group. (t.y.). Row security policies (PostgreSQL 17 dokümantasyonu, Bölüm 5.9). https://www.postgresql.org/docs/17/ddl-rowsecurity.html
- Supabase. (t.y.-a). Custom access token hook. Supabase Docs. https://supabase.com/docs/guides/auth/auth-hooks/custom-access-token-hook
- Supabase. (t.y.-b). Row Level Security. Supabase Docs. https://supabase.com/docs/guides/database/postgres/row-level-security

Mustafa Kürşad Başer
Kıdemli Yazılım Mühendisi
Karmaşık sorunlara zarif çözümler üretmekten keyif alan, tutkulu bir yazılım mühendisi. Kodlamanın ötesinde, teknoloji, sanat ve insan bilincinin kesişim noktalarını keşfetmekle derinden ilgileniyor.

