Backend güvenliği
Supabase RLS politikaları nasıl test edilir? 401 testten dersler
RichlyMe’nin Supabase backend’inde 49 migration, 17 Edge Function ve 38 SQL test dosyası var. Testleri nasıl kurduğumuzu ve neyi garanti ettiklerini anlatıyoruz.


KISA CEVAP
RLS politikalarını, istemcinin rolüne geçip yasaklı işlemi gerçekten deneyerek test edin. RichlyMe’de her test BEGIN … ROLLBACK içinde çalışıyor; test.as_user() JWT claim’lerini ve authenticated rolünü ayarlıyor, test.throws() beklenen SQLSTATE’i (çoğunlukla 42501) doğruluyor. 38 dosyada 401 test, 49 migration’ın tamamı uygulanmış geçici bir Postgres kümesinde koşuyor.
Testler neden Docker’sız, geçici bir Postgres kümesinde koşuyor?
RichlyMe, Expo/React Native ile yazdığımız ve henüz App Store’da olmayan bir iOS uygulaması. Backend tamamen Supabase üzerinde: 49 SQL migration, 17 Edge Function ve supabase/tests altında 38 numaralı SQL test dosyası. Bu dosyalarda -- @test ile işaretlenmiş 401 test bloğu var.
Test koşucusu supabase/tests/run.sh bir bash betiği ve Docker istemiyor. Sırası şöyle:
initdbile geçici bir dizinde, varsayılan olarak 54329 portunda boş bir küme açar;fsyncvesynchronous_commitkapalıdır._bootstrap.sqlile Supabase’in sağladığı rolleri,authşemasını vestorageşemasını taklit eder.- 49 migration’ı sırayla, ardından seed’i ve
_helpers.sql’i uygular. - Her
@testbloğunu kendiBEGIN … ROLLBACKişlemi içinde çalıştırır, PASS/FAIL basar, kümeyi kapatıp dizini siler.
SQL’den sonra dört ek kontrol gelir: puan formülünün TypeScript ile eşleştiği parity testi, Edge Function tip kontrolü, _shared altındaki 10 Deno birim test dosyası ve lint.
Aynı SQL testleri REMOTE_DB_URL verildiğinde zaten migrate edilmiş barındırılan bir veritabanında da koşabiliyor. Yardımcı fonksiyonlar her test işleminin içinde oluşturulup geri alındığı için geride hiçbir şey kalmıyor. Yani yerelde geçen test, canlı şemada da aynı şeyi doğruluyor.
Bir RLS testi pratikte nasıl yazılıyor?
Yerel Homebrew Postgres’te pgTAP yok; bu yüzden _helpers.sql küçük bir test şeması kuruyor. Fonksiyonlar bilinçli olarak az:
test.okvetest.eq: koşul tutmazsaP0099koduyla hata fırlatır.test.throws(sql, beklenen, mesaj): SQL’i o anki rolle çalıştırır; yalnızca beklenen 5 karakterlik SQLSTATE ya da mesaj kalıbıyla düşerse geçer. Hiç hata çıkmazsa test kalır.test.lives: işlemin hatasız geçtiğini doğrular.test.as_user(uid),test.as_anon(),test.as_service(),test.as_super(): rol değiştirir.
as_user, request.jwt.claim.sub, request.jwt.claims ve role ayarlarını işlem kapsamında yazıyor. Bootstrap’taki auth.uid() da bu ayarları okuyor; böylece politikalar gerçek bir PostgREST isteğindeki gibi değerlendiriliyor.
Önemli nokta şu: testler politikayı okuyup yorumlamıyor, yasaklı işlemi gerçekten deniyor. 06_scoring.sql içindeki bir test, sıradan bir üyenin public.scores üzerinde update, insert ve delete denemesinin, trust_score ya da tier sütununu değiştirmesinin ve score_proof çağırmasının her birinin 42501 ile düştüğünü tek tek kontrol ediyor. 02_proof_visibility.sql ise bir Member’ın, dosya yolunu bilse bile daha üst seviyedeki bir Patron kanıtının medya nesnesini storage.objects üzerinden çekemediğini doğruluyor.
Her test geri alındığı için testler birbirine sızmıyor ve hangi sırayla koştukları önemli değil.
Supabase’in varsayılan yetkileri nasıl kapatılıyor ve bu nasıl doğrulanıyor?
Supabase, public şemasında yeni oluşturulan her nesne için anon ve authenticated rollerine varsayılan olarak ALL yetkisi veriyor. Bootstrap dosyası bu davranışı bilerek taklit ediyor; migration’lar aynı geniş başlangıç noktasına karşı test ediliyor.
İlk migration olan 20261021120000_foundation.sql bunu en başta geri alıyor: alter default privileges in schema public revoke all on tables from anon, authenticated;. Aynı satırlar sequence’ler ve fonksiyonlar için de var; app ve private şemalarında da varsayılan execute kaldırılıyor. Sonrasında her yetki tek tek, açıkça veriliyor. Kanıt delilleri, kamera kodları ve denetim kaydı private şemasında; authenticated bu şemayı kullanamıyor bile.
01_schema.sql, tek tek politikaları değil yapısal kuralları test ediyor:
publicveprivateiçinde RLS’i kapalı tek bir tablo bile yok.anon,publicya daprivateiçindeki hiçbir tablo veya view üzerinde yetki sahibi değil.anon,publiciçinde yalnızcafounder_seats_remainingfonksiyonunu çalıştırabiliyor.public.proofstablosundalat,lng,location,exif,addressya davenuegibi bir sütun yok.- Her foreign key sütununun bir indeksi var.
Bu testler sonradan eklenen tablolar için de çalışıyor. Yeni bir migration RLS’i açmayı unutursa, kimse fark etmeden önce test kırılıyor. Migration’larda toplam 131 create policy ifadesi var; bunları tek tek hatırlamak yerine kurala bağlamak daha güvenilir.
Puanlama fonksiyonunu yalnızca service role nasıl çağırabiliyor?
Puan formülü istemcide değil, SQL’de: public.score_proof(p_proof_id uuid, p_method verification_method). Fonksiyon security definer ve set search_path = '' ile tanımlı. Definer, fonksiyonun RLS’e takılmadan scores, members ve güven olaylarını güncelleyebilmesini sağlıyor; boş search_path ise her nesnenin şemasıyla (public., app., private.) yazılmasını zorunlu kılıp arama yolu üzerinden nesne kaydırmayı engelliyor.
Definer fonksiyonun tehlikesi, yetkisi olan herkesin onu çağırabilmesi. Bu yüzden 20261026130030_a_scoring.sql iki adımlı bir yetki kalıbı uyguluyor:
revoke execute … from public, anon;ile kullanıcıya açık fonksiyonların listesinden genel yetki kaldırılıyor.score_proof,publish_delayedverefresh_standingiçin ayrıcarevoke … from authenticatedve ardındangrant … to service_roleyazılıyor.
Postgres’te fonksiyonlar varsayılan olarak PUBLIC rolüne açık oluyor. Bu nedenle yalnızca authenticated rolünden yetkiyi almak yetmiyor, public rolünden de alınması gerekiyor.
Çağıran taraf score-proof Edge Function’ı. İlk satırı requireService(req): bearer token service anahtarıyla sabit zamanlı bir karşılaştırmadan geçiyor, sonra RPC service client ile yapılıyor. Fonksiyon girdiyi doğrularken yalnızca beş yöntemden birini kabul ediyor (booking_api, c2pa_location_nonce, ai_witnessed, ai_only, rejected).
Test tarafında hem negatif hem pozitif durum var: istemciden score_proof çağrısı 42501 almalı, service role ile uçtan uca çağrı ise puanı hesaplayıp yazmalı. Ayrıca bir test, fonksiyonun imzasında para tutarı parametresi olmadığını doğruluyor; aynı karar idempotent çağrıda iki kez sayılmıyor.
Edge Functions hangi işi yapıyor, hangisini SQL’e bırakıyor?
17 Edge Function’ın çoğu ince bir kabuk: kimlik doğrulama, girdi doğrulama, tek bir RPC. İş kuralı SQL’de kalıyor, çünkü RLS ve yetki testleri de orada.
issue-nonce(14 satır): bilinçli olarak service client kullanmıyor. RPC’yi çağıranın kendi JWT’siyle yapıyor; aktif üyelik kontrolü ve hız sınırıissue_nonce()içinde, RLS’in gördüğü kullanıcıyla çalışıyor.submit-proof: kullanıcıyı doğruluyor, gövdeyi en fazla 262.144 bayt okuyor, çekim makbuzunu imza anahtarıyla kontrol ediyor. Sonra yüklenen orijinalleriproof-originalsbucket’ından kendisi indirip SHA-256 özetini kendisi hesaplıyor. İstemcinin gönderdiği özet kullanılmıyor. Kanıt kaydı tek bir SQL fonksiyonunda, atomik olarak oluşturuluyor.publish-delayed: gecikmesi dolan kanıtları yayınlandı olarak işaretleyip bildirimi kuyruğa alıyor. Akışta görünürlük yalnızcapublish_atsütununa bağlı; kaçan bir cron çalışması hiçbir şeyi erken göstermiyor ya da gizlemiyor.purge-originals: günlük çalışıyor. Sezonu 90 günden önce kapanmış kanıtların orijinallerini, her çağrıda en fazla 500 kanıt ve 100’lük silme partileriyle kaldırıyor. Dolandırıcılık ya da şikâyet incelemesi altındaki deliller tutuluyor.
17 fonksiyonun 9’u aynı requireService kapısından geçiyor, yani yalnızca service role ile çağrılabiliyor. Bu yüzden bunlardan birini yanlışlıkla herkese açmak için kodda bilinçli bir değişiklik yapmak gerekiyor.
120 saniyelik tek kullanımlık kamera kodu nasıl çalışıyor ve nasıl test ediliyor?
Uygulamada kanıt yalnızca uygulama içi kameradan geliyor. Kamera açıldığında issue_nonce(), karıştırılması zor 31 karakterlik bir alfabeden (0, O, 1, I, L yok) 10 karakterlik bir kod üretiyor. Kod çekim sırasında karede görünüyor ve gönderimde kanıta bağlanıyor.
Kurallar veritabanı seviyesinde:
private.noncestablosundacheck (expires_at = issued_at + interval '120 seconds')var. Süre bir uygulama sabiti değil, şemanın bir kısıtı; uzatılamıyor.codesütunuunique, kod kullanıldığındaconsumed_atdoluyor.- Bir üyenin aynı anda en fazla 5 canlı kodu olabiliyor.
- Son 24 saatte en fazla 10 kod alınabiliyor; 11. istek
nonce_capipucuyla reddediliyor. - Çekim zamanı, kodun penceresine 5 saniyelik toleransla uymak zorunda.
07_nonces_submission.sql bu sınırları uçlarından test ediyor: kod 119. saniyede hâlâ geçerli, 121. saniyede geçersiz. İkinci kullanım false dönüyor. Başka üyenin kodu kabul edilmiyor. 10 dakikalık bir kodu doğrudan tabloya yazma denemesi 23514 (check ihlali) ile düşüyor. İstemci consume_nonce çağıramıyor ve private.nonces tablosunu okuyamıyor. Aday durumundaki üye ve anon kod alamıyor.
Test süreyi beklemiyor. Süper kullanıcıya geçip issued_at değerini 121 saniye geriye çekiyor, sonra service role ile kodu tüketmeyi deniyor. Bu yöntem testi hızlı ve deterministik tutuyor.
24 saatlik gecikme ve şehir düzeyi mahremiyet veritabanında nasıl garanti ediliyor?
RichlyMe’de onaylanan bir kanıt en erken 24 saat sonra, yalnızca şehir düzeyinde görünüyor. Amaç, bir paylaşımdan kişinin o an nerede olduğunun anlaşılmasını engellemek. Bunu uygulama ekranına bırakmıyoruz; üç katmanda veritabanı tutuyor.
Birincisi tablo kısıtı: public.proofs üzerinde check (publish_at is null or publish_at >= approved_at + interval '24 hours'). Daha erken bir zaman yazmak sessizce düzeltilmiyor, doğrudan hata veriyor.
İkincisi üyenin kendi gecikmesi. Üye bu süreyi 24 saatin üzerine çıkarabiliyor. proofs_respect_delay tetikleyicisi, onay, "döndüm" işaretlemesi, itiraz ya da yönetici düzeltmesi fark etmeksizin her yazma yolunda publish_at değerini üyenin süresine yükseltiyor. Süreyi uzatmak bekleyen kanıtları da ileri taşıyor; kısaltmak ise hiçbir kanıtı öne çekmiyor.
Üçüncüsü konumun hiç saklanmaması. app.resolve_city, haversine formülüyle en yakın şehri, o şehrin yarıçapı içindeyse buluyor. Koordinatlar bu fonksiyonun dışına çıkmıyor. Yapısal test de proofs tablosunda konum benzeri bir sütun olmadığını doğruluyor. Hiçbir şehre yakın olmayan koordinatlar şehirsiz kalıyor.
Akış view’ı proof_feed, security_barrier ile tanımlı ve yayın zamanını tam kanıtlarda saate, bulanık kanıtlarda güne yuvarlıyor. Gecikme süresi biliniyorsa, saniye hassasiyetindeki bir yayın zamanından çekim anı geriye doğru hesaplanabilirdi. Testler kanıtın publish_at öncesinde başkalarına görünmediğini, sahibine göründüğünü ve süre dolduğu anda cron beklemeden göründüğünü ayrı ayrı kontrol ediyor.
Senaryo testleri politikaların gözden kaçırdığı neyi yakaladı?
Tablo bazlı RLS testleri her şeyi yakalamıyor. 29 Ekim 2026 tarihli 20261029120000_scenarios_proof_privacy.sql migration’ı, üye hikâyelerini baştan sona yürürken bulunan sorunları kapatıyor. Başlığında hepsi kayıtlı ve hepsi düzeltildi:
- Ekran ile API arasındaki fark: bulanık gösterilen bir kanıtın fotoğrafı ve açıklaması gizleniyordu, ama akış satırı hâlâ sahibinin adını, kullanıcı adını, şehri, görevi, puanı, tanık sayısını ve saniye hassasiyetindeki yayın zamanını taşıyordu. Ekran gizliyor, API veriyordu. Artık bulanık satırda yalnızca bulanık ekranın çizdiği alanlar var.
- Gecikme her yolda uygulanmıyordu: üyenin seçtiği uzun gecikme yalnızca evdeyken yapılan onayda okunuyordu. Seyahatteki bir üyenin kanıtı, 30 günlük gecikme seçmiş olsa bile sabit 7 günde yayına çıkıyordu. Şimdi tek bir değişmez kural her yazma yolunu kapsıyor.
- Sınır yalnızca istemcideydi: uygulama gecede 10 kodda duruyordu, sunucu durmuyordu. Bir betik, aynı anda 5 canlı kod limitiyle sınırsız kod üretebiliyordu. 24 saatlik 10 kod sınırı bu yüzden eklendi.
- Seviye sıralaması yanlıştı: sunucu Founder ve Patron seviyelerini eşit sayıyordu, bu yüzden bir Patron, Founder kanıtının medyasına storage politikası üzerinden ulaşabiliyordu. Şimdi her seviyenin ayrı bir sırası var: Member 1, Patron 2, Founder 3.
Ortak ders: "istemci zaten göstermiyor" bir güvenlik kontrolü değil. Bu bulguların her biri artık 81_scenarios_proof.sql ya da 02_proof_visibility.sql içinde bir testle korunuyor.
Benzer bir Supabase backend’i kuran biri neye dikkat etmeli?
RichlyMe’den çıkardığımız, başka projelere de taşınabilecek kontrol listesi:
- İlk migration’da
alter default privilegesileanonveauthenticatedyetkilerini geri alın, sonra her şeyi açıkça verin. - "RLS’i kapalı tablo yok", "
anonhiçbir şeye erişemiyor" gibi yapısal testler yazın; yeni tabloları da otomatik kapsasınlar. - Testi politikayı okuyarak değil, yasaklı işlemi o rolle deneyerek yazın ve beklenen SQLSTATE’i doğrulayın.
- Her testi
BEGIN … ROLLBACKiçinde koşun; sıra bağımlılığı olmasın. security definerfonksiyonlara her zamanset search_path = ''ekleyin. Yetkiyipublic,anonveauthenticatedrollerinden alın, yalnızca gereken role verin.- Süre ve sınırları uygulama sabiti olarak değil, mümkünse
checkkısıtı olarak tutun ve sınırın iki tarafını (119 ve 121 saniye gibi) test edin. - İstemcinin gönderdiği özet, konum ya da zaman bilgisine güvenmeyin; sunucuda yeniden hesaplayın.
- Görünürlüğü cron’a değil bir sütuna bağlayın. Böylece kaçan bir çalışma veri sızdırmaz.
- View’ların ekranın gösterdiğinden fazlasını döndürmediğini, API yanıtını okuyarak kontrol edin.
- Hesaplama hem SQL’de hem TypeScript’te varsa, ortak fixture dosyasıyla parity testi yazın. RichlyMe’de 27 fixture iki tarafı da aynı sonuca bağlıyor.
AMG Yazılım olarak bu tür, veri gizliliği ve yetki modeli ürünün merkezinde olan mobil uygulamalar ve backend’ler geliştiriyoruz.
PROJENİZE UYARLAYALIM
Backend ve API
Supabase, PostgreSQL ve Node ile ölçeklenen altyapı. Yetkilendirme veritabanı seviyesinde kurulur.
