Tüm yazılara dön

Çok Kiracılı SaaS'ta Kiracı İzolasyonu: EF Core Filtresi Yetmez, SQL Server RLS ile İkinci Kilit

7 Kasım 20264 dk okuma
ASP.NET CoreMulti-tenancySQL ServerGüvenlik

Hukuk büroları için geliştirdiğim çok kiracılı (multi-tenant) platformda en çok kafa yorduğum soru şuydu: bir büronun müvekkil dosyası, yanlışlıkla bile olsa başka bir büroya görünürse ne olur? Cevap basit, ürün biter. Avukatlık sırrı ve KVKK söz konusuyken "bir yerde Where unutmuşuz" demenin telafisi yok. Bu yüzden izolasyonu tek bir mekanizmaya bırakmadım, birbirinden bağımsız üç katmana böldüm. Bu yazıda o katmanları ve her birini neden tek başına yeterli görmediğimi anlatıyorum.

Kiracıyı kim belirler? Sadece imzalı token

İlk kural, kiracının (büronun) kim olduğunu istemcinin söylememesi. Header, query string, route parametresi, gövdedeki bir alan, hiçbiri kiracı belirlemiyor. TenantId yalnızca doğrulanmış JWT'nin içindeki tid claim'inden okunuyor. Büro kodu sadece giriş ekranında kullanılıyor: kod kiracıya çevriliyor, kimlik doğrulanıyor, sonra kiracı bilgisi imzalı token'a gömülüyor.

Bunun pratik bir sonucu var: kullanıcı modeli de kiracı bazlı. Aynı telefon numarası iki farklı büroda iki ayrı hesap demek. İlk bakışta tuhaf geliyor ama kimlik seviyesinde de kesin bir sınır çiziyor, "bu kullanıcı hangi büroda?" sorusu hiç belirsiz kalmıyor. Süper admin ise tamamen ayrı bir tabloda, ayrı issuer/audience ile token alıyor. Büro token'ı platform uçlarına, platform token'ı büro uçlarına giremiyor.

Birinci katman: EF Core global query filter

Kiracıya ait her entity ITenantEntity arayüzünü uyguluyor ve model oluşturulurken reflection ile hepsine aynı filtre ekleniyor: e.TenantId == currentTenant && !e.IsDeleted. Böylece geliştirici sorgu yazarken kiracıyı düşünmek zorunda kalmıyor; unutması da mümkün değil.

Yazma tarafında SaveChanges sırasında çalışan bir pipeline devrede: yeni eklenen kayda TenantId otomatik atanıyor, değiştirilen ya da silinen bir kaydın TenantId'si mevcut kiracıdan farklıysa istisna fırlatılıyor. Yani başka kiracının satırını bir şekilde belleğe yükleseniz bile onu güncelleyemiyorsunuz.

Peki bu neden yetmiyor? Çünkü EF filtresi uygulama kodunda yaşıyor. Biri IgnoreQueryFilters() çağırabilir, bir rapor için ham SQL yazabilir ya da ileride gelecek bir refactor filtreyi bozabilir. Uygulama katmanındaki her koruma, uygulama katmanındaki bir hatayla delinebilir.

İkinci katman: SQL Server Row-Level Security

Bu yüzden asıl kilidi veritabanına koydum. Her bağlantı açıldığında bir DbConnectionInterceptor SQL Server'ın oturum bağlamına o anki kiracıyı yazıyor:

internal sealed class TenantSessionContextInterceptor(ITenantContext tenantContext)
    : DbConnectionInterceptor
{
    public override Task ConnectionOpenedAsync(DbConnection connection,
        ConnectionEndEventData eventData, CancellationToken cancellationToken = default) =>
        SessionContext.ApplyAsync(connection, tenantContext.TenantId,
            tenantContext.IsSystem, cancellationToken);
}
 
// EXEC sys.sp_set_session_context @key = N'TenantId', @value = @tenantId;
// EXEC sys.sp_set_session_context @key = N'IsSystem', @value = @isSystem;

