Mert Özen Her Satırda Daha İleri
Güvenlik API Güvenliği (OWASP API Top 10) — #04

API3 BOPLA: Property Seviyesinde Yetkilendirme, PATCH İstekleri ve Response Shaping

Mert Özen 8 Kas 2026 12 dk 2 görüntülenme
API3 BOPLA: Property Seviyesinde Yetkilendirme, PATCH İstekleri ve Response Shaping

BOPLA'yı serializer ayarıyla değil, rol başına ayrı response tipleri ve rol başına yazılabilir alan allowlist'iyle kapatırsın. Mapperly ile yeni alan eklenince build'i kıran projection'lar, farklı görünümü ayrı endpoint ve policy'ye taşımak, JSON Merge Patch'te kilitli alanları reddetmek ve OpenAPI şemasını testle korunan bir sözleşme olarak kullanmak.

Sürüm notu: Bu yazıdaki kodlar .NET 10, EF (Entity Framework) Core 10.0.14, Riok.Mapperly 4.3.1 ve Microsoft.AspNetCore.OpenApi 10.0.14 ile derlenip denenmiştir.

Önce Tahmin Et

Lab'ın ilk sürümünde GET /orders/{orderId} tek bir DTO (Data Transfer Object) dönüyordu: OrderDetails. İçinde InternalCost da vardı, çünkü destek ekibinin ekranı bu alanı gösteriyordu. Bir önceki iki yazıda kimin hangi siparişi görebileceğini çözdük. Şimdi soru: Alan kendi siparişini istediğinde yanıtta ne var? Peki aynı endpoint'e PATCH ile {"internalCost": 0} gönderirse?

İlk sorunun cevabı can sıkıcı: Alan kendi siparişini görmeye yetkili, ama o siparişin bize kaça mal olduğunu görmeye değil. Kayıt seviyesinde her şey doğru, property seviyesinde değil. API3'ün (OWASP API (Application Programming Interface) Security Top 10 2023'teki üçüncü madde) konusu tam bu: BOPLA (Broken Object Property Level Authorization). İkinci sorunun cevabı ise endpoint'in nasıl yazıldığına bağlı ve bu yazının ikinci yarısı.

Mass assignment'ın ve JsonUnmappedMemberHandling'in temeli Web Güvenliği (OWASP Top 10) serisinin A08 Software or Data Integrity Failures yazısında. Orada "body'de bilinmeyen alan gelirse reddet" demiştik. Burada bir adım ileri gidiyoruz: alan biliniyor, model'de var, ama bu rol için yazılabilir değil.

BOPLA'nın İki Yüzü

OWASP (Open Worldwide Application Security Project) 2023 sürümünde 2019'daki iki maddeyi birleştirdi: Excessive Data Exposure (fazla veri döndürmek) ve Mass Assignment (fazla veri kabul etmek). İkisinin ortak kökü aynı: endpoint'in hangi property'leri okuyup yazabileceği, isteği yapanın kim olduğuna göre kontrol edilmiyor. Okuma tarafında sızıntı genelde "istemci zaten göstermiyor" düşüncesinden gelir. Angular ekranı internalCost'u render etmese de JSON'da (JavaScript Object Notation) durur; tarayıcının network sekmesini açan herkes görür.

Yazma tarafında sorun daha tehlikeli, çünkü veri değişiyor. Bir müşteri total'ı, tenantId'yi ya da status'u değiştirebiliyorsa, yetkilendirme kayıt seviyesinde ne kadar sağlam olursa olsun iş kuralları delinmiştir. İkisine de aynı ilkeyle yaklaşacağız: hangi rolün hangi alanı okuyup yazabildiği koda dağılmış if'lerde değil, tiplerde ve tek bir allowlist'te yazılı olmalı.

Rol Başına Response Tipi

İlk adım entity'yi ya da "her şeyi içeren" tek bir DTO'yu dışarı vermemek. Her izleyici için ayrı bir record yazıyoruz:

// Rol başına ayrı response tipi: hangi alanın kime görüneceği tipte yazılı
public sealed record CustomerOrderView(Guid Id, string Status, decimal Total, string ShippingAddress,
    string? Note, DateTime CreatedAt);

public sealed record StaffOrderView(Guid Id, Guid CustomerId, string Status, decimal Total, decimal InternalCost,
    string ShippingAddress, string? Note, DateTime CreatedAt);

