İçeriğe geç
← STÜDYO NOTLARI

SaaS mimarisi

Okul SaaS’ı nasıl geliştirilir? Çok kiracılı mimari ve RLS

ozelokul.com’un Supabase altyapısından notlar: marka-kampüs kiracılığı, izin anahtarlı roller, RLS’e inen plan farkı ve KVKK kayıtları.

ozelokul.com arayüzü 1
ozelokul.com mobil arayüzü 1
ozelokul.com — Projenin gerçek arayüzlerini ve kapsamını inceleyin ↗

KISA CEVAP

Türk özel okulları için çok kiracılı bir SaaS’ta kiracıyı “marka” (kurum), alt kapsamı “kampüs” olarak kurun; rolleri enum yerine izin anahtarlarıyla tanımlayın ve hem kiracı izolasyonunu hem plan farkını Postgres RLS’te uygulayın. ozelokul.com’da 139 migration, 3 plan, 56 izin anahtarı ve 33 pgTAP dosyasında 560 iddia bu modeli sınıyor.

Okul SaaS’ında kiracı kim: okul mu, marka mı, kampüs mü?

Türkiye’de özel okulların büyük kısmı tek bina değil, birden çok kampüsü olan bir kurumdur. ozelokul.com’da bu yüzden iki katmanlı bir hiyerarşi kurduk: brands (kurum) ve ona FK ile bağlı campuses. Kiracı sınırı markadır; kampüs, markanın içindeki kapsamdır.

Kişi ile kurum arasındaki bağ memberships tablosunda: kullanıcı, marka, rol şablonu ve sekme bazında ince ayar için permission_overrides. Kampüs kapsamı ayrı bir tabloda, membership_campuses. Tasarımdan gelen bir kural var: kapsam satırı yoksa üyelik markanın tüm kampüslerini kapsar. Bu kural pratik ama tehlikeli; aşağıda bunun bir yetki yükseltme yolu açtığını anlatacağım.

Abonelik de aynı soruyla karşılaşıyor: plan markaya mı satılır, kampüse mi? Cevabı sabitlemedik; subscriptions tablosunda scope: brand|campus kolonu ikisini de destekliyor.

Bir de bilinçli bir sınır: bu üründe öğretmenler kullanıcı değil. Tasarımda öğretmen girişi olmadığı için staff_members bir içerik kaydı; ileride öğretmen paneli istenirse diye staff_members.user_id kolonu şimdiden nullable duruyor. Yoklama ya da not modülü yok; ürün okul tanıtımı, veli talepleri ve okul operasyonu üzerine kurulu. Kapsamı baştan dürüstçe çizmek, şemayı hayali modüllerle şişirmekten iyidir.

Roller neden enum değil, izin anahtarı olmalı?

Devraldığımız ilk migration’larda app_role enum’u (super_admin|parent_admin|school_admin) ve okul bağını string olarak tutan profiles.assigned_school_slug vardı. Kararlar belgesindeki 2 numaralı karar ikisini de iptal etti. Gerekçe tek cümle: yeni bir rol eklemek migration gerektirmemeli, kampüs kapsamı FK ile kurulmalı.

Yeni model üç tablo: permission_keys, role_templates ve üyelikteki override’lar. Etkin yetki = şablon izinleri ⊕ override’lar, kampüs kapsamıyla kesişim. Seed’de 7 şablon var: Kurum yöneticisi, Kampüs müdürü, Kayıt ofisi, Pazarlama, Muhasebe, Danışma ve platform tarafı için Süper Admin.

İzin anahtarları ilk kurulumda 47’ydi, bugün 56:

  • 13 okul paneli sekmesi (veri, kampus, teklif, mesaj, yetki, fatura gibi)
  • 22 içerik bölümü (s1–s22)
  • 3 yayın akışı (content.publish, content.rollback, content.schedule)
  • 18 platform yetkisi (platform.billing, platform.impersonate gibi)

Bütün kontrol private.has_perm(user, perm, brand, campus) fonksiyonunda toplanıyor ve sırası önemli: silinmiş hesap her zaman false, platform yöneticisi true, sonra marka ve kampüs tutarlılığı, aktif üyelik, izin listesi ve en son kampüs kapsamı. Veli tarafı bilerek basit: account_type = parent, üyelik yok. Kayıt tetikleyicisi kimseye otomatik yetki vermiyor; ilk platform yöneticisi elle işaretleniyor.

Kiracı izolasyonu RLS’te nasıl kurulur ve nerede kırılır?

