Bir CQRS tarzı .NET servisinde her query ve her command, handler'a gitmeden önce bir MediatR pipeline'ından geçer. Bu pipeline, logging, validation, caching ve authorization gibi ortak konular için iyi bir yerdir.
Asıl zor soru sadece "kurallar neler?" değildir. Bir request hiç kural belirtmiyorsa ne olur? Yaygın tasarım, kuralı olan request'leri kontrol eder ve geri kalanları geçirir. Bu da unutulan tek bir rule'un korumasız bir endpoint yayınlaması anlamına gelir. Şimdi bunun tersine bakalım: access ya da permission rule'u olmayan bir request'in allowed değil, denied edildiği bir pipeline. Bunun çalışması dört şeye bağlıdır: zorunlu rule'lar, doğru behavior sırası, exception olsa bile deny eden handling ve boot-time check.
Önce pipeline'ın kendisine bakalım. MediatR, IPipelineBehavior implementasyonlarını handler etrafında iç içe wrapper'lar olarak çalıştırır. Her behavior kendi işini yapar, sonra içteki bir sonrakini çalıştırmak için next() çağırır. Handler en ortada çalışır. Gerçek hayatta sıra genelde şöyledir:
Exception → Access → Permission → Cache → Validation → Handler
Bu sıra sadece kozmetik değildir. İki basit kural bunun nedenini anlatır:
MediatR bu sırayı registration order'dan alır: ilk register ettiğiniz behavior en dıştaki wrapper olur.
services.AddTransient(typeof(IPipelineBehavior<,>), typeof(ExceptionBehavior<,>));
services.AddTransient(typeof(IPipelineBehavior<,>), typeof(AccessBehavior<,>));
services.AddTransient(typeof(IPipelineBehavior<,>), typeof(PermissionBehavior<,>));
services.AddTransient(typeof(IPipelineBehavior<,>), typeof(CacheBehavior<,>));
services.AddTransient(typeof(IPipelineBehavior<,>), typeof(RequestValidationBehavior<,>));
services.AddTransient(typeof(IPipelineBehavior<,>), typeof(RuleValidationBehavior<,>));
Bu aynı zamanda tasarımın zayıf tarafıdır. Garanti bir registration listesinde yaşar ve listeler kolayca kopyalanabilir, değiştirilebilir ya da servisler arasında farklı yönlere gidebilir. Buna birazdan geri döneceğiz.
Şimdi kuralın kendisine bakalım. Authorization bir pipeline behavior olarak çalışır. Her behavior, request type üzerinden kendi sorumlu olduğu rule'u okur ve o rule yoksa deny eder.
Bu sistemde authorization iki behavior'a ayrılır: [Access] ve [Permission]. Sonraki bölüm bunun nedenini anlatır. Ama ikisi de aynı fail-closed şekli izler. Aşağıda bunlardan birinin temel pattern'i vardır:
public async Task<TResponse> Handle(
TRequest request, RequestHandlerDelegate<TResponse> next, CancellationToken ct)
{
var rule = typeof(TRequest).GetCustomAttribute<AccessAttribute>();
// No attribute is not "no restrictions" — it is a denial.
if (rule is null)
{
_logger.LogWarning("{Request} has no access attribute", typeof(TRequest).Name);
return Denied<TResponse>();
}
if (rule.IsAnonymous)
return await next(); // explicitly public — opt in, not default
if (!_caller.IsAuthenticated || !_caller.HasAccess(rule))
return Denied<TResponse>();
return await next(); // reached only when the rule is satisfied
}
Buranın en önemli kısmı ilk branch'tir. Rule'u olmayan bir request, "restriction yok" diye yorumlanmaz. Doğrudan denied edilir. Bu tek karar failure mode'u değiştirir. Fail-open tasarımda unutulan bir attribute endpoint'i public yapar. Burada ise unutulan bir rule, biri gelip ne gerektirdiğini açıkça deklare edene kadar her request'i deny eder.
Sadece son next() çağrısı request'in devam etmesine izin verir. Diğer tüm yollar next() çağrılmadan denial döndürür. Yani bu behavior içinde yetkisiz bir request'i handler'a götüren bir yol yoktur.
Response type'in de default olarak deny etmesi yardımcı olur. Success, biri açıkça true yapana kadar false kalıyorsa, new TResponse() döndüren bir bug bile fail closed davranır.
Burada açıkça söylemek gereken bir sınır vardır. Bu şekilde yazılmış tek bir behavior, eksik rule durumunda fail closed yapar. Ama behavior hiç çalışmazsa fail closed yapamaz. Pipeline'dan düşmüş bir behavior hiçbir şeyi deny edemez. Sonraki bölüm bu riski nasıl azalttığımızı anlatır.
"Bu caller bu request'i çalıştırabilir mi?" aslında iki farklı sorudur:
Bunu iki attribute ve iki behavior'a bölmek her kuralı daha küçük ve daha kolay okunur hale getirir. Ayrıca size faydalı bir yedek de sağlar. Permission behavior, access attribute'un var olduğunu da kontrol edip yoksa deny edebilir.
Bu ilk bakışta duplicate iş gibi görünür. Öyledir de. Ama bu duplicate kontrol bilerek vardır. Bir önceki bölümde gördüğümüz zayıf noktayı korur. Behavior'lar yanlış sırada register edilirse ya da bir servis pipeline'ından biri çıkarsa, ikinci kontrol hâlâ request'i fail closed şekilde durdurabilir.
Bu pipeline-level kontrolün neyi yapıp neyi yapmadığı da açık olmalıdır. Bu kontrol, caller'in bu operasyonu çalıştırıp çalıştıramayacağını söyler. Hangi satırları görebileceğini söylemez. Row-level scope handler'da, yani query'nin kurulduğu yerde çözülmelidir. Orası da fail closed davranmalıdır. Scope çözülemezse güvenli sonuç tüm satırlar değil, hiç satır olmamasıdır.
Eksik rule hikayenin sadece yarısıdır. Diğer yarı da şudur: authorization sırasında exception olursa ne olur? Bir lookup fail edebilir. Bir bağımlılık ayakta olmayabilir. Kontrolün ortasında bir bug ortaya çıkabilir. Eğer bu exception yutulursa ya da uygulama yine de devam ederse, authorization path'indeki bir failure authorization bypass'a dönebilir.
Bu yüzden exception behavior en başa konur. En dış wrapper olduğu için try/catch, authorization ve içerideki her şeyi sarar. Authorization sırasında fırlayan bir exception burada yakalanır ve failed response'a çevrilir:
try
{
return await next();
}
catch (Exception ex)
{
_logger.LogError(ex, "Unhandled exception for {Request}", typeof(TRequest).Name);
return Failed<TResponse>(ex); // Success = false — not a passthrough, not a success
}
Bu catch bloğu devam etmez, next()'i tekrar çağırmaz ve "bir şekilde devam edelim" demez. Doğrudan non-success response döndürür. Böylece bu wrapper içindeki herhangi bir exception — authorization içindekiler dahil — "request allowed" olarak değil, "request failed" olarak sonuçlanır.
Runtime'da missing rule için deny etmek doğru davranıştır. Ama bu, bir request'in kural olmadan yayınlandığını o request artık trafiğe açıkken öğrenmek demektir. Bu boşluğu daha erken yakalayabiliriz.
Kurallar request type üzerinde olduğu için startup sırasında kontrol edilebilir. Assembly'deki tüm request type'ları gezip access rule'u ya da permission rule'u eksik olanları bulur ve uygulamayı hiç ayağa kaldırmayız:
var missing = RequestTypesIn(assembly)
.Where(t => t.GetCustomAttribute<AccessAttribute>() is null
|| !t.GetCustomAttributes<PermissionAttribute>().Any())
.Select(t => t.Name)
.ToList();
if (missing.Count > 0)
throw new AuthorizationConfigurationException(
"Requests missing an access or permission rule: " + string.Join(", ", missing));
İki rule'u da kontrol etmek önemlidir. Permission rule'u olup access rule'u olmayan bir request de boşa açıktır. Tersi de öyledir.
Ama bu startup check sadece eksik declaration'i yakalar. Declaration'in doğru olduğunu kanıtlamaz. Bir request yanlış surface'e açılmış olabilir ya da çok geniş bir permission almış olabilir. Bunlar presence hatası değil, anlam hatasıdır. Bu yüzden hâlâ review ve runtime denial gerekir.
Aynı startup taraması daha ince bir bug'i de arayabilir. Bir request "sadece kendi kayıtlarını okuyabilir" gibi scoped bir permission deklare etmiş olabilir; ama handler caller ile filtreleme yapmıyor olabilir. Bu bir data leak'tir. Basit bir scan bunun yaygın şeklini yakalayabilir. Ama bu scan sadece kod üzerinde basit bir kontroldür; proof değildir. Helper method'lar ya da service call'lar arkasına saklanan filtreleri kaçırabilir. Bazen de güvenli kodu yanlış pozitif olarak flag'leyebilir. Bunu tam garanti gibi değil, guardrail gibi kullanmak gerekir.
Bütün bunların bir maliyeti vardır. Maliyet de ekstra sürtünmedir:
Küçük bir servis ve az sayıda endpoint varsa, bu yaklaşım ihtiyacınızdan fazla machinery olabilir. ASP.NET Core'un [Authorize] ve policy yaklaşımı yeterli olabilir. Endpoint sayısı azsa review de eksik rule'u yakalamaya yetebilir.
Pipeline yaklaşımı endpoint sayısı büyüdüğünde ve "review'de biri fark eder" düşüncesi artık güvenilir olmaktan çıktığında değer kazanır. O noktada default davranış, tek tek kişilerin hafızasından daha önemli olur. Fail-open kolaylığı tercih eder. Fail-closed ise farklı bir özelliği tercih eder: biri ne gerektiğini deklare edene kadar request çalışmaz. Büyük bir serviste bu özellik çoğu zaman sürtünmeye değer.
Bu pattern — authorization behavior'ları, deny-by-default result type ve boot-time attribute check — multi-tenant .NET servislerinde tekrar kullanılabilir, net bir güvenlik temeli sunar.
English version: Read in English