Authorization Modelleri: RBAC, ABAC, ReBAC ve Permission Tabanlı Tasarım, Multi-Tenant Senaryolar
Rolleri koşullarla doldurmak rol patlamasına götürür; çözüm RBAC, ABAC ve ReBAC'ı katman olarak kullanmaktır. Rolü permission paketine indiriyor, iade kuralını tenant, limit ve zaman attribute'larıyla gerekçeli kararlar veren bir policy olarak yazıyor, Zanzibar fikrini oyuncak bir ilişki grafiğiyle gösteriyor ve hepsini testlerle sabitliyoruz; permission'ın token'a konup konmayacağını, tenant claim'ini ve impersonation denetimini tartışıyoruz.
Sürüm notu: Bu yazıdaki kodlar .NET 10, xUnit 2.9.3, Microsoft.Extensions.TimeProvider.Testing 10.11.0 ve Keycloak 26.8.2 ile derlenip denenmiştir.
Önce Tahmin Et
Bookstore'un destek ekibi iade yapabiliyor. Sonra kurallar geliyor: yalnız kendi şehrindeki mağazanın siparişleri, 500 TL'ye kadar, sipariş 30 günden eski değilse. Ardından Kadıköy mağazasının müdürü kendi mağazasının siparişlerini görmek istiyor, ama Beşiktaş'ınkileri değil. Bunları rollerle mi çözersin? SupportIstanbulRefundUpTo500 diye bir rol açtığın gün, bu yazının ilk problemiyle tanışmış olursun.
Önceki yazılar "kim bu?" sorusunu cevapladı. Bu yazı "bu kişi bunu yapabilir mi?" sorusunun tasarımı üzerine: RBAC (Role-Based Access Control), ABAC (Attribute-Based Access Control) ve ReBAC (Relationship-Based Access Control). ASP.NET Core'da policy, requirement ve handler'ın nasıl çalıştığı ASP.NET Core Web API (Application Programming Interface) serisinin "Authorization" yazısında anlatıldı; iç mekanizmasının derinliği ilerideki "ASP.NET Core Güvenliği" serisinin 01. yazısında. Burada mekanizmayı değil, modeli tasarlıyoruz. Kodlar framework'ten bağımsız bir class library'de ve her kural bir testle sabitlenmiş.
Rol Patlaması
RBAC'ın fikri basit ve çoğu uygulamada doğru başlangıç: kullanıcıya rol verilir, kod rolü kontrol eder. Sorun, rolün iki farklı şeyi birden taşımaya başlamasıyla çıkıyor: "bu kişi ne iş yapıyor" ve "hangi koşullarda". Koşullar rol adına girdikçe roller çoğalıyor: şehir başına, limit başına, mağaza başına bir rol. Yüz mağazalı bir zincirde mağaza müdürü rolü yüz rol demek. Kodda da [Authorize(Roles = "Admin,Support,StoreManager")] gibi listeler birikiyor ve yeni bir rol eklemek kodda arama yapmayı gerektiriyor.