Kural basitti: her tablo RLS’li doğar, uygulama katmanındaki kontrol ikinci hattır. 67 migration’ı sıfırdan, dosya sırasıyla kurduğumuz doğrulamada sonuç 149 tablo, RLS açık 148/148 tablo ve 296 politikaydı. Bugün migration sayısı 139.

Teoride düz bir iş; pratikte üç tuzağa düştük:

  • EXECUTE hakkı. Private şemadaki fonksiyonların hepsini authenticated rolünden geri aldık, ama RLS ifadeleri sorguyu yapan rolün haklarıyla çalışır. Her politika permission denied for function can_campus verdi; okul paneli ve veli tarafı tamamen ölüydü. Çözüm: yalnız politikalarda adı geçen 9 fonksiyona EXECUTE.
  • Politika döngüsü. memberships_read içinden membership_campuses’a bakınca Postgres 42P17: infinite recursion detected in policy verdi. Kural: RLS’li başka bir tabloya bakan politika, bu erişimi SECURITY DEFINER bir fonksiyona almalı.
  • Tutarsız argümanlar. has_perm marka ve kampüs birlikte verildiğinde kampüsün o markaya ait olduğunu kontrol etmiyordu. O gün hiçbir çağrı bu biçimi kullanmadığı için sömürülemiyordu; yine de düzelttik. Tutarsız çift artık false döner.

“Boş kapsam = tüm kampüsler” kuralı da kampüs müdürüne yetki devri açılınca sorun çıkardı. Üyelik güncellemesi üç adıma bölünmüştü ve aradaki anda kapsam satırı yoktu, yani üyelik kısa süreliğine marka geneline yayılıyordu. Şimdi save_membership() üyeliği ve kapsamı tek işlemde yazıyor; kimse kendi kapsamı dışına ya da kendinde olmayan bir izni veremiyor.

Free ve Premium farkı neden RLS’e kadar inmeli?

Üç plan var: Temel Listeleme (varsayılan, ücretsiz), Büyüme ve Vitrin & Premium. Planın içeriği koddaki bir if (plan === "premium") değil, plan_features tablosundaki satırlar: gallery_max_photos Temel’de 5, Büyüme’de 40; multi_admin_seats Temel’de 1, Büyüme’de 5. Temel planda 20 özellik anahtarının ikisi sayısal limit, on sekizi kapalı bayrak.

Herkese açık okul profilinde bir bölümün görünüp görünmeyeceğine tek fonksiyon karar veriyor: private.section_visible(campus, section). Üç koşulu birlikte arıyor: kampüs yayında mı, bölüm yayında ve aktif mi, bölümün bağlı olduğu özellik planda açık mı. Kilitli bölüm sorguda hiç dönmez. Ön yüzde gizlemek kabul edilmiyor, çünkü gizlenen veri yine de tarayıcıya gelmiş olur.

Mesajlaşmada bu ilke bir açığı kapattı. Plan “ücretsiz okul mesaj alamaz” diyordu, ama conversations politikası yalnız tarafları kontrol ediyordu; veli ücretsiz kampüse konuşma açabiliyordu. with check koşuluna private.has_feature(campus_id, 'messaging') eklendi. Okuma bilerek engellenmedi: plan düşerse taraflar eski konuşmayı görmeye devam ediyor, engellenen yalnız yeni konuşma açmak.

PgTAP tarafında bunun kanıtı mutasyon testi: section_visible içinden plan dalını sildiğimizde entitlement paketi 2 iddiada kırmızıya döndü.

RLS satırı korur, kolonu korumaz: veli verisi nasıl maskelenir?

Bir SELECT politikası satırın bütün kolonlarını açar. Okul tarafının veli teklif havuzunu görmesi gerekiyor, ama velinin iletişim bilgisini, veli bunu açmadan görmemesi gerekiyor. Çözüm politika değil görünüm: okul taban tabloya hiç SELECT almıyor, yalnız quote_requests_for_school görünümünü sorguluyor; iletişim kolonları case when contact_revealed ile NULL’lanıyor. Gizli profil görünümünde parent_id hiç yer almıyor. Yorumlarda kolon düzeyi GRANT var: doğrulama kodu ve moderasyon notu hiçbir API rolüne okunmuyor.

