Mert Özen Her Satırda Daha İleri
Backend .NET Core Web API — #23

ASP.NET Core Web API'de Authorization: Roller, Policy'ler ve Resource-Based Yetkilendirme

Mert Özen 4 Kas 2026 8 dk 2 görüntülenme
ASP.NET Core Web API'de Authorization: Roller, Policy'ler ve Resource-Based Yetkilendirme

[Authorize] ve FallbackPolicy ile başlayıp rollere, policy'lere, kendi requirement ve handler'ımıza ve resource-based authorization'a geçiyoruz. RoleClaimType tuzağını ve 401 ile 403 farkını curl ile görüyoruz.

Sürüm notu: Bu yazıdaki kodlar .NET 6 ile yazılmıştır ve .NET 10'da da değişmeden çalışır.

Önce Tahmin Et

Bir önceki yazıda API'ye kimin konuştuğunu öğrettik: login olan kullanıcı imzalı bir JWT alıyor, her istekte onu gösteriyor. Ama geçerli bir token "her şeyi yapabilirsin" demek değil. Bu yazıda ikinci kapıya, authorization'a geçiyoruz: kim neye izinli?

Önceki yazıdaki MapInboundClaims = false ayarıyla devam ediyoruz, yani token'daki claim adları değişmeden User'a geliyor. Ada'nın token'ında açıkça "role": "Admin" yazıyor. Ada, [Authorize(Roles = "Admin")] ile korunan DELETE api/products/2 endpoint'ine geçerli token'ıyla istek atıyor. Sence ne döner: 204 mü, 401 mi, 403 mü?

Kullanıcılar, Roller ve Token'daki Claim'ler

Örneği zenginleştirmek için dört kullanıcımız var. Ada yönetici, Alan ve Grace satıcı, Margaret ise müşteri. Alan 2024'ten beri satıcı, Grace iki hafta önce başladı:

        Add(new AppUser { Id = 1, Email = "ada@example.com", DisplayName = "Ada Lovelace", Roles = new[] { "Admin" } });
        Add(new AppUser { Id = 2, Email = "alan@example.com", DisplayName = "Alan Turing", Roles = new[] { "Seller" }, SellerSince = new DateTime(2024, 3, 1) });
        Add(new AppUser { Id = 3, Email = "grace@example.com", DisplayName = "Grace Hopper", Roles = new[] { "Seller" }, SellerSince = new DateTime(2026, 10, 20) });
        Add(new AppUser { Id = 4, Email = "margaret@example.com", DisplayName = "Margaret Hamilton", Roles = new[] { "Customer" } });

TokenService artık rolleri ve satıcılık başlangıç tarihini de token'a yazıyor:

        var claims = new List<Claim>
        {
            new Claim(JwtRegisteredClaimNames.Sub, user.Id.ToString()),
            new Claim("name", user.DisplayName)
        };
        // Her rol ayrı bir "role" claim'i olur
        claims.AddRange(user.Roles.Select(role => new Claim("role", role)));
        if (user.SellerSince is not null)
        {
            claims.Add(new Claim("seller_since", user.SellerSince.Value.ToString("yyyy-MM-dd")));
        }

Grace'in token'ını çözünce payload şöyle görünüyor:

{
  "sub": "3",
  "name": "Grace Hopper",
  "role": "Seller",
  "seller_since": "2026-10-20",
  "aud": "ProductCatalog.Clients",
  "exp": 1793742460,
  "iss": "ProductCatalog.Api",
  "iat": 1793741560,
  "nbf": 1793741560
}

Rol de sonuçta bir claim. Authorization'ın tamamı bu claim'lere bakıp karar vermekten ibaret; farklılaşan şey kararın nerede ve nasıl yazıldığı.

RoleClaimType: Rolü Nerede Arayacağını Söyle

Tahminin cevabı: 403 Forbidden. Ada'nın token'ı geçerli, rolü de token'da duruyor, ama API onu yönetici olarak görmüyor. me endpoint'ini User.Identity.Name ve User.IsInRole("Admin") dönecek şekilde yazınca durum ortaya çıktı:

