· 6 dk okuma
EF Core ile Çok Kiracılı Uygulamada Veri Sızıntısını Önlemek: Global Query Filter
Birden fazla işletmenin aynı uygulamayı ve aynı veritabanını kullandığı sistemlere çok kiracılı (multi-tenant) sistem denir. Bu sistemlerde en pahalı hata, bir işletmenin başka bir işletmenin verisini görmesidir. Bu yazıda, geliştirdiğim teknik servis takip uygulaması Tamirhane'de bu hatayı EF Core'un global query filter özelliğiyle nasıl kökten engellediğimi ve bunu gerçek bir PostgreSQL üzerinde nasıl test ettiğimi anlatıyorum.
Problem: aynı tablo, farklı atölyeler
Tamirhane'de her telefon tamircisi bir atölye. Bütün atölyelerin müşterileri, cihazları, iş emirleri ve yedek parçaları aynı tablolarda duruyor; her satırda kime ait olduğunu gösteren bir shop_id sütunu var. Bu düzen kurulumu ve bakımı kolaylaştırıyor ama bir şartı var: her sorgu sadece oturum açan personelin atölyesine ait satırları görmeli.
Bu şartın ne kadar kolay bozulduğunu kendi projemde gördüm. Veritabanı katmanını ilk kurduğumda filtre yoktu ve uçtan uca denerken ikinci bir atölye açtım. Yeni atölyede müşteri kaydederken uygulama bana "bu telefon numarası zaten kayıtlı" dedi. Numara gerçekten kayıtlıydı, ama başka bir atölyenin müşterisi olarak. Uyarı küçük görünüyor, ama aslında bir atölyenin başka bir atölyenin müşteri listesini sorgulayabildiğini gösteriyordu.
İlk akla gelen çözüm ve neden yetmediği
İlk akla gelen çözüm, her sorguya elle bir koşul eklemek:
db.Customers.FirstOrDefaultAsync(c => c.Phone == phone && c.ShopId == shopId, ct);
Bu çalışır, ama güvenliği her geliştiricinin her sorguda hatırlamasına bağlar. Projede onlarca sorgu var ve her yeni ekran yenilerini getiriyor. Tek bir unutulan ShopId koşulu bütün sistemi açık hâle getirir. Üstelik bu tür bir hata testlerde de kolayca gözden kaçar, çünkü geliştirirken genelde tek bir atölyeyle çalışırsınız.
İstediğim şey tersiydi: filtre varsayılan olsun, kapatmak bilinçli bir karar gerektirsin.
Çözüm: EF Core global query filter
EF Core'da bir varlık tipine global query filter tanımlanabiliyor. Bu filtre, o tipe yapılan her LINQ sorgusuna kendiliğinden eklenir. Include ile yüklenen ilişkiler de dahil.
public sealed class TamirhaneDbContext(DbContextOptions<TamirhaneDbContext> options, ICurrentUser currentUser)
: DbContext(options), IUnitOfWork
{
// Oturum yoksa Guid.Empty döner ve filtre hiçbir kaydı eşleştirmez.
private Guid CurrentShopId => currentUser.ShopId;
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
modelBuilder.ApplyConfigurationsFromAssembly(typeof(TamirhaneDbContext).Assembly);
modelBuilder.Entity<WorkOrder>().HasQueryFilter(o => o.ShopId == CurrentShopId);
modelBuilder.Entity<Customer>().HasQueryFilter(c => c.ShopId == CurrentShopId);
modelBuilder.Entity<Device>().HasQueryFilter(d => d.ShopId == CurrentShopId);
modelBuilder.Entity<Part>().HasQueryFilter(p => p.ShopId == CurrentShopId);
}
}
Burada önemli bir ayrıntı var: filtre sabit bir değere değil, DbContext'in bir özelliğine (CurrentShopId) bakıyor. Model bir kez kurulup bütün isteklerde paylaşılsa da EF Core bu değeri her sorguda o anki DbContext örneğinden okuyup SQL'e parametre olarak koyar. ASP.NET Core'da her istek kendi DbContext'ini aldığı için her istek kendi atölyesini görür.
Atölye kimliği ICurrentUser arayüzünden, yani oturumdan geliyor. Formdan ya da istekten gelen bir değere asla güvenilmiyor. Böylece kullanıcı tarayıcıda bir sayıyı değiştirerek başka bir atölyeye geçemiyor.
Artık depolardaki sorgular atölyeyi hiç bilmiyor:
public Task<Customer?> FindByPhoneAsync(PhoneNumber phone, CancellationToken ct) =>
db.Customers.FirstOrDefaultAsync(c => c.Phone == phone, ct);
Üretilen SQL'de ise WHERE shop_id = @shopId koşulu her zaman bulunuyor.
Hata olursa kapalı kalsın
Oturum yoksa ShopId değeri Guid.Empty oluyor ve hiçbir kayıtla eşleşmiyor. Yani bir şey ters giderse uygulama "her şeyi gösteren" değil, "hiçbir şeyi göstermeyen" tarafa düşüyor. Güvenlikte buna fail closed deniyor.
Domain'de olmayan sütun: gölge özellik
Stok tablolarının domain sınıflarında ShopId yoktu; stok kaydı bir parçaya ve bir şubeye bağlı. Sırf filtre için domain'i değiştirmek yerine EF Core'un gölge özellik (shadow property) desteğini kullandım. Sütun veritabanında var, sınıfta yok:
b.Property<Guid>("ShopId");
modelBuilder.Entity<StockLevel>()
.HasQueryFilter(l => EF.Property<Guid>(l, "ShopId") == CurrentShopId);
Okumak yetmez: yazarken de koruma
Filtre okumayı koruyor, ama yazma ayrı bir konu. Bir kod hatası yüzünden yeni bir kayıt yanlış atölyenin kimliğiyle oluşturulabilir. Bunu SaveChanges'ın içinde, veritabanına gitmeden hemen önce yakalıyorum:
private void StampShop()
{
foreach (var entry in ChangeTracker.Entries().Where(e => e.State == EntityState.Added))
{
// Stok kayıtlarının atölyesi oturumdan yazılır.
if (entry.Entity is StockLevel or StockMovement)
{
entry.Property("ShopId").CurrentValue = CurrentShopId;
continue;
}
var shopId = entry.Entity switch
{
WorkOrder o => o.ShopId,
Customer c => c.ShopId,
Device d => d.ShopId,
Part p => p.ShopId,
_ => CurrentShopId,
};
if (shopId != CurrentShopId)
throw new InvalidOperationException($"{entry.Entity.GetType().Name} başka bir atölyeye yazılmak istendi.");
}
}
Gölge sütunlu stok kayıtlarına atölye burada yazılıyor. Atölyesini kendisi taşıyan kayıtlarda ise değer oturumdakiyle aynı değilse kayıt hiç yapılmıyor.
Bilinçli istisna: takip kodu
Müşteri, cihazının durumunu oturum açmadan, kendisine verilen takip koduyla sorguluyor. Bu sorguda oturum ve atölye yok, o yüzden filtreyi bilerek kapatıyorum:
// Takip kodu tahmin edilemez olduğu için filtre bilerek kapatılır.
public Task<WorkOrder?> GetByTrackingCodeAsync(string trackingCode, CancellationToken ct) =>
Full.IgnoreQueryFilters().FirstOrDefaultAsync(o => o.TrackingCode == trackingCode, ct);
Bu güvenli, çünkü takip kodu sıra numarası değil: 32 karakterli bir alfabeden kriptografik rastgele üretilen 12 karakter. Yaklaşık 10¹⁸ olasılık var, yani tahmin edilemez. Ayrıca takip sayfasında müşterinin adı maskeli gösterilecek ("Ay** Ka**"). Projede IgnoreQueryFilters sadece bu tek yerde geçiyor; kod incelemesinde aranacak kelime belli.
Filtrenin göremediği yer: ham SQL
Global query filter LINQ sorgularına eklenir. Database.SqlQuery ya da ExecuteSql gibi doğrudan yazılan SQL komutları ise filtreyi görmez. Tamirhane'de iş emri numarası (2026-0001) için atölye ve yıl başına bir sayaç var ve bu sayaç aynı anda açılan iki iş emri aynı numarayı almasın diye tek bir SQL komutuyla artıyor. Bu komutta atölye kimliğini açıkça parametre olarak veriyorum:
INSERT INTO work_order_sequences (shop_id, year, value) VALUES (@shopId, @year, 1)
ON CONFLICT (shop_id, year) DO UPDATE SET value = work_order_sequences.value + 1
RETURNING value
Kural basit: ham SQL yazılan her yerde shop_id elle yazılır ve kod incelemesinde özellikle kontrol edilir.
Gerçek veritabanında test
Bu tür bir güvenlik kuralını sahte (mock) bir depoyla test etmek pek bir şey kanıtlamaz, çünkü asıl iş EF Core'un ürettiği SQL'de oluyor. Bu yüzden testleri Testcontainers ile gerçek bir PostgreSQL üzerinde çalıştırıyorum. Test başlarken Docker'da geçici bir veritabanı açılıyor, migration'lar uygulanıyor, testler bitince konteyner siliniyor.
Kabul kriteri olarak yazdığım test şu: B atölyesine bir iş emri kaydediliyor, sonra A atölyesi o iş emrini kimliğiyle istiyor.
[Fact]
public async Task Shop_cannot_open_another_shops_work_order_by_id()
{
var order = await ReceiveAsync(_shopB, "0532 111 11 11");
using var a = db.AsShop(_shopA);
Assert.Null(await a.Orders.GetAsync(order.Id, default));
using var b = db.AsShop(_shopB);
Assert.NotNull(await b.Orders.GetAsync(order.Id, default));
}
A atölyesi için kayıt yokmuş gibi davranılıyor ve kullanım senaryosu "bulunamadı" hatası döndürüyor. Web katmanında bu bir 404 olacak, yani kaydın var olduğu bile sızmayacak. Bunun yanında dört test daha var: başka atölyenin müşterisinin telefonla bulunamaması (başta gördüğüm hata), oturum yokken hiçbir kaydın dönmemesi ama takip koduyla aramanın çalışması, stok kaydının atölyesinin oturumdan yazılıp diğer atölyeden gizlenmesi ve başka atölyeye kayıt yazma denemesinin engellenmesi.
Testin gerçekten işe yaradığından emin olmak için filtreleri geçici olarak kaldırıp testleri yeniden çalıştırdım: 5 testten 3'ü kırmızıya döndü. Filtre geri gelince hepsi geçti. Yani biri ileride filtreyi yanlışlıkla bozarsa bunu ilk test çalıştırmasında görecek.
Özet
- Kiracı filtresini her sorguya elle yazmak yerine EF Core global query filter ile varsayılan hâle getirdim; kapatmak için
IgnoreQueryFiltersyazmak gerekiyor ve bu projede tek bir yerde var. - Atölye kimliği her zaman oturumdan geliyor; oturum yoksa sistem hiçbir veriyi göstermiyor.
- Yazarken de
SaveChangesiçinde kontrol var; domain'de olmayan sütunlar için gölge özellik kullandım. - Ham SQL filtreyi görmediği için orada
shop_idelle yazılıyor. - Bütün bunları Testcontainers ile gerçek PostgreSQL'de test ettim ve testlerin filtre bozulunca gerçekten kırıldığını doğruladım.
Tamirhane'nin kaynak kodu GitHub'da: github.com/MrcDprm/tamirhane-repair-tracker. Proje hâlâ geliştiriliyor; bitince ayrıntılı bir proje yazısıyla burada olacak.