Çıkış yolu üç modeli rakip değil, katman olarak görmek. RBAC "bu rol hangi işleri yapabilir?" sorusunu cevaplasın ve sonucu permission'lara indirsin. ABAC "bu koşullarda izin var mı?" sorusunu kaynağın ve ortamın attribute'larıyla cevaplasın. ReBAC "kullanıcı bu nesneye nasıl bağlı?" sorusunu ilişki grafiğinden cevaplasın. Bookstore'da üçü de var.
Permission Tabanlı Tasarım: Rol Bir Pakettir
İlk adım, kodun rol adını hiç sormaması. Kod permission sorar; rol yalnız bir permission paketi:
public static class Permissions
{
public const string BooksRead = "books.read";
public const string BooksWrite = "books.write";
public const string OrdersRead = "orders.read";
public const string OrdersRefund = "orders.refund";
}
// RBAC katmanı: rol yalnız bir permission paketi; kod rol adını değil permission'ı sorar
public static class RolePermissions
{
private static readonly FrozenDictionary<string, FrozenSet<string>> Map = new Dictionary<string, FrozenSet<string>>
{
["Customer"] = new[] { Permissions.BooksRead, Permissions.OrdersRead }.ToFrozenSet(),
["Support"] = new[] { Permissions.BooksRead, Permissions.OrdersRead, Permissions.OrdersRefund }.ToFrozenSet(),
["Admin"] = new[] { Permissions.BooksRead, Permissions.BooksWrite, Permissions.OrdersRead, Permissions.OrdersRefund }.ToFrozenSet()
}.ToFrozenDictionary();
public static IReadOnlySet<string> For(IEnumerable<string> roles) =>
roles.Where(Map.ContainsKey).SelectMany(role => Map[role]).ToHashSet();
// Keycloak realm rolleri access token'da realm_access.roles içinde gelir
public static IReadOnlyList<string> KeycloakRealmRoles(ClaimsPrincipal user)
{
string? json = user.FindFirst("realm_access")?.Value;
if (json is null)
{
return [];
}
using JsonDocument doc = JsonDocument.Parse(json);
return doc.RootElement.TryGetProperty("roles", out JsonElement roles)
? [.. roles.EnumerateArray().Select(r => r.GetString()!)]
: [];
}
}
KeycloakRealmRoles authorization server'a özgü kısmı tek bir yere hapsediyor; 15. yazıdaki "adaptörün arkasında eriş" önerisi bu. Yarın rolleri Entra ID'den alırsan değişen tek method bu olur. 13. yazının API'sine bir endpoint ekledim; Angular arayüzde neyi göstereceğine buna bakarak karar verecek (17. yazı):
// SPA'nın UI kararları için: rol -> permission eşlemesi sunucuda, tek yerde
app.MapGet("/me/permissions", (ClaimsPrincipal user) =>
RolePermissions.For(RolePermissions.KeycloakRealmRoles(user)).Order())
.RequireAuthorization(policy => policy.RequireAuthenticatedUser().RequireEndUser());
Üç test kullanıcısıyla, 14. yazının BFF'i (Backend for Frontend) üzerinden:
ada: ["books.read","orders.read"]
alan: ["books.read","orders.read","orders.refund"]
grace: ["books.read","books.write","orders.read","orders.refund"]
Ada Customer, Alan Support, Grace Admin. Keycloak'un her kullanıcıya eklediği default-roles-bookstore, offline_access gibi roller eşlemede olmadığı için hiçbir permission eklemiyor; bilinmeyen bir rol yetki genişletmiyor. Bu "fail closed" davranışını bir testle sabitledim.
Permission Token'a mı, Her İstekte Sunucudan mı?
İki yol var. Birincisi permission'ları token'a koymak: API hiçbir yere sormadan karar verir, hızlıdır. Bedeli iki tane. Token şişer: Ada'nın yalnız birkaç rolü olan access token'ı lab'da zaten 1411 karakter; her istekte header'da gidiyor ve yüzlerce permission'lı bir yönetici hesabında bu sayı katlanır. Daha önemlisi bayatlık: Alan'ın iade yetkisini kaldırdığında, cebindeki token'ın süresi bitene kadar Alan iade yapmaya devam eder (13. yazıda JWT'nin (JSON Web Token), yani JSON (JavaScript Object Notation) Web Token'ın iptal edilemediğini görmüştük).
İkincisi token'da yalnız kimliği ve kaba rolleri taşıyıp permission'ı her istekte sunucuda çözmek: rol → permission eşlemesi API'de, gerekirse veritabanında, kısa süreli bir cache'in arkasında. Değişiklik cache süresi kadar gecikmeyle yansır. Ben lab'da ortada bir yol seçtim: roller token'da (Keycloak'un yönettiği, nadiren değişen şey), eşleme API'de (uygulamanın sahip olduğu, sık değişen şey). Rol ataması değişince bayatlık access token ömrüyle sınırlı, yani 5 dakika; eşleme değişince hiç yok.
Genel kural: token'a, token ömrü boyunca yanlış olmasına tahammül edebileceğin bilgiyi koy. Kimlik, tenant ve kaba roller genelde böyle; ince taneli permission'lar ve kaynak bazlı izinler değil.
ABAC: Kararı Attribute'lar Verir
Alan'ın orders.refund permission'ı var. Ama her siparişi iade edebilir mi? Önce Tahmin Et'teki kurallar permission'ın kendisinde değil; Alan'ın, siparişin ve zamanın attribute'larında:
// RefundLimit kullanıcının bir attribute'u (null = limitsiz): rol değil, yetkinin boyutu
public sealed record Actor(string UserId, string TenantId, IReadOnlySet<string> Permissions, decimal? RefundLimit);
public sealed record OrderFacts(int OrderId, string TenantId, string CustomerId, decimal Total, DateTimeOffset PlacedAt);
// ABAC katmanı: permission gerekli ama yeterli değil; kaynağın ve ortamın attribute'ları da karara girer
public sealed class RefundPolicy(TimeProvider clock)
{
private static readonly TimeSpan RefundWindow = TimeSpan.FromDays(30);
public Decision CanRefund(Actor actor, OrderFacts order)
{
if (!actor.Permissions.Contains(Permissions.OrdersRefund))
{
return Decision.Deny("missing permission orders.refund");
}
if (actor.TenantId != order.TenantId)
{
return Decision.Deny("order belongs to another tenant");
}
if (clock.GetUtcNow() - order.PlacedAt > RefundWindow)
{
return Decision.Deny("refund window of 30 days has passed");
}
if (order.Total > actor.RefundLimit)
{
return Decision.Deny($"total {order.Total} exceeds the refund limit of {actor.RefundLimit}");
}
return Decision.Allow();
}
}
İki tasarım kararı var. Birincisi limit bir rol değil, kullanıcının attribute'u: Destek ekibine yeni biri katıldığında 300 TL limitle başlatmak için yeni rol açmıyorsun. decimal? ile null "limitsiz" demek; C#'ta 1250 > null ifadesi false döndüğü için Admin'in kontrolü doğal olarak geçiyor. İkincisi karar bir bool değil, gerekçesiyle dönüyor; denetim kaydına ve hata mesajına bu yazılır. Saat TimeProvider üzerinden geliyor ki 30 günlük kural testte sabit bir zamanla sınanabilsin:
[Theory]
[InlineData("tenant-istanbul", 320, 3, true, "ok")]
[InlineData("tenant-ankara", 320, 3, false, "order belongs to another tenant")]
[InlineData("tenant-istanbul", 320, 31, false, "refund window of 30 days has passed")]
[InlineData("tenant-istanbul", 1250, 3, false, "total 1250 exceeds the refund limit of 500")]
public void Support_refunds_are_bounded_by_attributes(string tenant, int total, int daysAgo, bool allowed, string reason)
{
Decision decision = _policy.CanRefund(Alan(), Order(tenant, total, daysAgo));
Assert.Equal((allowed, reason), (decision.Allowed, decision.Reason));
}
ABAC'ın zayıf noktası burada görünüyor: kurallar koda dağıldıkça hangi kombinasyonun neye izin verdiğini anlamak zorlaşıyor. Çaresi kuralları tek bir yerde toplamak ve her dalı bir testle sabitlemek; ihtiyaç büyürse kuralları koddan ayrı bir policy dilinde yazan araçlar (OPA (Open Policy Agent) ve Rego, AWS'in (Amazon Web Services) Cedar'ı) devreye girer.
ReBAC: İlişkiler ve Zanzibar Fikri
Kadıköy mağazasının müdürü Alan, mağazasının siparişlerini görebilmeli; Beşiktaş'ın müdürü Grace göremez. Bunu ABAC ile de yazabilirsin (order.StoreId == actor.StoreId), ama ilişkiler zincirlenmeye başlayınca (bölge müdürü bölgedeki tüm mağazaları görür, bir siparişi müşteriyle paylaşabilirsin) attribute'lar yetmez. Google'ın 2019'da yayımladığı Zanzibar makalesi (Zanzibar: Google's Consistent, Global Authorization System, USENIX (Advanced Computing Systems Association) ATC (Annual Technical Conference) 2019) bu problemi ilişki tuple'larıyla çözüyor: object#relation@subject. Kurallar da "bir siparişin viewer'ı, sahibi ya da bağlı olduğu mağazanın viewer'ıdır" gibi ilişki tanımları. OpenFGA ve SpiceDB bu fikrin açık kaynak uygulamaları. Fikri görmek için oyuncak bir sürüm:
// ReBAC katmanı: Zanzibar fikrinin oyuncak hâli. İlişkiler tuple olarak saklanır: object#relation@subject
public sealed record RelationTuple(string Object, string Relation, string Subject);
public sealed class RelationshipGraph
{
private readonly HashSet<RelationTuple> _tuples = [];
public void Write(string obj, string relation, string subject) => _tuples.Add(new(obj, relation, subject));
public void Delete(string obj, string relation, string subject) => _tuples.Remove(new(obj, relation, subject));
// Kurallar (OpenFGA modeliyle aynı mantık):
// order#viewer = owner OR viewer of parent store
// store#viewer = manager OR staff
public bool Check(string obj, string relation, string user, int depth = 0)
{
if (depth > 8)
{
return false; // döngüye karşı sınır
}
if (_tuples.Contains(new(obj, relation, user)))
{
return true;
}
return (obj.Split(':')[0], relation) switch
{
("order", "viewer") => Check(obj, "owner", user, depth + 1)
|| Parents(obj).Any(store => Check(store, "viewer", user, depth + 1)),
("store", "viewer") => Check(obj, "manager", user, depth + 1) || Check(obj, "staff", user, depth + 1),
_ => false
};
}
private IEnumerable<string> Parents(string obj) =>
_tuples.Where(t => t.Object == obj && t.Relation == "parent").Select(t => t.Subject);
}
private static RelationshipGraph Seed()
{
RelationshipGraph graph = new();
graph.Write("order:1001", "owner", "user:ada");
graph.Write("order:1001", "parent", "store:kadikoy");
graph.Write("store:kadikoy", "manager", "user:alan");
graph.Write("store:besiktas", "manager", "user:grace");
return graph;
}
Bütün modelin test çıktısı:
$ dotnet test --logger "console;verbosity=normal"
Passed RelationshipGraphTests.Order_viewer_follows_ownership_and_store_hierarchy(user: "user:alan", expected: True) [3 ms]
Passed RefundPolicyTests.Admin_without_limit_can_refund_large_order [4 ms]
Passed RelationshipGraphTests.Order_viewer_follows_ownership_and_store_hierarchy(user: "user:grace", expected: False) [< 1 ms]
Passed RelationshipGraphTests.Order_viewer_follows_ownership_and_store_hierarchy(user: "user:ada", expected: True) [< 1 ms]
Passed RelationshipGraphTests.Removing_a_relation_revokes_access_immediately [< 1 ms]
Passed RefundPolicyTests.Support_refunds_are_bounded_by_attributes(tenant: "tenant-istanbul", total: 320, daysAgo: 31, allowed: False, reason: "refund window of 30 days has passed") [1 ms]
Passed RefundPolicyTests.Support_refunds_are_bounded_by_attributes(tenant: "tenant-ankara", total: 320, daysAgo: 3, allowed: False, reason: "order belongs to another tenant") [< 1 ms]
Passed RefundPolicyTests.Support_refunds_are_bounded_by_attributes(tenant: "tenant-istanbul", total: 320, daysAgo: 3, allowed: True, reason: "ok") [< 1 ms]
Passed RefundPolicyTests.Support_refunds_are_bounded_by_attributes(tenant: "tenant-istanbul", total: 1250, daysAgo: 3, allowed: False, reason: "total 1250 exceeds the refund limit of 500") [< 1 ms]
Passed RefundPolicyTests.Customer_cannot_refund_even_own_order [< 1 ms]
Passed RolePermissionTests.Keycloak_realm_roles_are_read_from_realm_access [6 ms]
Passed RolePermissionTests.Roles_expand_to_permissions_and_unknown_roles_add_nothing [< 1 ms]
Test Run Successful.
Total tests: 12
ReBAC'ın gücü Removing_a_relation_revokes_access_immediately testinde: Alan'ın müdürlüğü silindiği anda, hiçbir token yenilenmeden erişimi bitiyor; çünkü karar her istekte grafikten okunuyor. Bedeli de aynı cümlede: her yetki kontrolü bir sorgu, ilişki verisi de uygulamanın veritabanıyla senkron tutulması gereken ayrı bir kaynak. Gerçek bir ReBAC servisinde (OpenFGA, SpiceDB) bu sorgular için cache, tutarlılık token'ları ve "bu kullanıcının görebildiği siparişleri listele" gibi ters sorgular var; oyuncak sürümde yok. ReBAC'a paylaşım, hiyerarşi ya da organizasyon ağacı gerçekten varsa geç; yalnız "sahibi mi?" sorusu için order.CustomerId == userId yeter.
Multi-Tenant: Tenant Claim'i, Tenant Değiştirme ve Impersonation
Bookstore birden fazla şehirde ayrı işletmelere hizmet veriyorsa her kullanıcı ve her kayıt bir tenant'a ait. ABAC örneğindeki actor.TenantId != order.TenantId kontrolü bunun en küçük hâli. Kural basit ama uygulaması tutarlı olmalı: tenant kimliği token'daki bir claim'den (tenant_id) gelir, istemcinin gönderdiği bir parametreden ya da header'dan değil. Veri erişiminde de her sorgu tenant'la filtrelenmeli; EF (Entity Framework) Core'un global query filter'ları bunun için iyi bir araç (EF Core serisinin "Global Query Filter: Soft Delete, Multi-Tenancy ve IgnoreQueryFilters" yazısı) ve veritabanında RLS (Row-Level Security) ikinci bir savunma hattı.
Bir kullanıcı birden fazla tenant'ta çalışıyorsa (hem İstanbul hem Ankara mağazasında yönetici olan bir bölge müdürü) aktif tenant'ı token'da tutup tenant değiştirmeyi yeni bir token almak olarak tasarla. Aktif tenant'ı bir query string'den okumak, kullanıcının her istekte tenant seçebilmesi ve bir yerde kontrolün unutulması demek.
Destek ekibinin "müşterinin gözünden görmek" için impersonation yapması gerekebilir. Bunu Ada'nın parolasıyla ya da Ada'nın token'ını kopyalayarak yapma. Doğru tasarımda token iki kimliği birlikte taşır: konu (sub: Ada) ve eylemi yapan (act: Alan). RFC (Request for Comments) 8693, OAuth 2.0 Token Exchange, act claim'ini tam bunun için tanımlıyor. API her yazma işleminde iki kimliği de denetim kaydına yazar, impersonation token'ı kısa ömürlüdür ve yetkileri daraltılmıştır (iade yapamaz, parola değiştiremez). Denetim kaydında "Ada iade istedi" değil "Alan, Ada adına iade istedi" yazmalı.
Üretimde Dikkat
Yetki kararlarını loglarken kararın gerekçesini de yaz; Decision.Reason bu yüzden var. Bir kullanıcı "neden iade yapamıyorum?" diye sorduğunda cevap log'da olmalı. Ama gerekçeyi istemciye olduğu gibi döndürmek her zaman doğru değil: "order belongs to another tenant" mesajı, siparişin var olduğunu söyler. Dışarıya 404 ya da genel bir 403 dön, ayrıntıyı log'a yaz; Web Güvenliği (OWASP Top 10) serisinin "Broken Access Control (1)" yazısındaki IDOR (Insecure Direct Object Reference) tartışması aynen geçerli.
Rol → permission eşlemesini veritabanına taşıyorsan, değişikliklerin kim tarafından ne zaman yapıldığını kaydet ve yetki yükselten değişiklikleri (bir role orders.refund eklemek) iki kişilik onaya bağla. Permission listesini kodda sabit tut: veritabanında yeni bir permission adı yaratmak hiçbir kodu korumaz.
Performans için ölçüm yap: yetki kontrolü her istekte çalışıyor. Bellekteki bir FrozenDictionary lookup'ı nanosaniyeler sürer; her istekte veritabanına giden bir permission sorgusu ise sayfa başına onlarca milisaniye ekleyebilir. Cache'le, ama cache süresini iptal gecikmesine tahammülünle sınırla.
Sık Düşülen Tuzaklar
Birincisi, kodda rol adı kontrol etmek (User.IsInRole("Admin")); her yeni rol bir kod değişikliği olur. İkincisi, koşulları rol adına gömmek ve rol patlamasına girmek. Üçüncüsü, tenant kimliğini istemciden almak. Dördüncüsü, UI'da (User Interface) gizlenen butonu yetki kontrolü sanmak; asıl kontrol API'de, 17. yazıda bunu ayrıca konuşacağız.
Beşincisi, bilinmeyen bir rolü ya da eksik bir attribute'u "izin ver" olarak yorumlamak; her belirsizlik reddedilmeli. Altıncısı, ReBAC'ı ihtiyaç olmadan kurmak; ilişki verisini senkron tutmak başlı başına bir proje. Yedincisi, impersonation'ı denetim kaydı olmadan yapmak.
Küçük Bir Deneme
★☆☆ RolePermissions'a bir StoreManager rolü ekle (books.read, orders.read) ve bilinmeyen rol testinin hâlâ geçtiğini doğrula.
★★☆ Keycloak'ta kullanıcılara tenant_id attribute'u ekle, bir protocol mapper ile access token'a koy ve API'de RefundPolicy'yi token'daki tenant ile çalıştıran bir POST /orders/{id}/refund endpoint'i yaz. Ankara tenant'ındaki bir siparişe İstanbul'daki Alan'la istek atıp cevabın siparişin varlığını sızdırmadığını kontrol et.
★★★ RelationshipGraph'e region nesnesi ekle: bölge müdürü bölgedeki tüm mağazaların viewer'ı olsun. Sonra aynı modeli Docker'da çalışan bir OpenFGA'da kur ve iki uygulamanın aynı testlerde aynı cevabı verdiğini göster.
Yetki kararlarının sunucuda nasıl verildiğini tasarladık. Bir sonraki yazıda Angular tarafına geçiyoruz: signal tabanlı bir auth state service, functional guard'lar, permission directive'i, token kullanılan senaryoda eşzamanlı 401'lerde tek seferlik refresh ve sekmeler arası oturum senkronu; ve hepsinin neden yalnız kullanıcı deneyimi için olduğu.