Yazma tarafında aynı sorun daha sert çıktı. Yetki denetiminde “kullanıcı kendi profilini güncelleyebilir” politikasının satırın tamamını açtığını canlı denemeyle gösterdik: bir veli hesabı tek PATCH isteğiyle kendi account_type alanını platform yapabiliyordu. Aynı sınıftan beş yer bulundu. Hepsi kapatıldı, iki katmanla:

  • Kolon GRANT’ları: profiles üzerinde UPDATE geri alınıp yalnız full_name, phone, avatar_url, locale, marketing_opt_in, last_seen_at geri verildi.
  • Tetikleyici: grant ileride genişletilse bile ayrıcalık kolonundaki değişimi reddediyor.

İkinci katmanın neden gerektiği de yaşanarak görüldü. İlk düzeltmede tetikleyiciler yanlışlıkla security definer yazılmıştı; bu bağlamda current_user fonksiyon sahibini döndürdüğü için kontrol her zaman geçiyordu. profiles yine korundu, çünkü kolon grant’ları tutuyordu. Kural: rol, durum, onaylayan, işlenme zamanı gibi alanlar beyan edilmez, türetilir.

KVKK için şemada ne olmalı: onay kaydı, sürümlü metin, anonimleştirme

KVKK uyumu bir çerez bandı değil, veritabanında izlenebilir bir zincir. ozelokul.com’da üç parça var.

consent_records: her form onayında kullanıcı ya da anonim oturum kimliği, onaylanan metnin legal_text_id’si, bağlam (gizli profil, teklif, randevu, bülten, iletişim, danışmanlık, başvuru, bilgi talebi), zaman damgası ve geri alma zamanı. Bir check kısıtı, kaydın ya kullanıcıya ya da anonim oturuma bağlı olmasını zorunlu kılıyor.

legal_texts: (key, version) benzersiz, her anahtar için tek is_current. Bir doğrulama turunda platform yöneticisinin yayınlanmış metni yerinde değiştirebildiği görüldü. Bu, onay kayıtlarının sessizce başka bir içeriğe işaret etmesi demek; düzeltmeyle yayınlanmış metin yönetici dahil kimse tarafından değiştirilemiyor, yalnız yeni sürüm yayınlanabiliyor.

anonymize_user(): hesap silinince satırlar silinmiyor, kişisel alanlar boşaltılıyor. Profilde ad “Silinmiş kullanıcı” oluyor; veli profilinde telefon ve semt, çocuk kaydında ad ve özel gereksinim notu temizleniyor; favoriler ve karşılaştırmalar siliniyor. Talep kaydına 12 tablonun adı yazılıyor. Yorumların ve sözleşmesel kayıtların bütünlüğü böylece korunuyor.

Anonim formlarda bir Postgres ayrıntısı daha var: .insert().select() RETURNING üretir ve RETURNING SELECT politikasına tabidir. Anon’a SELECT verilmediği için bütün oturumsuz formlar düşüyordu. Kimliği sunucuda crypto.randomUUID() ile üretip geri okumayı bıraktık; ziyaretçiye birbirinin talebini okuma hakkı vermek çok daha ağır bir sızıntı olurdu.

Türkiye pazarına özgü neler çıktı?

Konum: şemada 81 il ve 973 ilçe seed olarak geliyor; il ve ilçe metin değil FK. Taksonomi 411 terim ve yalnız platform panelinden açılıyor. Okul listede olmayan bir etiket girerse yayın Türkçe bir hatayla bloklanıyor, sessizce düşmüyor.

Arama: ilike Türkçe’yi çözmüyor. kadikoy aramasında 0 sonuç, kadıköy aramasında 1 sonuç vardı; aksansız yazan kullanıcı hiçbir şey bulamıyordu ve hata da görmüyordu. Tetikleyiciyle dolan search_text kolonu ve trigram indeksi eklendi, arama terimi de aynı kurallarla normalize ediliyor. unaccent sorgu içinde çağrılsaydı indeks kullanılamazdı, çünkü fonksiyonel indeks IMMUTABLE ister.

PDF: jsPDF’in yerleşik Helvetica’sında ı, İ, ğ, Ğ, ş, Ş yok. Roboto, Türkçe karakter aralıklarına indirgenip stil başına 515 KB’tan 39 KB’a düşürüldü ve gömüldü; kütüphane yalnız indirme düğmesine basılınca yükleniyor.

Ödeme: sağlayıcı yok, bu bir müşteri kararı. Akış havale/EFT üzerine kurulu: platform fatura kesiyor, okul panelden ödeme bildiriyor, yönetici onaylayınca tek transaction’da fatura ödendi, abonelik aktif ve denetim kaydı yazılıyor.