{"name":null,"isAdmin":false,"isSeller":false}

ClaimsIdentity, IsInRole çağrıldığında hangi claim tipine bakacağını RoleClaimType property'sinden öğrenir. Varsayılan değer ClaimTypes.Role, yani http://schemas.microsoft.com/ws/2008/06/identity/claims/role. MapInboundClaims açıkken JwtBearer role claim'ini bu uzun ada çevirdiği için her şey kendiliğinden çalışıyordu; eşlemeyi kapatınca bu çeviri de kalktı. Çözüm, handler'a doğru adları söylemek:

    .AddJwtBearer(options =>
    {
        options.MapInboundClaims = false;
        options.TokenValidationParameters = new TokenValidationParameters
        {
            ValidateIssuer = true,
            ValidIssuer = jwt.Issuer,
            ValidateAudience = true,
            ValidAudience = jwt.Audience,
            ValidateIssuerSigningKey = true,
            IssuerSigningKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(jwt.SigningKey)),
            ValidateLifetime = true,
            ClockSkew = TimeSpan.FromSeconds(30),
            // User.Identity.Name ve User.IsInRole bu claim'lere bakar
            NameClaimType = "name",
            RoleClaimType = "role"
        };
    });

Aynı istek artık {"name":"Ada Lovelace","isAdmin":true,"isSeller":false} dönüyor ve DELETE 204 veriyor. .NET 6'da da .NET 10'da da birebir aynı sonucu aldım.

[Authorize], [AllowAnonymous] ve FallbackPolicy

[Authorize] attribute'u bir action'ı ya da controller'ı korur. [AllowAnonymous] ise korumayı kaldırır ve her zaman kazanır. Bunları tek tek yazmak yerine kuralı tersine çevirmek daha güvenli: her endpoint varsayılan olarak korumalı olsun, açık olanları sen işaretle. Bunu FallbackPolicy yapar:

    options.FallbackPolicy = new AuthorizationPolicyBuilder()
        .RequireAuthenticatedUser()
        .Build();

FallbackPolicy, üzerinde hiçbir authorization attribute'u olmayan endpoint'lere uygulanır. AuthController'daki Me action'ının üstünde [Authorize] yok, ama token'sız istek 401 alıyor. ProductsController'ın GetAll action'ı ise [AllowAnonymous] taşıdığı için herkese açık. Bunu DefaultPolicy ile karıştırma: DefaultPolicy, parametresiz [Authorize] yazdığında kullanılan policy'dir.

401 ile 403 farkını da burada net görüyoruz. Token'sız POST api/products isteği 401 dönüyor: kim olduğunu bilmiyoruz. Margaret'in token'ıyla aynı istek 403 dönüyor: kim olduğunu biliyoruz, ama müşteri ürün ekleyemez. 403 cevabının body'si boş ve WWW-Authenticate header'ı yok; ortada çözülecek bir kimlik sorunu yok çünkü.

Rollerle Yetkilendirme

En basit yol, rolü attribute'a yazmak:

    [HttpDelete("{id:int}")]
    [Authorize(Roles = "Admin")]
    public IActionResult Delete(int id)

Alan'ın token'ıyla bu endpoint 403, Ada'nınkiyle 204 dönüyor. İki kuralı bilmen yeter. [Authorize(Roles = "Admin,Seller")] içindeki virgül "veya" demek: Alan bu endpoint'e girebildi. Alt alta iki ayrı [Authorize(Roles = ...)] yazarsan bu kez "ve" olur: Admin ve Seller attribute'larını birlikte taşıyan endpoint'e ne Ada ne Alan girebildi, çünkü ikisi de iki role birden sahip değil.

Roller küçük projelerde iş görür, ama kural büyüdükçe controller'lara dağılmış "Admin,Seller" string'leri bakımı zorlaştırır. "Ürün yönetebilecekler kimler?" sorusunun cevabı tek bir yerde durmalı. Policy'ler tam bunun için var.

Policy'ler