Bunun alternatifi tek DTO'da [JsonIgnore(Condition = ...)] ya da bir JsonConverter ile alanları role göre gizlemek. Bu yolu sevmiyorum. Serializer ayarı global bir şeydir: biri JsonSerializerOptions'ı değiştirir, bir test projesi farklı ayarla serialize eder, bir log satırı DTO'yu olduğu gibi yazar ya da bir cache katmanı nesneyi başka bir serializer'la saklar. Alan tipte yoksa bunların hiçbiri onu sızdıramaz.

Mapperly: Yeni Alan Eklenince Build'in Kırılması

Elle yazılan mapping'in sorunu, entity'ye yeni bir alan eklendiğinde kimsenin "bu alan hangi görünüme girmeli?" diye sormaması. Source generator tabanlı Mapperly bu soruyu derleme zamanında sordurabiliyor:

[Mapper]
public static partial class OrderViewMapper
{
    public static partial IQueryable<CustomerOrderView> ProjectToCustomerView(this IQueryable<Order> orders);
    public static partial IQueryable<StaffOrderView> ProjectToStaffView(this IQueryable<Order> orders);

    // Müşteriye gitmeyen her alan burada adıyla ve bilinçli olarak dışarıda bırakılıyor
    [MapperIgnoreSource(nameof(Order.TenantId))]
    [MapperIgnoreSource(nameof(Order.CustomerId))]
    [MapperIgnoreSource(nameof(Order.InternalCost))]
    [MapperIgnoreSource(nameof(Order.Items))]
    [MapperIgnoreSource(nameof(Order.Invoices))]
    private static partial CustomerOrderView ToCustomerView(Order order);

    [MapperIgnoreSource(nameof(Order.TenantId))]
    [MapperIgnoreSource(nameof(Order.Items))]
    [MapperIgnoreSource(nameof(Order.Invoices))]
    private static partial StaffOrderView ToStaffView(Order order);
}

IQueryable projection'ları EF Core'un SQL'e (Structured Query Language) çevirebileceği bir Select üretiyor; veritabanından yalnız görünümdeki kolonlar geliyor. İlk denememde ignore attribute'larını doğrudan projection method'una yazmıştım ve Mapperly "queryable projection'a object mapping ayarı yapılamaz" diye RMG065 (Mapperly'nin tanı kodu) uyarısı verdi. Ayarlar private bir object mapping method'unda duruyor, projection onu kullanıyor.

Asıl kazanç proje dosyasındaki bir satırda:

<PropertyGroup>
  <!-- Mapperly: eşlenmemiş kaynak/hedef alanı build hatası -->
  <WarningsAsErrors>$(WarningsAsErrors);RMG012;RMG020</WarningsAsErrors>
</PropertyGroup>

RMG020 (Mapperly tanı kodu), kaynak tipteki bir property'nin hedefte karşılığı olmadığında verilen uyarı; RMG012 (yine Mapperly) ise ters yön. İkisini hataya çevirince "her alan için bilinçli bir karar" kuralı derleyiciye geçiyor. Deneyelim: Order'a bir FraudScore alanı ekliyorum.

$ dotnet build src/Bookstore.Orders.Api
src/Bookstore.Orders.Api/Endpoints/OrderViews.cs(20,5): error RMG020: The member FraudScore on the mapping source type Bookstore.Orders.Api.Domain.Order is not mapped to any member on the mapping target type Bookstore.Orders.Api.Endpoints.CustomerOrderView
src/Bookstore.Orders.Api/Endpoints/OrderViews.cs(27,5): error RMG020: The member FraudScore on the mapping source type Bookstore.Orders.Api.Domain.Order is not mapped to any member on the mapping target type Bookstore.Orders.Api.Endpoints.StaffOrderView

Alanı ekleyen kişi şimdi iki görünüm için ayrı ayrı karar vermek zorunda: ya görünüme ekleyecek ya da [MapperIgnoreSource] ile dışarıda bıraktığını yazacak. İkinci seçenek pull request'te açıkça görünüyor ve review'da "müşteri bu skoru görmeli mi?" sorusunu doğal olarak açıyor.

Farklı Görünüm, Farklı Endpoint

