API Güvenlik Lab'ı: Multi-Tenant Sipariş API'si, Authorization Matrisi ve Güvenlik Testi Altyapısı
API güvenliğinin çoğu "kimin verisi" sorusunda bozulur ve bunu ancak endpoint × aktör × kaynak için beklenen durum kodunu yazan, koddan ayrı tutulan bir authorization matrisiyle sistematik olarak yakalarsın. İki tenant'lı, üç rollü bir Minimal API lab'ı kurup matrisi xUnit Theory, WebApplicationFactory ve test authentication handler ile koşturuyor, ilk koşuda sekiz BOLA açığını görüyoruz.
Sürüm notu: Bu yazıdaki kodlar .NET 10, EF (Entity Framework) Core 10.0.14 ve xUnit v3 4.0.2 ile derlenip denenmiştir; risk adları OWASP'ın (Open Worldwide Application Security Project) API (Application Programming Interface) Security Top 10 2023 listesine göredir.
Önce Tahmin Et
Bir kitapçı zinciri için sipariş API'si yazıyorsun. İki mağaza (North ve South) aynı API'yi ve aynı veritabanını paylaşıyor. Endpoint'lerin hepsinde .RequireAuthorization() var, fallback policy açık, admin endpoint'i rol istiyor. Testlerin yeşil. Şimdi soru: North mağazasının müşterisi Alan, South mağazasının müşterisi Linus'un sipariş id'sini bir şekilde öğrenirse ne olur? Peki Alan kendi siparişinin id'siyle Grace'in faturasının id'sini aynı URL (Uniform Resource Locator) içinde birleştirirse?
Cevabın "test yazdık, sorun yok" ise devam et; bu yazının sonunda aynı API'de sekiz kırmızı test satırı göreceğiz. Hiçbiri derleyici hatası değil, hiçbiri mevcut unit testlerin yakalayacağı türden değil. Hepsi tek bir tablonun, authorization matrisinin, otomatik olarak koşturulmasıyla ortaya çıkıyor.
Bu seri OWASP API Security Top 10 2023 listesini sırasıyla ele alıyor. Amaç tek bir endpoint'i yamamak değil, bütün API'yi kapsayan ve testle korunan savunmalar kurmak. İlk yazı bunun için gereken lab'ı ve test altyapısını kuruyor.
Web Top 10'dan API Top 10'a: Odak Nerede Kayıyor
Web Güvenliği (OWASP Top 10) serisinin "OWASP'ın Diğer Listeleri" yazısında API Security Top 10'a genel bir bakış atmış, BOLA (Broken Object Level Authorization), BOPLA (Broken Object Property Level Authorization) ve BFLA (Broken Function Level Authorization) ayrımını yapmıştık. Burada o ayrımı tekrar etmeyeceğim; yalnız bu serinin neden ayrı bir seri olduğunu bir paragrafla söyleyeyim.
Web Top 10 büyük ölçüde tarayıcı ve sunucu arasındaki sorunlara bakar: injection, XSS (Cross-Site Scripting), cookie ayarları, yanlış yapılandırma. API Top 10'da ise listenin ilk beş maddesinden üçü yetkilendirme: nesne seviyesinde (API1), property seviyesinde (API3) ve fonksiyon seviyesinde (API5). Sebebi basit: bir API'nin istemcisi senin yazdığın Angular uygulaması değil, HTTP (Hypertext Transfer Protocol) isteği atabilen herkes. Ekranda gizlenen buton, disabled input ya da route guard API için hiçbir şey ifade etmiyor. Her istek, her id ve her alan için "bu kişi bunu yapabilir mi?" sorusu sunucuda cevaplanmalı.
Bu sorunun en zor hâli multi-tenant SaaS (Software as a Service) uygulamalarında ortaya çıkıyor. Tek kullanıcılı bir uygulamada "kayıt bu kullanıcının mı?" diye sorarsın. Multi-tenant bir uygulamada iki soru var: kayıt bu tenant'ın mı ve tenant içinde bu kullanıcının mı? Birinciyi unutmak bir müşterinin verisini başka bir şirkete açmak demek; B2B (Business to Business) dünyasında bunun adı sözleşme ihlali.
Lab: İki Tenant, Üç Rol, Bir Sipariş API'si
Web Güvenliği serisinin Bookstore lab'ı tek tenant'lıydı. Bu seride yeni bir lab kuruyoruz: Bookstore.Orders.Api. ASP.NET Core 10 Minimal API, EF Core 10 ve SQLite. İki tenant var: north (ücretli plan) ve south (ücretsiz plan). Her tenant'ta üç rol: customer, support ve admin. Kullanıcılar seed verisinde sabit id'lerle duruyor:
public static readonly AppUser[] Users =
[
User("0197f000-0000-7000-8000-000000000001", North, "ada", "admin"),
User("0197f000-0000-7000-8000-000000000002", North, "margaret", "support"),
User("0197f000-0000-7000-8000-000000000003", North, "alan", "customer"),
User("0197f000-0000-7000-8000-000000000004", North, "grace", "customer"),
User("0197f000-0000-7000-8000-000000000005", South, "edsger", "admin"),
User("0197f000-0000-7000-8000-000000000006", South, "barbara", "support"),
User("0197f000-0000-7000-8000-000000000007", South, "linus", "customer")
];
Alan ve Grace North'un müşterileri, Margaret North'un destek ekibinden, Ada North'un yöneticisi. South tarafında Linus müşteri, Barbara destek, Edsger yönetici. Her müşterinin bir siparişi ve o siparişe bağlı bir faturası var. Id'ler UUIDv7 (Universally Unique Identifier, sürüm 7) biçiminde; sabit tutmamın sebebi testlerin ve curl örneklerinin bu kayıtlara isimle başvurabilmesi.
Tenant'a ait her entity aynı küçük interface'i uyguluyor. Bu interface bir sonraki yazıda global query filter'ı tek yerden kurmamızı sağlayacak:
public interface ITenantOwned
{
string TenantId { get; }
}
public sealed class Order : ITenantOwned
{
public Guid Id { get; init; }
public required string TenantId { get; init; }
public Guid CustomerId { get; init; }
public OrderStatus Status { get; set; }
public decimal Total { get; set; }
public decimal InternalCost { get; set; }
public required string ShippingAddress { get; set; }
public string? Note { get; set; }
public DateTime CreatedAt { get; init; }
public List<OrderItem> Items { get; init; } = [];
public List<Invoice> Invoices { get; init; } = [];
}
InternalCost siparişin bize maliyeti; destek ekibi görebilir, müşteri görmemeli. Bu alan API3 yazısında işimize yarayacak. Şimdilik Program.cs'e bakalım:
WebApplicationBuilder builder = WebApplication.CreateBuilder(args);
builder.Services.AddDbContext<OrdersDbContext>(options =>
options.UseSqlite(builder.Configuration.GetConnectionString("Orders") ?? "Data Source=orders.db"));
builder.Services.AddHttpContextAccessor();
builder.Services.AddScoped<ICurrentUser, HttpCurrentUser>();
builder.Services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
.AddJwtBearer(options =>
{
options.MapInboundClaims = false;
options.TokenValidationParameters = LabTokens.ValidationParameters(builder.Configuration);
});
builder.Services.AddAuthorizationBuilder()
// Açıkça işaretlenmeyen her endpoint en az authentication ister
.SetFallbackPolicy(new AuthorizationPolicyBuilder().RequireAuthenticatedUser().Build())
.AddPolicy(LabPolicies.TenantAdmin, policy => policy.RequireRole("admin"))
.AddPolicy(LabPolicies.Customer, policy => policy.RequireRole("customer"));
builder.Services.AddProblemDetails();
WebApplication app = builder.Build();
using (IServiceScope scope = app.Services.CreateScope())
{
OrdersDbContext db = scope.ServiceProvider.GetRequiredService<OrdersDbContext>();
db.Database.EnsureCreated();
SeedData.Seed(db);
}
app.MapGet("/health", () => "ok").AllowAnonymous();
app.MapOrderEndpoints();
app.MapAdminEndpoints();
app.Run();
Fallback policy'nin ne işe yaradığını Web Güvenliği serisinin A01 Broken Access Control yazısında görmüştük: endpoint'e hiçbir authorization metadata'sı eklenmemişse bile authentication zorunlu olur. MapInboundClaims = false ise JWT (JSON Web Token) içindeki sub, tenant_id ve role claim'lerinin isimlerinin .NET'in eski uzun claim URI'lerine (Uniform Resource Identifier) çevrilmeden kalmasını sağlıyor. ICurrentUser bu üç claim'i okuyan küçük bir service:
public sealed class HttpCurrentUser(IHttpContextAccessor accessor) : ICurrentUser
{
private ClaimsPrincipal Principal =>
accessor.HttpContext?.User ?? throw new InvalidOperationException("No HTTP context.");
public Guid Id => Guid.Parse(Required(LabClaims.Subject));
public string TenantId => Required(LabClaims.Tenant);
public string Role => Required(LabClaims.Role);
private string Required(string claimType) =>
Principal.FindFirstValue(claimType)
?? throw new InvalidOperationException($"Claim '{claimType}' is missing.");
}
Claim eksikse varsayılan bir değer uydurmak yerine exception fırlatıyor. Tenant bilgisi olmayan bir token'ın "boş tenant" ile sorgu çalıştırması, sessizce yanlış veri döndürmekten daha kötü bir sonuç doğurabilir; fail closed burada da geçerli.
Endpoint'lerin İlk Hâli
Sipariş endpoint'leri tipik bir ilk sürüm gibi yazıldı. Liste endpoint'inde geliştirici tenant ve müşteri filtresini hatırlamış:
orders.MapGet("/", async (ICurrentUser user, OrdersDbContext db, CancellationToken ct) =>
{
IQueryable<Order> query = db.Orders.Where(o => o.TenantId == user.TenantId);
if (user.Role == "customer")
{
query = query.Where(o => o.CustomerId == user.Id);
}
return await query
.OrderByDescending(o => o.CreatedAt)
.Select(o => new OrderSummary(o.Id, o.Status.ToString(), o.Total, o.CreatedAt))
.ToListAsync(ct);
});
Detay ve fatura endpoint'lerinde ise hatırlamamış. Kod review'da da göze batmıyor, çünkü endpoint "korumalı": fallback policy authentication istiyor.
// Detay: yalnız id ile okunuyor
orders.MapGet("/{orderId:guid}", async (Guid orderId, OrdersDbContext db, CancellationToken ct) =>
await db.Orders
.Where(o => o.Id == orderId)
.Select(o => new OrderDetails(o.Id, o.CustomerId, o.Status.ToString(), o.Total,
o.InternalCost, o.ShippingAddress, o.Note, o.CreatedAt))
.FirstOrDefaultAsync(ct) is { } order
? Results.Ok(order)
: Results.NotFound());
// Alt kaynak: fatura id'si sipariş id'siyle eşleşiyor mu bakılmıyor
orders.MapGet("/{orderId:guid}/invoices/{invoiceId:guid}",
async (Guid orderId, Guid invoiceId, OrdersDbContext db, CancellationToken ct) =>
await db.Invoices
.Where(i => i.Id == invoiceId)
.Select(i => new InvoiceDetails(i.Id, i.OrderId, i.Number, i.Amount))
.FirstOrDefaultAsync(ct) is { } invoice
? Results.Ok(invoice)
: Results.NotFound());
POST /orders yalnız customer rolüne açık ve kitabı db.Books.FirstOrDefaultAsync(b => b.Id == request.BookId) ile buluyor. Bu satırda da tenant yok; body'deki bir id'nin de URL'deki id kadar tehlikeli olduğunu birazdan matris gösterecek. /admin/users ise TenantAdmin policy'siyle korunan bir route group'ta.
Token'lar: Kimlik Sağlayıcı Yerine Küçük Bir Araç
Lab'da bir kimlik sağlayıcı (Keycloak, OpenIddict ya da Entra ID) kurmuyoruz; token doğrulamanın ve OAuth akışlarının ayrıntısı ilerideki Authentication ve Authorization serisinin konusu. Onun yerine tools/Bookstore.Orders.TokenTool adında küçük bir console uygulaması, çalışma klasöründe ürettiğimiz bir anahtarla kısa ömürlü JWT basıyor:
AppUser user = SeedData.UserNamed(args[0]);
string keyFile = Environment.GetEnvironmentVariable("Auth__SigningKeyFile") ?? ".lab/signing.key";
SecurityTokenDescriptor descriptor = new()
{
Issuer = LabTokens.Issuer,
Audience = LabTokens.Audience,
Expires = DateTime.UtcNow.AddHours(1),
Claims = new Dictionary<string, object>
{
[LabClaims.Subject] = user.Id.ToString(),
[LabClaims.Tenant] = user.TenantId,
[LabClaims.Role] = user.Role
},
SigningCredentials = new SigningCredentials(LabTokens.LoadKey(keyFile), SecurityAlgorithms.HmacSha256)
};
Console.WriteLine(new JsonWebTokenHandler().CreateToken(descriptor));
Anahtar dosyasını openssl ile üretiyoruz ve hiçbir yere commit etmiyoruz; .lab/ klasörü .gitignore'da. API'yi 19510 port'unda çalıştırıp iki kullanıcı için token alalım:
$ openssl rand -base64 48 > .lab/signing.key
$ export Auth__SigningKeyFile=$PWD/.lab/signing.key
$ dotnet run --project src/Bookstore.Orders.Api --urls http://localhost:19510 &
$ ALAN=$(dotnet run --project tools/Bookstore.Orders.TokenTool -- alan)
$ LINUS=$(dotnet run --project tools/Bookstore.Orders.TokenTool -- linus)
Önce token'sız, sonra Alan'ın token'ıyla liste endpoint'ine gidelim:
$ curl -i http://localhost:19510/orders
HTTP/1.1 401 Unauthorized
Content-Length: 0
Date: Sun, 08 Nov 2026 05:23:42 GMT
Server: Kestrel
WWW-Authenticate: Bearer
$ curl -s http://localhost:19510/orders -H "Authorization: Bearer $ALAN"
[{"id":"0197f001-0000-7000-8000-000000000001","status":"Paid","total":54.9,"createdAt":"2026-11-02T10:00:00"}]
Liste doğru: Alan yalnız kendi siparişini görüyor. Şimdi Linus'un sipariş id'sini detay endpoint'ine verelim. Bu id'yi Alan'ın nereden bildiği önemli değil; bir destek e-postasında, paylaşılan bir ekran görüntüsünde ya da bir log satırında görmüş olabilir.
$ curl -s http://localhost:19510/orders/0197f001-0000-7000-8000-000000000003 -H "Authorization: Bearer $ALAN"
{"id":"0197f001-0000-7000-8000-000000000003","customerId":"0197f000-0000-7000-8000-000000000007","status":"Paid","total":47.5,"internalCost":26.8,"shippingAddress":"linus street 1","note":null,"createdAt":"2026-11-02T10:00:00"}
North'un müşterisi South'un müşterisinin adresini ve üstüne bizim maliyetimizi okudu. Bunu curl ile tek tek bulmak mümkün ama ölçeklenmez: on endpoint, yedi kullanıcı ve her biri için birkaç kaynak, yüzlerce kombinasyon demek. Bir tabloya ihtiyacımız var.
Authorization Matrisi: Kuralı Koddan Ayırmak