Policy, bir isim altında toplanmış bir ya da daha fazla requirement'tır. Program.cs'te bir kez tanımlarsın, controller'da sadece adını anarsın:

builder.Services.AddAuthorization(options =>
{
    options.AddPolicy("CanManageProducts", policy =>
        policy.RequireRole("Admin", "Seller"));

    // Bir policy'deki tüm requirement'lar sağlanmalı
    options.AddPolicy("ExperiencedSeller", policy =>
        policy.RequireRole("Seller")
              .AddRequirements(new MinimumSellerTenureRequirement(30)));

    options.AddPolicy("ProductOwner", policy =>
        policy.AddRequirements(new ProductOwnerRequirement()));

    options.FallbackPolicy = new AuthorizationPolicyBuilder()
        .RequireAuthenticatedUser()
        .Build();
});

Controller'da kullanımı:

    [HttpPost]
    [Authorize(Policy = "CanManageProducts")]
    public ActionResult<Product> Create(ProductRequest request)

RequireRole dışında RequireClaim("seller_since") gibi bir claim'in varlığını ya da RequireClaim("role", "Seller") gibi değerini isteyen hazır method'lar da var. Kural bunlarla anlatılamayacak kadar özelse kendi requirement'ını yazarsın.

Policy adı bir string olduğu için yazım hatası derleyiciye takılmaz. Bir action'a bilerek "CanManageProduct" yazdığımda istek 500 döndü:

System.InvalidOperationException: The AuthorizationPolicy named: 'CanManageProduct' was not found.

Adları bir static class içinde const string olarak tutmak bu hatayı derleme zamanına taşır.

Requirement ve Handler

İş kuralımız şu: indirim yapabilmek için en az 30 gündür satıcı olmak gerekiyor. Bunu bir requirement ve onu değerlendiren bir handler ile yazıyoruz:

using System.Globalization;
using Microsoft.AspNetCore.Authorization;

namespace ProductCatalog.Auth;

public class MinimumSellerTenureRequirement : IAuthorizationRequirement
{
    public MinimumSellerTenureRequirement(int days)
    {
        Days = days;
    }

    public int Days { get; }
}

public class MinimumSellerTenureHandler : AuthorizationHandler<MinimumSellerTenureRequirement>
{
    protected override Task HandleRequirementAsync(
        AuthorizationHandlerContext context,
        MinimumSellerTenureRequirement requirement)
    {
        var claim = context.User.FindFirst("seller_since");
        if (claim is not null &&
            DateTime.TryParseExact(claim.Value, "yyyy-MM-dd", CultureInfo.InvariantCulture, DateTimeStyles.None, out var since) &&
            since.AddDays(requirement.Days) <= DateTime.UtcNow)
        {
            context.Succeed(requirement);
        }

        // Succeed çağrılmazsa requirement sağlanmamış sayılır
        return Task.CompletedTask;
    }
}

Requirement sadece veriyi taşır (kaç gün?), karar handler'dadır. Handler'ları DI'a kaydetmeyi unutma:

builder.Services.AddSingleton<IAuthorizationHandler, MinimumSellerTenureHandler>();
builder.Services.AddSingleton<IAuthorizationHandler, ProductOwnerHandler>();

POST api/products/1/discount isteğinde sonuçlar: Alan 200 alıyor ve klavyenin fiyatı 1450'den 1350'ye iniyor. Grace 403 alıyor, çünkü satıcılığı 30 günü doldurmadı. Ada da 403 alıyor. Yönetici olması burada işe yaramıyor, çünkü ExperiencedSeller policy'si hem Seller rolünü hem de süre şartını istiyor ve bir policy'deki bütün requirement'ların sağlanması gerekiyor. Yöneticilere de izin vermek istersen handler'a context.User.IsInRole("Admin") kontrolünü ekler, rol şartını da policy'den çıkarırsın.

Resource-Based Authorization

Bazı kararlar sadece kullanıcıya bakarak verilemez. "Satıcı sadece kendi ürününü güncelleyebilir" kuralı için ürünü de görmen gerekir, ürün de ancak action'ın içinde, veritabanından okununca belli olur. Attribute bu noktada yetmez; handler'a kaynağı da veren generic AuthorizationHandler<TRequirement, TResource> kullanırız:

using Microsoft.AspNetCore.Authorization;
using ProductCatalog.Products;

namespace ProductCatalog.Auth;

public class ProductOwnerRequirement : IAuthorizationRequirement
{
}

public class ProductOwnerHandler : AuthorizationHandler<ProductOwnerRequirement, Product>
{
    protected override Task HandleRequirementAsync(
        AuthorizationHandlerContext context,
        ProductOwnerRequirement requirement,
        Product resource)
    {
        // Yönetici her ürünü, satıcı sadece kendi ürününü güncelleyebilir
        var userId = context.User.FindFirst("sub")?.Value;
        if (context.User.IsInRole("Admin") || userId == resource.SellerId.ToString())
        {
            context.Succeed(requirement);
        }

        return Task.CompletedTask;
    }
}

Action'da IAuthorizationService'i kendimiz çağırıyoruz:

    [HttpPut("{id:int}")]
    [Authorize(Policy = "CanManageProducts")]
    public async Task<IActionResult> Update(int id, ProductRequest request)
    {
        var product = _store.Find(id);
        if (product is null)
        {
            return NotFound();
        }

        // Kararı ürünü gördükten sonra veriyoruz
        var result = await _authorizationService.AuthorizeAsync(User, product, "ProductOwner");
        if (!result.Succeeded)
        {
            return Forbid();
        }

        product.Name = request.Name;
        product.Price = request.Price;
        return NoContent();
    }

1 numaralı ürün Alan'ın. Grace onu güncellemeye çalışınca 403, Alan çalışınca 204 alıyor. Ada, Grace'in mouse'unu güncelleyebiliyor, çünkü handler yöneticiye izin veriyor. Forbid() kimliği doğrulanmış kullanıcı için 403 üretir; [Authorize(Policy = "CanManageProducts")] attribute'u da token'sız istekleri action'a hiç sokmadan 401 ile geri çevirir.

Sık Düşülen Tuzaklar

Rol claim'inin adını eşleştirmemek. Baştaki örnek: claim token'da duruyor ama RoleClaimType farklı bir ad bekliyorsa IsInRole false döner ve 403 alırsın.

401 ile 403'ü karıştırmak. Kimlik yoksa 401, kimlik var ama izin yoksa 403. İstemci 401'de yeniden login olmayı dener; 403'te bunun bir anlamı yoktur.

Kaynak kontrolünü sadece listede yapmak. Ürün listesini satıcıya göre filtrelemek, PUT api/products/1 isteğinin elle atılmasını engellemez. Her değişiklik endpoint'inde resource-based kontrolü tekrar yap.

Handler'ı DI'a kaydetmeyi unutmak. Kaydı olmayan bir requirement'ı hiçbir handler sağlamaz; kural sessizce herkese 403 döner.

Yetki kontrolünü istemciye bırakmak. Butonu gizlemek bir kullanıcı deneyimi kararıdır, güvenlik değil. Asıl kontrol her zaman API'de olmalı.

Küçük Bir Deneme

★☆☆ Policy adlarını Policies adında bir static class içinde const string olarak topla ve bütün [Authorize(Policy = ...)] kullanımlarını bu sabitlere çevir.

★★☆ ExperiencedSeller policy'sini yöneticilerin de geçebileceği şekilde değiştir. Ada'nın 200, Grace'in hâlâ 403 aldığını curl ile doğrula.

★★★ DELETE action'ını da resource-based hâle getir: satıcı kendi ürününü silebilsin ama sadece fiyatı 1000'in altındaysa; yönetici her ürünü silebilsin.

Kimin neye izinli olduğu artık tek bir yerde tanımlı. Sıradaki soru farklı: API'yi tarayıcıda çalışan bir frontend'den çağırdığında, token'ın doğru olsa bile istek neden engelleniyor? Bir sonraki yazıda CORS'u, HTTPS ve HSTS'yi, güvenlik header'larını ve secret yönetimini ele alacağız.