Veli bildirimleri: kuyruk kanaldan bağımsız (in_app, email, sms, whatsapp, push). SMS ve WhatsApp sağlayıcısı henüz seçilmedi; yapılandırılmamış kanal hata sayılmıyor, kayıt kuyrukta kalıyor. Dağıtıcı 100’lük partilerle çalışıyor, bir kaydı en çok 5 kez deniyor.

Çok kiracılı yetki nasıl test edilir?

Bugün supabase/tests/ altında 33 pgTAP dosyası ve toplam 560 planlanmış iddia var: anonim okuma, plan farkı, kampüs kapsamı, maskeleme, denetim kaydı, ayrıcalık kolonları, yetki devri, başvuru akışı.

İki alışkanlık testleri anlamlı kılıyor. Birincisi, testin kendisini denetlemek: ayrıcalık kolonu paketinin ilk iki iddiası current_user = authenticated olduğunu ve ayrıcalıklı yazar kontrolünün false döndüğünü doğruluyor. Testler süper kullanıcı olarak koşsaydı RLS baypas edilir, hepsi boş yere geçerdi. İkincisi, mutasyon testi: politikayı bilerek bozup paketin kırmızıya döndüğünü görmek. has_perm’den kampüs kapsamı silinince kapsam paketi 5 iddiada düştü.

Veritabanı testleri sunucu fonksiyonlarını kapsamıyor. Bu yüzden ayrıca 80 mutasyon ucu bir kapı haritasıyla eşlendi (28’i platform yöneticisi, 20’si kampüs izni kapısında) ve 30 uç gerçek JWT’lerle canlı sömürü denemesinden geçti. Dört bulgu sömürüldü; aralarında bir teklif talebine eşleşmemiş kampüsün yanıt yazabilmesi ve e-postaya bağlı olmayan davet token’ı vardı. Dördü de kod ve migration tarafında kapatıldı.

Son olarak dağıtımın kendisi test ediliyor: canlıda artımlı uygulanan migration’lar, dosya adı sırasıyla boş bir veritabanına kuruldu ve canlıda görünmeyen iki teslim engeli çıktı. “Canlıda çalışıyor” teslim kanıtı değil; teslim, sıfırdan kurulabilmek.

Benzer bir okul SaaS’ı kuracaklar için kontrol listesi

ozelokul.com’dan çıkan, başka bir okul ya da kurum SaaS’ına doğrudan taşınabilecek kararlar:

  • Kiracıyı kurum (marka) olarak kurun, kampüsü FK’li alt kapsam yapın. “Boş kapsam = hepsi” kuralını kullanıyorsanız kapsam değişikliğini tek transaction’da yazın.
  • Rolleri izin anahtarı ve şablon olarak tanımlayın; bütün kontrolü tek bir has_perm fonksiyonunda toplayın ve argümanların birbiriyle tutarlılığını da doğrulayın.
  • Plan farkını plan_features satırlarıyla tanımlayın ve RLS’e kadar indirin. Kilitli veri tarayıcıya hiç gelmemeli.
  • Politikada adı geçen fonksiyonlara sorgulayan role EXECUTE verin; RLS’li başka tabloya bakan politikaları SECURITY DEFINER fonksiyonla sarın.
  • “Kendi satırını düzenler” politikası yazdığınız her tabloda her kolonu tek tek sorun. Ayrıcalık kolonlarını hem kolon GRANT’ı hem tetikleyiciyle koruyun.
  • Veli iletişim bilgisi gibi hassas kolonları görünümle maskeleyin.
  • KVKK için onay kaydını sürümlü ve değiştirilemez yasal metne bağlayın; hesap silmede anonimleştirin ve hangi tabloların temizlendiğini kaydedin.
  • Türkçe arama için normalize kolon ve trigram kullanın; PDF üretiyorsanız Türkçe karakterli font gömün.
  • pgTAP testlerinin hangi rolle koştuğunu testin içinde doğrulayın, mutasyon testi yapın, migration setini her değişiklikte sıfırdan kurun.

AMG Yazılım olarak bu tür çok kiracılı SaaS ürünlerini, şemadan yetki denetimine kadar uçtan uca geliştiriyoruz.

PROJENİZE UYARLAYALIM

SaaS Geliştirme

Abonelik, roller, arka plan işleri ve yönetim araçlarıyla özel SaaS ürünleri. Web ve mobil deneyimi birlikte planlıyoruz.