Burada küçük ama kritik bir detay var: değer, kiracı boş olsa bile her açılışta yazılıyor. Bağlantı havuzundan gelen bir bağlantıda önceki isteğin kiracısı kalmış olabilir. "Boşsa yazma" gibi masum görünen bir optimizasyon, bir isteğin başka bir kiracının oturum bağlamıyla çalışması demek olurdu.

Veritabanı tarafında, kiracı tablolarına bağlanan bir predicate fonksiyonu bu değeri zorluyor:

SELECT 1 AS allowed
WHERE @TenantId = CAST(SESSION_CONTEXT(N'TenantId') AS uniqueidentifier)
   OR CAST(SESSION_CONTEXT(N'IsSystem') AS bit) = 1;

Her kiracı tablosuna hem okuma için bir FILTER predicate'i hem de ekleme/güncelleme sonrası için BLOCK predicate'leri bağlanıyor. Security policy SCHEMABINDING = ON ile kuruluyor ve migration aracı her çalıştığında politikayı tablo listesine göre yeniden üretiyor. Böylece yeni bir kiracı tablosu eklendiğinde RLS'e bağlamayı unutmak gibi bir risk de kalmıyor. Sonuçta uygulama tarafındaki filtre bir hatayla atlansa bile veritabanı başka kiracının satırını döndürmüyor ve yazdırmıyor.

IsSystem bayrağı ise bilinçli bir kaçış kapısı. Gece çalışan bildirim işleri ya da platform raporları gibi kiracılar arası çalışması gereken arka plan işleri bunu açıyor. İstemciden gelen hiçbir istek bu bayrağı açamıyor, sadece işaretli altyapı kodu kullanabiliyor.

Üçüncü katman: kayıt kapsamı (aynı büro içinde bile)

Kiracı izolasyonu büroları birbirinden ayırıyor ama büronun içinde de bir sınır var: stajyer avukat sadece atandığı dosyaları görmeli, müvekkil sadece kendi dosyasını. Bunu controller attribute'larına bırakmadım. İzinler ikili geliyor (Cases.ViewAll / Cases.ViewAssigned) ve kapsam doğrudan sorguya ekleniyor. Böylece liste, arama, sayaç ve bildirim hedeflemesi aynı kuraldan geçiyor. Bu son ikisi çok gözden kaçıyor: listede görünmeyen bir dosyanın, menüdeki "3 yeni belge" rozetine sayılması da bir sızıntı. Kapsam dışındaki tekil bir kaydı istediğinizde de 403 değil 404 dönüyor, kaydın var olduğu bilgisi bile sızmıyor.

Bonus: değiştirilemez loglar

Hukuk bürosunda "kim, hangi belgeyi, ne zaman gördü" sorusunun cevabı da en az veri kadar önemli. Denetim, erişim, güvenlik, finans ve KVKK loglarını ayrı tablolarda, kiracı ve log türü bazında bir hash zinciriyle tutuyorum. Her satır bir önceki satırın hash'ini içeriyor; araya satır eklemek, silmek ya da bir satırı değiştirmek zinciri kırıyor ve doğrulayıcı bunu yakalıyor. Zincir başı satırı UPDLOCK, HOLDLOCK ile kilitleniyor, böylece aynı anda gelen iki yazım aynı sıra numarasını alamıyor. Loglar da iş işlemiyle aynı transaction'da yazılıyor: log yazılamazsa iş de geri alınıyor.

Sonuç

Tek bir koruma katmanı, o katmandaki tek bir hatayla delinebilir. Kiracıyı yalnızca imzalı token'dan okumak, uygulama tarafında EF filtresi ve SaveChanges kontrolü, veritabanı tarafında RLS: üçü birbirinden bağımsız çalışıyor ve herhangi biri hata yapsa diğer ikisi sızıntıyı durduruyor. Paylaşılan veritabanı ve paylaşılan şemayla başlamak operasyonu sade tutuyor, RLS de bu sadeliğin güvenlikten ödün vermeden yapılabilmesini sağlıyor. Platformun mobil uygulamaları, 2FA ve token rotasyonu tarafını proje sayfasında bulabilirsin.

İlgili Proje

Bu yazının anlattığı projeyi detaylıca incele.

Projeyi İncele