API1 BOLA'yı Sistematik Kapatmak: Tenant/Owner Scoped Sorgular, Global Query Filter ve Resource-Based Authorization
BOLA'yı her endpoint'e Where ekleyerek değil, kapsamı veri erişim katmanında zorunlu kılarak kapatırsın: EF Core 10'un named query filter'larıyla tenant ve owner filtresi, kapsamsız sorguyu tek bir işaretli class'a hapseden testler, alt kaynak ve toplu işlemlerde ilişki kontrolü, resource-based handler ile 403/404 ayrımı ve matristen otomatik üretilen başka tenant senaryoları.
Sürüm notu: Bu yazıdaki kodlar .NET 10 ve EF (Entity Framework) Core 10.0.14 ile derlenip denenmiştir; named query filter EF Core 10 ile gelen bir özelliktir.
Önce Tahmin Et
Bir önceki yazıda authorization matrisi sekiz kırmızı satır verdi. En kolay düzeltme belli: detay endpoint'ine && o.TenantId == user.TenantId eklemek, fatura endpoint'ine de aynısını. On dakikada biter, testler yeşile döner. Şimdi şunu düşün: altı ay sonra ekibe yeni katılan biri GET /orders/{orderId}/items endpoint'ini yazıyor. O satırı ekleyecek mi? Peki bir raporlama sorgusu, bir toplu güncelleme ya da bir background job?
BOLA (Broken Object Level Authorization), OWASP'ın (Open Worldwide Application Security Project) API (Application Programming Interface) Security Top 10 listesinde yıllardır birinci sırada; 2023 sürümündeki kodu API1 (listenin ilk maddesi). Sebebi teknik olarak zor olması değil; kontrolün her endpoint'te, her sorguda tekrar tekrar hatırlanması gerekmesi. Bu yazıda hatırlamayı ortadan kaldıracağız: kapsamı veri erişim katmanına zorunlu olarak koyacak, kapsamsız sorguyu tek bir işaretli yere hapsedecek ve matristen başka tenant senaryolarını otomatik üreteceğiz. IDOR'un (Insecure Direct Object Reference) ve 403/404 kararının temeli Web Güvenliği (OWASP Top 10) serisinin A01 Broken Access Control yazısında; burada API ve multi-tenant tarafına odaklanıyoruz.
Neden Endpoint Başına Kontrol Ölçeklenmez
Bir önceki yazıdaki liste endpoint'i yeşildi, çünkü geliştirici filtreyi elle yazmıştı. Detay endpoint'i kırmızıydı, çünkü aynı geliştirici aynı gün aynı filtreyi orada unutmuştu. Bu bir dikkat sorunu değil, bir tasarım sorunu. Kontrolün doğru olması için N endpoint × M sorgu yerde doğru yazılması gerekiyorsa, sistem güvenliği en dikkatsiz anın kalitesine eşit olur.
Çözüm, kontrolü sorgunun yazıldığı yerden alıp sorgunun çalıştığı yere taşımak. EF Core'da bunun aracı global query filter: bir entity tipine eklenen ve o tipe dokunan her LINQ (Language Integrated Query) sorgusuna otomatik eklenen bir Where. Soft delete ve multi-tenancy için global query filter'ın mekaniğini EF Core serisinin global query filter yazısında anlatmıştık. EF Core 10'un getirdiği yenilik, bir entity'ye birden fazla adlandırılmış filtre ekleyip bunları ayrı ayrı kapatabilmek. Bizim için anlamı şu: tenant kapsamı ile müşteri (owner) kapsamı iki ayrı kural olarak durabiliyor.
Veri Kapsamını Claim'lerden Çıkarmak
Önce isteğin kapsamını tek bir değer olarak tanımlıyoruz. Hangi tenant ve tenant içinde hangi müşteri. Support ve admin rolleri tenant genelinde çalışıyor, bu yüzden onlar için müşteri null:
// İsteğin veri kapsamı: hangi tenant, tenant içinde hangi müşteri (null = tenant geneli)
public sealed record DataScope(string? TenantId, Guid? CustomerId)
{
public static DataScope From(ClaimsPrincipal? user)
{
string? tenantId = user?.FindFirstValue(LabClaims.Tenant);
string? role = user?.FindFirstValue(LabClaims.Role);
string? subject = user?.FindFirstValue(LabClaims.Subject);
Guid? customerId = role == "customer" && Guid.TryParse(subject, out Guid id) ? id : null;
return new DataScope(tenantId, customerId);
}
}
builder.Services.AddScoped(sp => DataScope.From(sp.GetRequiredService<IHttpContextAccessor>().HttpContext?.User));
Kimliksiz bir istekte, ya da uygulama açılırken seed çalışırken, TenantId null oluyor. Bunu bilinçli bıraktım: aşağıdaki filtre TenantId == null koşuluyla hiçbir satır döndürmez. Kapsam bilinmiyorsa veri yok; fail closed.
Named Query Filter ile Tenant ve Owner Kapsamı
OrdersDbContext artık DataScope'u constructor'dan alıyor ve iki adlandırılmış filtre kuruyor:
public sealed class OrdersDbContext(DbContextOptions<OrdersDbContext> options, DataScope scope) : DbContext(options)
{
public const string TenantFilter = "Tenant";
public const string OwnerFilter = "Owner";
// EF Core bu field'ları her sorguda bu context instance'ından parametre olarak okur
private readonly string? _tenantId = scope.TenantId;
private readonly Guid? _customerId = scope.CustomerId;
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
// ITenantOwned uygulayan her entity'ye tenant filtresi: yeni entity eklemek filtreyi unutmayı imkânsız kılar
MethodInfo apply = typeof(OrdersDbContext).GetMethod(nameof(ApplyTenantFilter),
BindingFlags.NonPublic | BindingFlags.Instance)!;
foreach (Type clrType in modelBuilder.Model.GetEntityTypes().Select(t => t.ClrType)
.Where(t => typeof(ITenantOwned).IsAssignableFrom(t)).ToList())
{
apply.MakeGenericMethod(clrType).Invoke(this, [modelBuilder]);
}
modelBuilder.Entity<Order>()
.HasQueryFilter(OwnerFilter, o => _customerId == null || o.CustomerId == _customerId);
modelBuilder.Entity<Invoice>()
.HasQueryFilter(OwnerFilter, i => _customerId == null || i.CustomerId == _customerId);
}
private void ApplyTenantFilter<TEntity>(ModelBuilder modelBuilder) where TEntity : class, ITenantOwned =>
modelBuilder.Entity<TEntity>().HasQueryFilter(TenantFilter, e => e.TenantId == _tenantId);
}
İki ayrıntı önemli. Birincisi, filtre _tenantId field'ına bakıyor, bir sabite değil. Model bir kez kurulur ve cache'lenir; EF Core field referansını gördüğünde değeri her sorguda o anki context instance'ından parametre olarak okur. Filtreye scope.TenantId gibi bir değeri doğrudan gömseydik, ilk isteğin tenant'ı modele yapışırdı. İkincisi, tenant filtresi elle tek tek değil, ITenantOwned uygulayan her entity için döngüyle ekleniyor. Yarın Shipment entity'si eklenirse interface'i uyguladığı anda filtreyi de alır.
Invoice'a bir CustomerId kolonu ekledim. Owner filtresini Order navigation'ı üzerinden de yazabilirdik, ama EF Core dokümantasyonu filtreli entity'lere required navigation ile erişmenin INNER JOIN yüzünden beklenmedik sonuç verebileceği konusunda uyarıyor. Multi-tenant tablolarda tenant ve owner kolonunu her satırda tutmak hem filtreyi basitleştirir hem de index'lenebilir kılar.
Endpoint'ler şimdi filtresiz yazılıyor. Liste endpoint'i neredeyse çıplak:
// Filtre yok: tenant ve müşteri kapsamını DbContext ekliyor
orders.MapGet("/", async (OrdersDbContext db, CancellationToken ct) =>
await db.Orders
.OrderByDescending(o => o.CreatedAt)
.Select(o => new OrderSummary(o.Id, o.Status.ToString(), o.Total, o.CreatedAt))
.ToListAsync(ct));
Alan'ın detay isteğinde EF Core'un ürettiği SQL'e (Structured Query Language) bakalım. Endpoint'te yalnız o.Id == orderId yazdık:
Executed DbCommand (0ms) [Parameters=[@ef_filter___tenantId='?' (Size = 5), @ef_filter__p3='?' (DbType = Boolean), @ef_filter___customerId='?' (DbType = Guid), @orderId='?' (DbType = Guid)], CommandType='Text', CommandTimeout='30']
SELECT "o"."Id", "o"."CustomerId", CAST("o"."Status" AS TEXT), "o"."Total", "o"."InternalCost", "o"."ShippingAddress", "o"."Note", "o"."CreatedAt"
FROM "Orders" AS "o"
WHERE "o"."TenantId" = @ef_filter___tenantId AND (@ef_filter__p3 OR "o"."CustomerId" = @ef_filter___customerId) AND "o"."Id" = @orderId
LIMIT 1
_customerId == null kısmı SQL'de @ef_filter__p3 adlı bir boolean parametreye dönüşmüş; EF Core bu karşılaştırmayı veritabanına göndermeden istemci tarafında hesaplıyor. Alan için false, Margaret için true. Aynı istek curl ile:
$ curl -i http://localhost:19512/orders/0197f001-0000-7000-8000-000000000003 -H "Authorization: Bearer $ALAN"
HTTP/1.1 404 Not Found
Content-Length: 0
Date: Sun, 08 Nov 2026 05:33:20 GMT
Server: Kestrel
Kapsamsız Sorgu: Yalnız Açıkça İşaretlenmiş Yerde
Filtreyi kapatmak bazen gerçekten gerekir: platform seviyesinde bir rapor, tenant'lar arası bir migration ya da aşağıda göreceğimiz tespit sorgusu. Sorun, IgnoreQueryFilters() çağrısının kod tabanına dağılması. Benim kuralım şu: bu çağrı yalnız tek bir class'ta yaşar ve oradaki her method neden kapsam dışına çıktığını adıyla söyler.
// IgnoreQueryFilters yalnız bu dosyada: kapsam dışına çıkan her sorgu adıyla ve gerekçesiyle burada durur
public sealed class ScopeProbe(OrdersDbContext db, DataScope scope, ILogger<ScopeProbe> logger)
{
// Tespit amaçlı: istenen kayıt var ama çağıranın kapsamında değil mi?
public async Task ReportIfOutOfScopeAsync<TEntity>(Guid id, CancellationToken ct)
where TEntity : class, ITenantOwned
{
bool existsElsewhere = await db.Set<TEntity>()
.IgnoreQueryFilters()
.AnyAsync(e => EF.Property<Guid>(e, "Id") == id, ct);
if (existsElsewhere)
{
logger.LogWarning("Out-of-scope {Resource} {ResourceId} requested by tenant {TenantId}, customer {CustomerId}",
typeof(TEntity).Name, id, scope.TenantId, scope.CustomerId);
}
}
}
Detay endpoint'i sonuç boş geldiğinde bu method'u çağırıyor. Cevap her durumda 404; yalnız log'a fark yansıyor. Alan'ın az önceki isteği şu satırı bıraktı:
warn: Bookstore.Orders.Api.Data.ScopeProbe[0]
Out-of-scope Order 0197f001-0000-7000-8000-000000000003 requested by tenant north, customer 0197f000-0000-7000-8000-000000000003
Var olmayan bir id'yi denemek çoğu zaman yazım hatasıdır. Başka bir tenant'ta gerçekten var olan bir id'yi denemek ise ya bir entegrasyon hatası ya da birinin id topladığının işaretidir. Bu ayrım alarm kurarken çok işe yarıyor; telemetry tarafını serinin onuncu yazısında bağlayacağız. Ek sorgunun maliyeti yalnız 404 yolunda ödeniyor ve her 404'te çalıştığı için yanıt süresi kaydın var olup olmamasına göre değişmiyor.
Kuralın kalıcı olması için iki test yazıyoruz. Biri modele bakıyor, diğeri kaynak koda:
[Fact]
public void Every_tenant_owned_entity_has_a_tenant_filter()
{
using IServiceScope scope = factory.Services.CreateScope();
OrdersDbContext db = scope.ServiceProvider.GetRequiredService<OrdersDbContext>();
string[] missing = db.Model.GetEntityTypes()
.Where(t => typeof(ITenantOwned).IsAssignableFrom(t.ClrType))
.Where(t => t.FindDeclaredQueryFilter(OrdersDbContext.TenantFilter) is null)
.Select(t => t.ClrType.Name)
.ToArray();
Assert.Empty(missing);
}
[Fact]
public void IgnoreQueryFilters_is_used_only_in_ScopeProbe()
{
string source = Path.GetFullPath(Path.Combine(AppContext.BaseDirectory,
"../../../../../src/Bookstore.Orders.Api"));
string[] offenders = Directory.EnumerateFiles(source, "*.cs", SearchOption.AllDirectories)
.Where(f => !f.Contains($"{Path.DirectorySeparatorChar}obj{Path.DirectorySeparatorChar}"))
.Where(f => File.ReadAllText(f).Contains("IgnoreQueryFilters"))
.Select(Path.GetFileName)
.Where(name => name != "ScopeProbe.cs")
.ToArray()!;
Assert.Empty(offenders);
}
İkinci test kaba görünebilir; bir Roslyn analyzer'ı daha zarif olurdu. Ama işini görüyor: biri başka bir dosyada filtreyi kapatırsa build kırılıyor ve pull request'te bu satırı açıklamak zorunda kalıyor.
Alt Kaynaklar, Body'deki Id'ler ve Toplu İşlemler
Filtre tek başına her şeyi çözmüyor. Fatura endpoint'ine bak: Margaret North'un destek ekibinde, hem Alan'ın siparişi hem Grace'in faturası onun kapsamında. /orders/{alanOrder}/invoices/{graceInvoice} isteğinde filtre iki kaydı da geçirir. Eksik olan kapsam değil, ilişki: fatura bu siparişe mi ait? Bu yüzden alt kaynak sorgusu ebeveyn id'sini de koşula katıyor:
// Alt kaynak: kapsam filtresi + ebeveyn ilişkisi birlikte
orders.MapGet("/{orderId:guid}/invoices/{invoiceId:guid}",
async (Guid orderId, Guid invoiceId, OrdersDbContext db, CancellationToken ct) =>
await db.Invoices
.Where(i => i.Id == invoiceId && i.OrderId == orderId)
.Select(i => new InvoiceDetails(i.Id, i.OrderId, i.Number, i.Amount))
.FirstOrDefaultAsync(ct) is { } invoice
? Results.Ok(invoice)
: Results.NotFound());
Matrise margaret, alan-order+grace-invoice, 404 satırını ekledim; ilişki koşulunu biri silerse bu satır kırılır.
Body'deki id'ler için ekstra bir şey yapmamıza gerek kalmadı: POST /orders kitabı db.Books üzerinden arıyor ve Book da ITenantOwned. Linus North'un kitabını gönderdiğinde filtre kitabı gizliyor, endpoint "Unknown book" ile 400 dönüyor. Bir önceki yazıdaki kırmızı satırın düzelmesi için endpoint koduna dokunmadık.
Toplu işlemler daha sinsi. ExecuteUpdateAsync change tracker'ı atlayıp doğrudan SQL UPDATE üretiyor; filtrelerin burada da uygulandığını görmek istedim. Margaret'ın toplu iptalinde üretilen SQL:
UPDATE "Orders" AS "o"
SET "Status" = @p
WHERE "o"."TenantId" = @ef_filter___tenantId AND @ef_filter__p3 AND "o"."Id" = @ids1 AND "o"."Status" = 'Pending'
Filtre orada. Ama toplu endpoint'te bir tasarım kararı daha var: listede kapsam dışı bir id varsa ne yapmalı? Sessizce atlayıp "1 sipariş iptal edildi" demek ilk bakışta zararsız. Oysa istemci beş id gönderip dört sonuç aldığında hatayı fark etmeyebilir. Biz "ya hepsi ya hiçbiri" kuralını seçtik:
orders.MapPost("/cancel", async (BulkCancelRequest request, OrdersDbContext db, CancellationToken ct) =>
{
Guid[] ids = request.OrderIds.Distinct().ToArray();
// Ya hepsi kapsamda ya hiçbiri: kapsam dışı id'leri sessizce atlamak hatayı gizler
int visible = await db.Orders.CountAsync(o => ids.Contains(o.Id), ct);
if (visible != ids.Length)
{
return Results.ValidationProblem(new Dictionary<string, string[]>
{
["orderIds"] = ["One or more orders are unknown."]
});
}
int cancelled = await db.Orders
.Where(o => ids.Contains(o.Id) && o.Status == OrderStatus.Pending)
.ExecuteUpdateAsync(s => s.SetProperty(o => o.Status, OrderStatus.Cancelled), ct);
return Results.Ok(new BulkCancelResult(ids.Length, cancelled));
}).RequireAuthorization(LabPolicies.Staff);
$ curl -s -X POST http://localhost:19512/orders/cancel -H "Authorization: Bearer $MARGARET" -H "Content-Type: application/json" -d '{"orderIds":["0197f001-0000-7000-8000-000000000002","0197f001-0000-7000-8000-000000000003"]}'
{"type":"https://tools.ietf.org/html/rfc9110#section-15.5.1","title":"One or more validation errors occurred.","status":400,"errors":{"orderIds":["One or more orders are unknown."]},"traceId":"00-d70a403f88ed9d8987f951ac50970532-07db92946df7ffdf-00"}
$ curl -s -X POST http://localhost:19512/orders/cancel -H "Authorization: Bearer $MARGARET" -H "Content-Type: application/json" -d '{"orderIds":["0197f001-0000-7000-8000-000000000002"]}'
{"requested":1,"cancelled":1}
Hata mesajı hangi id'nin bilinmediğini söylemiyor. Söylese de çok bir şey sızmazdı, çünkü id'leri istemci kendisi gönderdi; ama mesajı sade tutmak kapsam dışı ve hiç var olmayan id'yi ayırt etmeyi de imkânsız kılıyor.
Kapsamın Yetmediği Yer: Resource-Based Authorization
Kapsam "bu kaydı görebilir mi?" sorusunu cevaplıyor. "Bu kayıt üzerinde bu işlemi yapabilir mi?" ise ayrı bir soru. Sipariş iptalinde kural şöyle: müşteri yalnız kendi Pending siparişini, support Pending ya da Paid siparişi, admin iptal edilmemiş her siparişi iptal edebilir. Bu, ASP.NET Core'un resource-based authorization modeline tam oturuyor:
public sealed class OrderAuthorizationHandler : AuthorizationHandler<OperationAuthorizationRequirement, Order>
{
protected override Task HandleRequirementAsync(AuthorizationHandlerContext context,
OperationAuthorizationRequirement requirement, Order order)
{
// Kapsamı sorgu zaten uyguladı; burada yine de tenant'ı kontrol etmek ucuz bir ikinci kilit
if (order.TenantId != context.User.FindFirstValue(LabClaims.Tenant))
{
return Task.CompletedTask;
}
bool isOwner = order.CustomerId.ToString() == context.User.FindFirstValue(LabClaims.Subject);
bool allowed = requirement.Name switch
{
nameof(OrderOperations.Cancel) => context.User.FindFirstValue(LabClaims.Role) switch
{
"admin" => order.Status != OrderStatus.Cancelled,
"support" => order.Status is OrderStatus.Pending or OrderStatus.Paid,
"customer" => isOwner && order.Status == OrderStatus.Pending,
_ => false
},
_ => false
};
if (allowed)
{
context.Succeed(requirement);
}
return Task.CompletedTask;
}
}
orders.MapPost("/{orderId:guid}/cancel", async (Guid orderId, ClaimsPrincipal user, OrdersDbContext db,
IAuthorizationService authorization, CancellationToken ct) =>
{
Order? order = await db.Orders.FirstOrDefaultAsync(o => o.Id == orderId, ct);
if (order is null)
{
return Results.NotFound();
}
// Kayıt görünür ama işlem yasak: burada 403 bilgi sızdırmaz
AuthorizationResult result = await authorization.AuthorizeAsync(user, order, OrderOperations.Cancel);
if (!result.Succeeded)
{
return Results.Forbid();
}
order.Status = OrderStatus.Cancelled;
await db.SaveChangesAsync(ct);
return Results.NoContent();
});
Burada 403 ile 404 arasındaki kararın API'deki karşılığı netleşiyor. Kapsam dışındaki kayıt için 404: kaydın varlığını doğrulamak bile bir bilgi. RFC (Request for Comments) 9110 de yasak bir kaynağın varlığını gizlemek isteyen sunucunun 403 yerine 404 dönebileceğini söylüyor. Kapsam içindeki ama işleme kapalı kayıt için ise 403: kullanıcı kaydı zaten görüyor, saklayacak bir şey yok, üstelik 403 ona neyin yanlış gittiğini doğru anlatıyor. Alan kendi Paid siparişini iptal etmeye çalıştığında:
$ curl -i -X POST http://localhost:19512/orders/0197f001-0000-7000-8000-000000000001/cancel -H "Authorization: Bearer $ALAN"
HTTP/1.1 403 Forbidden
Content-Length: 0
Date: Sun, 08 Nov 2026 05:33:20 GMT
Server: Kestrel
Handler'daki tenant kontrolü kapsam filtresiyle aynı işi yapıyor gibi görünebilir. Bilinçli bir tekrar: handler bir gün başka bir yoldan, örneğin bir background job içinden filtresiz yüklenmiş bir Order ile çağrılırsa ikinci kilit devreye girer.
Matristen Başka Tenant Senaryoları Üretmek
Matrise elle "başka tenant" satırı yazmak bir önceki yazıdaki kuralla mümkün, ama unutulabilir. Oysa bu satırların hepsi aynı şablondan çıkıyor: izin verilen her istek, aynı aktör tarafından diğer tenant'ın aynı türden kaydıyla yapılırsa reddedilmeli. Bunu bir generator'a çevirdik:
// İzin verilen her satırdan "aynı istek, diğer tenant'ın kaydı" satırı türetilir:
// route'taki id için 404, body'deki id için 400
public static IEnumerable<MatrixRow> CrossTenantVariants(IEnumerable<MatrixRow> rows)
{
foreach (MatrixRow row in rows.Where(r => r.Actor != "anonymous" && r.Expected is >= 200 and < 300))
{
if (row.Resource == "-" || row.Resource.Contains('+'))
{
continue;
}
string actorTenant = SeedData.UserNamed(row.Actor).TenantId;
(string tenant, string counterpart) = Tenancy[row.Resource];
if (tenant != actorTenant)
{
continue;
}
bool inRoute = Resources[counterpart].Any(p => row.Route.Contains($"{{{p.Parameter}}}"));
yield return row with { Resource = counterpart, Expected = inRoute ? 404 : 400 };
}
}
Test class'ında Theory'ye ikinci bir MemberData ekledik: [MemberData(nameof(CrossTenantRows))]. Yeni bir endpoint matrise bir izin satırıyla girdiği anda başka tenant satırını da bedavaya alıyor. Generator'ın ürettiklerinden bazıları:
passed Endpoint_returns_expected_status(endpoint: "POST /orders", actor: "alan", resource: "south-book", expected: 400) (602ms)
passed Endpoint_returns_expected_status(endpoint: "POST /orders/cancel", actor: "margaret", resource: "linus-order", expected: 400) (0ms)
passed Endpoint_returns_expected_status(endpoint: "POST /orders/{orderId}/cancel", actor: "barbara", resource: "alan-order", expected: 404) (0ms)
passed Endpoint_returns_expected_status(endpoint: "GET /orders/{orderId}", actor: "ada", resource: "linus-order", expected: 404) (0ms)
Test run summary: Passed!
total: 41
failed: 0
succeeded: 41
skipped: 0
duration: 1s 211ms
Bir önceki yazıdaki sekiz kırmızı satırın hepsi yeşil. Endpoint kodunda yaptığımız değişiklik ise neredeyse yalnız silmek oldu: liste endpoint'indeki elle yazılmış Where'ler gitti.
Üretimde Dikkat
Id'leri UUIDv7 (Universally Unique Identifier, sürüm 7) ile üretiyoruz ve bu, sıralı integer id'lere göre tahmin etmeyi çok zorlaştırıyor. RFC 9562'ye göre UUIDv7'nin ilk 48 biti milisaniye cinsinden zaman damgası, 74 biti rastgele. Yani id kaydın oluşturulma zamanını da taşıyor ve "tahmin edilemez" değil, "tahmini zor". En önemlisi id'ler gizli değildir: URL'lerde (Uniform Resource Locator), log'larda, e-postalarda, tarayıcı geçmişinde dolaşır. Rastgele id derinlemesine savunmanın bir katmanıdır, yetkilendirmenin yerine geçmez.
Filtrenin kapsamadığı yolları bil. Database.SqlQuery, ExecuteSql gibi ham SQL çağrıları ve Dapper sorguları EF Core modelinden geçmediği için filtre almaz; bu sorgularda tenant koşulunu elle yazmak ve ayrıca test etmek gerekir. Dapper serisinde yazdığımız sorgular için de aynı kural geçerli. Veritabanı seviyesinde ikinci bir kilit istiyorsan PostgreSQL ve SQL Server'daki RLS (Row-Level Security) uygulama katmanındaki filtrenin altına güzel oturur.
DbContext pooling ile bu yapı doğrudan çalışmaz. AddDbContextPool context instance'larını istekler arasında yeniden kullanır, yani context fiilen singleton gibi davranır; tenant'ı constructor'dan almak bu modele uymaz. EF Core dokümantasyonu bu durum için pooled factory'den alınan her context'e tenant'ı set eden scoped bir factory öneriyor. Aynı dokümantasyon, dotnet ef dbcontext optimize ile üretilen compiled model'lerin global query filter'ı desteklemediğini de yazıyor; başlangıç süresi için compiled model'e geçmeden önce bunu kontrol et. Çoğu API için pooling'siz scoped context yeterince hızlı.
Tespit tarafında ScopeProbe log'u tek başına bir alarm değil. Bir kullanıcının kısa sürede çok sayıda kapsam dışı id denemesi, ya da bir tenant'tan gelen 404 oranının aniden artması anlamlı sinyaller. Log satırına id'yi ve tenant'ı yazıyoruz, kişisel veriyi yazmıyoruz.
Sık Düşülen Tuzaklar
Birinci tuzak, tenant'ı istemciden almak. X-Tenant-Id header'ı ya da /tenants/{tenantId}/orders route'u kullanılabilir, ama kapsam her zaman token'daki doğrulanmış claim'den gelmeli. Route'taki tenant id'si claim ile eşleşmiyorsa 404 dönmek gerekir.
İkincisi, filtreyi bir sabitle kurmak. HasQueryFilter(o => o.TenantId == currentTenant) gibi bir yerel değişken modele gömülür ve bütün istekler ilk tenant'ı görür. Filtre her zaman context instance'ının bir field'ına ya da property'sine bakmalı.
Üçüncüsü, liste endpoint'ini filtreleyip detay, alt kaynak ve toplu işlem endpoint'lerini unutmak. Filtre bunu büyük ölçüde çözüyor, ama alt kaynak ilişkisini (fatura bu siparişin mi?) çözmüyor. O koşul sorguda kalmalı.
Dördüncüsü, IgnoreQueryFilters()'ı "bu sorguda sorun çıkarıyor" diye eklemek. Filtre bir sorguyu bozuyorsa genelde sorgu yanlış yerde ya da yanlış context'le çalışıyordur. İstisnayı tek bir dosyada tutmak bu konuşmayı her seferinde yapmaya zorlar.
Küçük Bir Deneme
★☆☆ OrdersDbContext'teki tenant filtre döngüsünü yorum satırı yap ve testleri koştur. Hangi testler kırıldı? Every_tenant_owned_entity_has_a_tenant_filter sana ne söyledi?
★★☆ GET /orders/{orderId}/items endpoint'i ekle ve yalnız db.OrderItems.Where(i => i.OrderId == orderId) yaz. Matrise yalnız bir izin satırı ekle. Generator başka tenant satırını üretti mi, test geçti mi? Peki aynı tenant'ta başka müşterinin siparişi için? OrderItem'a neden owner filtresi de gerekiyor?
★★★ Tenant'ı route'tan alan bir sürüm tasarla: /tenants/{tenantId}/orders/{orderId}. Route'taki tenant ile claim'deki tenant farklıysa endpoint'e hiç girmeden 404 dönen bir endpoint filter yaz ve matrise iki satır ekle.
Bir sonraki yazıda API2'ye (Broken Authentication) geçiyoruz ama insan kullanıcılar yerine makineler üzerinden: B2B (Business to Business) entegrasyonlar için API key'i doğru tasarlamak, yalnız hash'ini saklamak, rotation ve revocation, HMAC (Hash-based Message Authentication Code) ile imzalı istekler ve token endpoint'lerini korumak.