Authorization matrisi endpoint × aktör × kaynak üçlüsü için beklenen HTTP durum kodunu yazan bir tablo. Önemli olan, tablonun koddan ayrı bir veri olarak durması. Kod "ne yapıldığını" söyler, matris "ne yapılması gerektiğini". İkisi aynı yerde durursa kodu değiştiren kişi beklentiyi de sessizce değiştirir. Biz matrisi test projesinde bir CSV (Comma-Separated Values) dosyası olarak tutuyoruz:
endpoint,actor,resource,expected
GET /orders,anonymous,-,401
GET /orders,alan,-,200
GET /orders,margaret,-,200
GET /orders/{orderId},anonymous,alan-order,401
GET /orders/{orderId},alan,alan-order,200
GET /orders/{orderId},alan,grace-order,404
GET /orders/{orderId},alan,linus-order,404
GET /orders/{orderId},margaret,grace-order,200
GET /orders/{orderId},margaret,linus-order,404
GET /orders/{orderId},ada,alan-order,200
GET /orders/{orderId},edsger,alan-order,404
GET /orders/{orderId}/invoices/{invoiceId},alan,alan-order,200
GET /orders/{orderId}/invoices/{invoiceId},alan,alan-order+grace-invoice,404
GET /orders/{orderId}/invoices/{invoiceId},alan,grace-order,404
GET /orders/{orderId}/invoices/{invoiceId},linus,alan-order,404
POST /orders,anonymous,north-book,401
POST /orders,alan,north-book,201
POST /orders,margaret,north-book,403
POST /orders,linus,north-book,400
GET /admin/users,anonymous,-,401
GET /admin/users,ada,-,200
GET /admin/users,margaret,-,403
GET /admin/users,alan,-,403
Satırları okurken üç sütuna dikkat et. actor seed'deki kullanıcı adı ya da anonymous. resource route'taki id'lerin hangi kayıttan geleceğini söyleyen bir ad: alan-order hem {orderId}'yi hem {invoiceId}'yi Alan'ın kayıtlarıyla doldurur, alan-order+grace-invoice ise sipariş id'sini Alan'dan, fatura id'sini Grace'ten alır. expected ise kararımız. Başka bir tenant'ın ya da başka bir müşterinin kaydı için 403 değil 404 seçtik; neden 404 dediğimizi Web Güvenliği serisinin A01 yazısında konuşmuştuk, bir sonraki yazıda API açısından tekrar ele alacağız. Linus'un North'un kitabını sipariş etmesi ise 400: body'deki id bilinmeyen bir kitap gibi davranmalı.
Matris yalnız "yasak" satırlarından oluşmuyor. alan → alan-order → 200 gibi izin satırları da en az onlar kadar önemli. Yalnız red bekleyen bir test seti, her şeye 404 dönen bozuk bir API'yi de geçirir.
Matrisi Teste Çevirmek
Matrisi koşturmak için üç parça yazıyoruz. Birincisi, CSV satırını bir HttpRequestMessage'a çeviren küçük bir yardımcı:
public static class AuthorizationMatrix
{
// Matristeki kaynak adları seed verisindeki sabit id'lere çözülür
private static readonly Dictionary<string, (string Parameter, Guid Id)[]> Resources = new()
{
["alan-order"] = [("orderId", SeedData.AlanOrder), ("invoiceId", SeedData.AlanInvoice)],
["grace-order"] = [("orderId", SeedData.GraceOrder), ("invoiceId", SeedData.GraceInvoice)],
["linus-order"] = [("orderId", SeedData.LinusOrder), ("invoiceId", SeedData.LinusInvoice)],
["grace-invoice"] = [("invoiceId", SeedData.GraceInvoice)],
["linus-invoice"] = [("invoiceId", SeedData.LinusInvoice)],
["north-book"] = [("bookId", SeedData.NorthBook)],
["south-book"] = [("bookId", SeedData.SouthBook)]
};
private static readonly Dictionary<string, string> Bodies = new()
{
["POST /orders"] = """{"bookId":"{bookId}","quantity":1,"shippingAddress":"1 Test Street"}"""
};
public static HttpRequestMessage ToRequest(MatrixRow row)
{
Dictionary<string, Guid> values = [];
foreach (string part in row.Resource.Split('+').Where(p => p != "-"))
{
foreach ((string parameter, Guid id) in Resources[part])
{
values[parameter] = id;
}
}
HttpRequestMessage request = new(new HttpMethod(row.Method), Fill(row.Route, values));
if (Bodies.TryGetValue($"{row.Method} {row.Route}", out string? body))
{
request.Content = new StringContent(Fill(body, values), System.Text.Encoding.UTF8, "application/json");
}
return request;
}
private static string Fill(string template, Dictionary<string, Guid> values) =>
values.Aggregate(template, (text, pair) => text.Replace($"{{{pair.Key}}}", pair.Value.ToString()));
}
İkincisi, test authentication handler. Her test için JWT üretmek mümkün ama gereksiz; testin derdi token doğrulamak değil, yetkilendirme kararları. Handler X-Test-User header'ındaki kullanıcı adını seed'den bulup aynı üç claim'le bir principal kuruyor:
// Testte JWT üretmek yerine kullanıcıyı header'dan seçiyoruz; yalnız test host'unda kayıtlı
public sealed class TestAuthHandler(
IOptionsMonitor<AuthenticationSchemeOptions> options, ILoggerFactory logger, UrlEncoder encoder)
: AuthenticationHandler<AuthenticationSchemeOptions>(options, logger, encoder)
{
public const string SchemeName = "Test";
public const string UserHeader = "X-Test-User";
protected override Task<AuthenticateResult> HandleAuthenticateAsync()
{
if (!Request.Headers.TryGetValue(UserHeader, out var values))
{
return Task.FromResult(AuthenticateResult.NoResult());
}
AppUser user = SeedData.UserNamed(values.ToString());
Claim[] claims =
[
new(LabClaims.Subject, user.Id.ToString()),
new(LabClaims.Tenant, user.TenantId),
new(LabClaims.Role, user.Role)
];
ClaimsIdentity identity = new(claims, SchemeName, LabClaims.Subject, LabClaims.Role);
return Task.FromResult(AuthenticateResult.Success(
new AuthenticationTicket(new ClaimsPrincipal(identity), SchemeName)));
}
}
Header yoksa NoResult dönüyor, böylece anonymous satırları gerçekten kimliksiz istek oluyor ve 401'i framework'ün kendi challenge'ı üretiyor. Üçüncü parça WebApplicationFactory. Handler'ı yalnız test host'unda kaydediyor ve default scheme'i ona çeviriyor; veritabanı olarak paylaşılan bir in-memory SQLite kullanıyor:
public sealed class OrdersApiFactory : WebApplicationFactory<Program>
{
// Paylaşılan in-memory SQLite: bağlantı açık kaldıkça veritabanı yaşar
private readonly string _connectionString = $"Data Source=orders-{Guid.NewGuid():N};Mode=Memory;Cache=Shared";
private readonly SqliteConnection _keepAlive;
public OrdersApiFactory()
{
_keepAlive = new SqliteConnection(_connectionString);
_keepAlive.Open();
}
protected override void ConfigureWebHost(IWebHostBuilder builder)
{
builder.UseSetting("ConnectionStrings:Orders", _connectionString);
builder.UseSetting("Logging:LogLevel:Default", "Warning");
builder.ConfigureTestServices(services =>
services.AddAuthentication(TestAuthHandler.SchemeName)
.AddScheme<AuthenticationSchemeOptions, TestAuthHandler>(TestAuthHandler.SchemeName, null));
}
public HttpClient CreateClientFor(string actor)
{
HttpClient client = CreateClient();
if (actor != "anonymous")
{
client.DefaultRequestHeaders.Add(TestAuthHandler.UserHeader, actor);
}
return client;
}
public override async ValueTask DisposeAsync()
{
await base.DisposeAsync();
await _keepAlive.DisposeAsync();
}
}
WebApplicationFactory<Program> için artık public partial class Program; satırına gerek yok; .NET 10'da top-level statement'lı projeler için bu bildirimi bir source generator üretiyor. Integration testin genel kurulumunu ASP.NET Core Web API serisinin WebApplicationFactory yazısında anlatmıştık; burada yalnız güvenlik için gereken iki ayar var: test scheme'i ve her factory'ye ayrı veritabanı.
Test class'ı ise neredeyse sıkıcı derecede kısa. Matristeki her satır bir xUnit Theory satırı:
public sealed class AuthorizationMatrixTests(OrdersApiFactory factory) : IClassFixture<OrdersApiFactory>
{
public static TheoryData<string, string, string, int> Rows()
{
TheoryData<string, string, string, int> data = [];
foreach (MatrixRow row in AuthorizationMatrix.Load())
{
data.Add($"{row.Method} {row.Route}", row.Actor, row.Resource, row.Expected);
}
return data;
}
[Theory]
[MemberData(nameof(Rows))]
public async Task Endpoint_returns_expected_status(string endpoint, string actor, string resource, int expected)
{
string[] parts = endpoint.Split(' ');
MatrixRow row = new(parts[0], parts[1], actor, resource, expected);
HttpClient client = factory.CreateClientFor(actor);
using HttpResponseMessage response = await client.SendAsync(AuthorizationMatrix.ToRequest(row),
TestContext.Current.CancellationToken);
Assert.Equal(expected, (int)response.StatusCode);
}
}
Theory parametrelerini MatrixRow yerine dört primitive olarak geçirmemin sebebi test adları: xUnit bu değerleri test adına yazıyor ve kırmızı satırı okuyan kişi CSV'yi açmadan hangi kombinasyonun bozulduğunu görüyor.
İlk Koşu: Sekiz Kırmızı Satır
$ dotnet test
failed Endpoint_returns_expected_status(endpoint: "GET /orders/{orderId}", actor: "alan", resource: "linus-order", expected: 404) (475ms)
Assert.Equal() Failure: Values differ
Expected: 404
Actual: 200
failed Endpoint_returns_expected_status(endpoint: "GET /orders/{orderId}", actor: "edsger", resource: "alan-order", expected: 404) (1ms)
failed Endpoint_returns_expected_status(endpoint: "GET /orders/{orderId}/invoices/{invoiceId}", actor: "alan", resource: "grace-order", expected: 404) (2ms)
failed Endpoint_returns_expected_status(endpoint: "GET /orders/{orderId}", actor: "alan", resource: "grace-order", expected: 404) (0ms)
failed Endpoint_returns_expected_status(endpoint: "POST /orders", actor: "linus", resource: "north-book", expected: 400) (16ms)
failed Endpoint_returns_expected_status(endpoint: "GET /orders/{orderId}/invoices/{invoiceId}", actor: "linus", resource: "alan-order", expected: 404) (0ms)
failed Endpoint_returns_expected_status(endpoint: "GET /orders/{orderId}", actor: "margaret", resource: "linus-order", expected: 404) (0ms)
failed Endpoint_returns_expected_status(endpoint: "GET /orders/{orderId}/invoices/{invoiceId}", actor: "alan", resource: "alan-order+grace-invoice", expected: 404) (0ms)
Test run summary: Failed!
total: 23
failed: 8
succeeded: 15
skipped: 0
duration: 1s 148ms
Çıktıyı kısalttım; her satırın altında aynı Expected/Actual bloğu ve stack trace var. Kırmızıların dağılımına bak: 401 ve 403 satırlarının hepsi yeşil. Authentication ve rol kontrolü çalışıyor, çünkü onları framework yapıyor. Kırmızıların hepsi "kimin verisi" sorusunda: başka müşteri, başka tenant, başka siparişin faturası ve body'de başka tenant'ın kitabı. Bu tam olarak API1'in, BOLA'nın tanımı.
Bir de şunu fark et: liste endpoint'i yeşil, çünkü orada filtreyi elle yazmışız. Elle yazılan filtre unutulan yerde yok demek; bu yüzden bir sonraki yazıda filtreyi endpoint'lerden alıp veri erişim katmanına taşıyacağız.
Üretimde Dikkat
Matris ancak gerçek kararları kapsıyorsa değerlidir. İlk günden "her endpoint için en az bir izin, bir başka-müşteri, bir başka-tenant ve bir anonim satırı" kuralını koy. Bizim 23 satırımız bu kuralla yazıldı; serinin sonunda endpoint sayısı arttıkça yüzün üstüne çıkacak ve koşu süresi yine birkaç saniye olacak, çünkü hepsi aynı in-memory test host'unda koşuyor.
Matrisi kim değiştiriyor sorusu kod kadar önemli. CSV dosyasını CODEOWNERS'ta (repository'deki dosya sahiplerini tanımlayan GitHub/GitLab dosyası) güvenlik sorumlusuna bağlamak, "testi yeşile çekmek için beklentiyi değiştir" refleksini görünür kılar. Matristeki bir 404'ün 200'e döndüğü bir pull request, endpoint kodundaki bir değişiklikten çok daha kolay fark edilir.
Test authentication handler'ı yalnız test projesinde yaşamalı. Aynı handler'ı "geliştirme kolaylığı" için API projesine koyup IsDevelopment() ile açmak, yanlış ortam değişkeniyle production'a header'la kimlik seçme kapısı açmak demek. Lab'daki TokenTool'un da API projesinde değil, ayrı bir projede durmasının sebebi bu.
Son olarak seed id'lerinin sabit olması test için doğru, production için değil. Gerçek sistemde id'ler Guid.CreateVersion7() ile üretiliyor; tahmin edilmesi zor olmaları hoş ama yetkilendirmenin yerini tutmuyor. Bu konuya bir sonraki yazıda döneceğiz.
Sık Düşülen Tuzaklar
Birinci tuzak, yalnız 401 ve 403 test etmek. Birçok ekibin "authorization testi" anonim isteğe 401, yanlış role 403 bekleyen birkaç testten ibaret. Bunlar framework'ü test eder. Asıl hata, bizim ilk koşuda gördüğümüz gibi, doğru role sahip ama yanlış veriye uzanan isteklerde.
İkincisi, matrisi koda gömmek. [InlineData("alan", "grace-order", 404)] satırlarını test method'unun üstüne dizmek de çalışır, ama o zaman matris test kodunun bir parçası olur ve "hangi endpoint'in hangi rolde ne döndüğü" sorusunun tek bir okunabilir cevabı kalmaz. CSV'yi bir ürün sahibi de okuyabilir.
Üçüncüsü, test için seed'i paylaşıp sırayı varsaymak. Matrisimizde POST /orders satırı yeni sipariş yaratıyor. Bir başka satır "Alan'ın tam bir siparişi var" diye sayım yapsaydı, sonuç test sırasına bağlı olurdu. Matris satırları yalnız durum kodu kontrol ediyor; sayım gerektiren testler ayrı factory kullanmalı.
Dördüncüsü, 404 ile 403'ü karıştırmak. Bir satır 404 beklerken endpoint 403 dönerse test kırılır ve bu iyi bir şey: tutarsızlık da bir sızıntıdır. 403, "bu kayıt var ama sana yasak" demektir ve id'nin geçerli olduğunu doğrular.
Küçük Bir Deneme
★☆☆ Lab'ı çalıştır, Linus için token al ve Alan'ın sipariş id'siyle GET /orders/{orderId}/invoices/{invoiceId} endpoint'ine istek at. Matristeki hangi satır bu isteği temsil ediyor?
★★☆ Matrise GET /orders/{orderId}/invoices/{invoiceId} için Margaret'ın (North destek) Linus'un siparişine eriştiği bir satır ekle. Beklenen değeri önce yaz, sonra testi koştur. Kırmızı mı?
★★★ AuthorizationMatrix.Load() method'una bir doğrulama ekle: matristeki her endpoint için en az bir 200/201 satırı ve en az bir anonymous satırı yoksa test başlamadan hata versin. Bu kural sana hangi endpoint'te eksik satır olduğunu söyledi?
Bir sonraki yazıda kırmızıların hepsini kapatıyoruz, ama endpoint'lere tek tek Where ekleyerek değil. EF Core 10'un named query filter'larıyla tenant ve owner kapsamını DbContext'e taşıyacak, kapsamsız sorguyu yalnız açıkça işaretlenmiş yerlere bırakacak ve matristen başka tenant senaryolarını otomatik üreteceğiz.