İlk tasarımımda tek endpoint vardı: GET /orders/{orderId}, role göre Ok<CustomerOrderView> ya da Ok<StaffOrderView> dönüyordu. Çalışıyordu, testler geçiyordu. Sonra üretilen OpenAPI dokümanına baktım: 200 yanıtı için yalnız StaffOrderView vardı, CustomerOrderView şema listesinde bile yoktu. ASP.NET Core 10'da aynı durum kodu için birden fazla yanıt tipi tanımlanırsa yalnız biri dokümana yazılıyor; ASP.NET Core 11 sürüm notları bu davranışın değiştiğini, artık her tipin korunduğunu söylüyor. Yani sözleşme, müşteriye maliyet alanının döndüğünü iddia ediyordu.

Bu küçük aksilik aslında doğru tasarımı gösterdi. Görünümü role göre endpoint içinde seçmek, property seviyesindeki bir kararı runtime'daki bir if'e bağlıyor. Görünümleri ayrı endpoint'lere koymak ise o kararı function seviyesine, yani bir policy'ye taşıyor:

// Herkes için tek şema: müşteri görünümü
orders.MapGet("/{orderId:guid}", async Task<Results<Ok<CustomerOrderView>, NotFound>> (
    Guid orderId, OrdersDbContext db, ScopeProbe probe, CancellationToken ct) =>
{
    if (await db.Orders.Where(o => o.Id == orderId).ProjectToCustomerView().FirstOrDefaultAsync(ct)
        is { } view)
    {
        return TypedResults.Ok(view);
    }
    await probe.ReportIfOutOfScopeAsync<Order>(orderId, ct);
    return TypedResults.NotFound();
});

// Maliyet alanını içeren görünüm ayrı bir endpoint'te ve ayrı bir policy arkasında
app.MapGet("/support/orders/{orderId:guid}", async Task<Results<Ok<StaffOrderView>, NotFound>> (
    Guid orderId, OrdersDbContext db, CancellationToken ct) =>
    await db.Orders.Where(o => o.Id == orderId).ProjectToStaffView().FirstOrDefaultAsync(ct) is { } view
        ? TypedResults.Ok(view)
        : TypedResults.NotFound())
    .RequireAuthorization(LabPolicies.Staff);

Artık her endpoint'in tek bir yanıt şeması var ve hangi alanın kime gittiği, matristeki satırlarla test edilen bir policy'ye bağlı:

$ curl -s http://localhost:19514/orders/0197f001-0000-7000-8000-000000000001 -H "Authorization: Bearer $ALAN"
{"id":"0197f001-0000-7000-8000-000000000001","status":"Paid","total":54.9,"shippingAddress":"alan street 1","note":null,"createdAt":"2026-11-02T10:00:00"}
$ curl -s http://localhost:19514/support/orders/0197f001-0000-7000-8000-000000000001 -H "Authorization: Bearer $MARGARET"
{"id":"0197f001-0000-7000-8000-000000000001","customerId":"0197f000-0000-7000-8000-000000000003","status":"Paid","total":54.9,"internalCost":31.2,"shippingAddress":"alan street 1","note":null,"createdAt":"2026-11-02T10:00:00"}
$ curl -s -o /dev/null -w "%{http_code}\n" http://localhost:19514/support/orders/0197f001-0000-7000-8000-000000000001 -H "Authorization: Bearer $ALAN"
403

Burada 403 doğru cevap: Alan bu endpoint'in var olduğunu bilse de bir şey kaybetmiyoruz, ve kayıt seviyesindeki kapsam (/support/orders'ta da global query filter çalışıyor) başka tenant'ın siparişini yine 404 ile saklıyor.

PATCH: Yazılabilir Alan Allowlist'i

Kısmi güncelleme için iki standart var: RFC (Request for Comments) 7396 JSON Merge Patch ve RFC 6902 JSON Patch. Merge patch basit: istemci değiştirmek istediği alanları içeren bir JSON object gönderir, null alanı temizler. JSON Patch ise op, path, value işlemlerinden oluşan bir liste; .NET 10 ile System.Text.Json tabanlı Microsoft.AspNetCore.JsonPatch.SystemTextJson paketi de geldi. Hangisini seçersen seç, güvenlik açısından kural aynı: işlemin dokunduğu her alan, o rol için yazılabilir alanlar listesinde olmalı. Biz merge patch kullanıyoruz:

public const string MediaType = "application/merge-patch+json";

// Rol başına yazılabilir alan allowlist'i; burada olmayan her alan reddedilir
private static readonly Dictionary<string, string[]> Writable = new()
{
    ["customer"] = ["shippingAddress", "note"],
    ["support"] = ["shippingAddress", "note", "status"],
    ["admin"] = ["shippingAddress", "note", "status"]
};

public static Dictionary<string, string[]> Validate(JsonObject patch, string role, Order order)
{
    string[] allowed = Writable.GetValueOrDefault(role, []);
    Dictionary<string, string[]> errors = [];
    foreach ((string field, JsonNode? value) in patch)
    {
        // Hata mesajı yalnız istemcinin gönderdiği alan adını tekrarlar, iç modeli anlatmaz
        if (!allowed.Contains(field, StringComparer.Ordinal))
        {
            errors[field] = ["Field is not writable."];
        }
        else if (field is "shippingAddress" && (value?.GetValueKind() != JsonValueKind.String
                 || value.GetValue<string>().Length is 0 or > 200))
        {
            errors[field] = ["Must be a non-empty string up to 200 characters."];
        }
        else if (field is "note" && value is not null && (value.GetValueKind() != JsonValueKind.String
                 || value.GetValue<string>().Length > 500))
        {
            errors[field] = ["Must be null or a string up to 500 characters."];
        }
        else if (field is "status" && (value?.GetValueKind() != JsonValueKind.String
                 || !Enum.TryParse(value.GetValue<string>(), out OrderStatus _)))
        {
            errors[field] = ["Unknown status."];
        }
    }

    // Müşteri yalnız henüz kargoya verilmemiş siparişi değiştirebilir
    if (role == "customer" && order.Status is not (OrderStatus.Pending or OrderStatus.Paid) && errors.Count == 0)
    {
        errors["order"] = ["Only pending or paid orders can be changed."];
    }
    return errors;
}

Patch'i bir DTO'ya bind etmek yerine JsonObject olarak okuyorum. Sebebi merge patch'in anlamı: {"note": null} ile hiç note göndermemek farklı şeyler. Nullable property'li bir DTO bu farkı kaybeder, JsonObject korur. Aynı sebeple doğrulama "bilinmeyen alanları yok say" değil, "listede olmayan her alanı reddet" mantığıyla çalışıyor. Sessizce yok saymak istemciye isteğinin uygulandığı izlenimini verir; reddetmek ise entegrasyonu yazan kişiye hatasını hemen gösterir.

Endpoint önce media type'ı kontrol ediyor, sonra kaydı kapsam içinde yüklüyor, en son alanları doğruluyor. Sıralama bilinçli: kapsam dışındaki bir sipariş için alan hatası dönmek, siparişin var olduğunu ele verirdi.

orders.MapPatch("/{orderId:guid}", async (Guid orderId, HttpRequest request, ICurrentUser user,
    OrdersDbContext db, CancellationToken ct) =>
{
    if (request.ContentType != OrderPatch.MediaType)
    {
        return Results.StatusCode(StatusCodes.Status415UnsupportedMediaType);
    }
    if (await JsonNode.ParseAsync(request.Body, cancellationToken: ct) is not JsonObject patch)
    {
        return Results.ValidationProblem(new Dictionary<string, string[]> { [""] = ["Expected a JSON object."] });
    }

    Order? order = await db.Orders.FirstOrDefaultAsync(o => o.Id == orderId, ct);
    if (order is null)
    {
        return Results.NotFound();
    }

    Dictionary<string, string[]> errors = OrderPatch.Validate(patch, user.Role, order);
    if (errors.Count > 0)
    {
        return Results.ValidationProblem(errors);
    }

    OrderPatch.Apply(patch, order);
    await db.SaveChangesAsync(ct);
    return Results.NoContent();
})
.Accepts<OrderPatchRequest>(OrderPatch.MediaType);

Alan'ın denemeleri:

$ curl -s -X PATCH http://localhost:19514/orders/0197f001-0000-7000-8000-000000000001 -H "Authorization: Bearer $ALAN" -H "Content-Type: application/merge-patch+json" -d '{"note":"hi","internalCost":0,"tenantId":"south","total":1}'
{"type":"https://tools.ietf.org/html/rfc9110#section-15.5.1","title":"One or more validation errors occurred.","status":400,"errors":{"internalCost":["Field is not writable."],"tenantId":["Field is not writable."],"total":["Field is not writable."]},"traceId":"00-c9155db610131c1e70e785df23eb43d7-5b561089bd8eb26e-00"}
$ curl -s -o /dev/null -w "%{http_code}\n" -X PATCH http://localhost:19514/orders/0197f001-0000-7000-8000-000000000001 -H "Authorization: Bearer $ALAN" -H "Content-Type: application/merge-patch+json" -d '{"shippingAddress":"2 Harbour Road","note":null}'
204
$ curl -s -o /dev/null -w "%{http_code}\n" -X PATCH http://localhost:19514/orders/0197f001-0000-7000-8000-000000000001 -H "Authorization: Bearer $ALAN" -H "Content-Type: application/json" -d '{"note":"x"}'
415

İlk istekte note geçerli olduğu hâlde hiçbir şey kaydedilmedi: istek ya bütünüyle uygulanır ya hiç. Hata yanıtı yalnız Alan'ın gönderdiği üç alanın adını tekrarlıyor. "Field is not writable" mesajı InternalCost'un var olduğunu söylüyor gibi görünebilir, ama tenantId ya da isAdmin gibi model'de olmayan bir alan da aynı mesajı alıyor; mesaj alanın varlığını değil, yazılabilir olmadığını söylüyor. Bunun tersi, yani hata mesajında iç tipin adını, kolon adını ya da validation kütüphanesinin iç yolunu döndürmek, saldırgana model hakkında bedava bilgi verir.

OpenAPI Şeması Bir Sözleşme

Request ve response için ayrı tipler kullanmanın bir yan etkisi de dokümanın dürüst olması. Üretilen OpenAPI dokümanında PATCH body'si yalnız üç isteğe bağlı alan içeriyor:

"OrderPatchRequest": {
  "type": "object",
  "properties": {
    "shippingAddress": { "type": [ "null", "string" ] },
    "note": { "type": [ "null", "string" ] },
    "status": { "type": [ "null", "string" ] }
  }
}

OrderPatchRequest'i ilk önce positional record olarak yazmıştım ve şema üç alanı da required gösterdi; merge patch'te hiçbiri zorunlu olmadığı için init property'li bir record'a çevirdim. Aynı dokümanda CreateOrderRequest'in quantity alanı ["integer", "string"] tipinde görünüyor; ASP.NET Core'un web varsayılanları sayıların string olarak da okunmasına izin veriyor ve şema bunu olduğu gibi yansıtıyor. Bunlar küçük ayrıntılar ama sözleşmeyi okuyan entegrasyon ekibi tam olarak bunlara güveniyor.

Şemayı bir test de koruyor. Bir geliştirici yanlışlıkla InternalCost'u CustomerOrderView'a eklerse ya da CreateOrderRequest'e Total koyarsa test kırılıyor:

[Fact]
public async Task OpenApi_request_and_response_schemas_are_separate()
{
    JsonObject document = (await factory.CreateClient().GetFromJsonAsync<JsonObject>("/openapi/v1.json", Ct))!;
    JsonObject schemas = document["components"]!["schemas"]!.AsObject();

    string[] Properties(string schema) => schemas[schema]!["properties"]!.AsObject().Select(p => p.Key).ToArray();

    Assert.DoesNotContain("internalCost", Properties("CustomerOrderView"));
    Assert.Contains("internalCost", Properties("StaffOrderView"));
    Assert.Equal(["bookId", "quantity", "shippingAddress"], Properties("CreateOrderRequest"));
    Assert.Equal(["shippingAddress", "note", "status"], Properties("OrderPatchRequest"));
}

Diğer testler davranışı kontrol ediyor: müşteri görünümünde internalCost ve customerId yok, kilitli alanlarla gelen patch reddediliyor ve veritabanında hiçbir şey değişmiyor, müşteri status'u değiştiremiyor ama support değiştirebiliyor, düz application/json 415 alıyor. Matrise de PATCH /orders/{orderId} ve GET /support/orders/{orderId} için dokuz satır ekledim; bir önceki yazıdaki generator başka tenant satırlarını kendisi türetti.

Test run summary: Passed!
  total: 74
  failed: 0
  succeeded: 74
  skipped: 0
  duration: 1s 307ms

Üretimde Dikkat

Görünüm sayısını kontrol altında tut. Her rol ve her ekran için yeni bir DTO üretmek kısa sürede yönetilemez hâle gelir. Benim pratik kuralım: veri hassasiyetine göre görünüm, ekrana göre değil. "Müşteri görünümü" ve "personel görünümü" iki ayrı hassasiyet seviyesi; bir ekranın ihtiyacı farklıysa istemci tarafında kırpmak sorun değil, çünkü kırpılan veri zaten o kullanıcının görmeye yetkili olduğu veri.

Property seviyesindeki kararlar log'a ve hata mesajlarına da uzanır. Exception middleware'inin entity'yi serialize ettiği bir hata yanıtı, en dikkatli DTO tasarımını bir anda boşa çıkarır. Hata yanıtları ProblemDetails ile sınırlı olmalı; ayrıntısını Web Güvenliği serisinin A10 yazısında konuşmuştuk.

Cache katmanlarına dikkat. Output cache ya da bir CDN (Content Delivery Network) rol ayrımı yapmadan aynı URL'ye (Uniform Resource Locator) aynı yanıtı dönerse, bir personelin gördüğü yanıt müşteriye gidebilir. Görünümleri ayrı URL'lere koymanın bir faydası da bu: cache key'i zaten farklı. Kimlik doğrulaması gerektiren yanıtlarda Cache-Control ayarını API8 (Security Misconfiguration) yazısında ele alacağız.

Son olarak GraphQL ya da OData gibi istemcinin alan seçtiği API'lerde aynı sorun alan seviyesinde yetkilendirme olarak karşına çıkar; bu konular ilerideki gRPC ve GraphQL serisinde.

Sık Düşülen Tuzaklar

Birinci tuzak, tek DTO'yu hem okuma hem yazma için kullanmak. OrderDto'yu GET'te döndürüp PUT'ta kabul etmek, okunabilir her alanı yazılabilir yapar. Id, Total, CreatedAt gibi alanlar okuma DTO'sunda olmalı, yazma DTO'sunda asla.

İkincisi, yetkiyi istemcinin gönderdiği değerlerle doğrulamak. if (patch.Total == order.Total) gibi "değişmemişse sorun yok" kontrolleri, istemcinin alanı hiç göndermemesi gerektiği gerçeğini gizler ve bir sonraki refactor'da kaybolur. Alan listede değilse, değeri ne olursa olsun reddet.

Üçüncüsü, [JsonIgnore]'u yetkilendirme aracı sanmak. Entity üzerindeki [JsonIgnore] o alanı her yerde gizler; ama ikinci bir serializer, bir log formatter ya da bir mesaj kuyruğu bu attribute'u tanımayabilir. Üstelik "support görsün, müşteri görmesin" gibi rol bazlı bir kuralı ifade edemez.

Dördüncüsü, hata mesajında iç ayrıntı vermek. "Column 'InternalCost' cannot be updated by role 'customer'" gibi bir mesaj kolon adını, rol adını ve kuralın kendisini tek satırda anlatır.

Küçük Bir Deneme

★☆☆ Lab'da Margaret olarak PATCH /orders/{orderId} ile Alan'ın siparişinin status'unu Shipped yap. Sonra Alan olarak aynı siparişin adresini değiştirmeyi dene. Hangi kural devreye girdi?

★★☆ Order'a GiftMessage alanı ekle. Build hangi hatayı verdi? Alanı müşteri görünümüne ekle, personel görünümünde de göster, ve müşteri için yalnız Pending siparişte yazılabilir yap. Kaç dosyaya dokundun?

★★★ Aynı allowlist'i JSON Patch için yaz: Microsoft.AspNetCore.JsonPatch.SystemTextJson paketiyle JsonPatchDocument kabul eden bir endpoint ekle ve her işlemin path'ini allowlist'e göre doğrula. move ve copy işlemlerinde from alanını da kontrol etmeyi unutma.

Bir sonraki yazıda API4'e (Unrestricted Resource Consumption) geçiyoruz: maksimum sayfa boyutu, sort ve filtre alanı allowlist'i, request timeout'ları, pahalı sorgular için ayrı limit ve SMS (Short Message Service) ya da e-posta gibi para harcatan çağrılar için tenant başına bütçe.