The API Security Lab: A Multi-Tenant Orders API, an Authorization Matrix and a Security Test Harness
Most API security breaks on the question "whose data is this?", and you only catch it systematically with an authorization matrix kept apart from the code that states the expected status for each endpoint × actor × resource. We build a two-tenant, three-role Minimal API lab, run the matrix with an xUnit Theory, WebApplicationFactory and a test authentication handler, and see eight BOLA holes on the first run.
Version note: The code in this post was built and run with .NET 10, EF (Entity Framework) Core 10.0.14 and xUnit v3 4.0.2; risk names follow the OWASP (Open Worldwide Application Security Project) API (Application Programming Interface) Security Top 10 2023.
Guess First
You are writing an orders API for a bookstore chain. Two stores (North and South) share the same API and the same database. Every endpoint has .RequireAuthorization(), the fallback policy is on, the admin endpoint requires a role. Your tests are green. Now the question: what happens if Alan, a North customer, somehow learns the id of an order placed by Linus, a South customer? And what if Alan combines his own order id with Grace's invoice id in the same URL (Uniform Resource Locator)?
If your answer is "we have tests, we're fine", keep reading: by the end of this post we will see eight red test rows against this very API. None of them is a compiler error and none of them is the kind of thing existing unit tests would catch. All of them surface when a single table, the authorization matrix, is run automatically.
This series walks through the OWASP API Security Top 10 2023 in order. The goal is not to patch one endpoint but to build defences that cover the whole API and are guarded by tests. This first post builds the lab and the test harness we need for that.
From the Web Top 10 to the API Top 10: Where the Focus Shifts
In the "OWASP Beyond the Top 10" post of the Web Security (OWASP Top 10) series we took a quick look at the API Security Top 10 and separated BOLA (Broken Object Level Authorization), BOPLA (Broken Object Property Level Authorization) and BFLA (Broken Function Level Authorization). I won't repeat that here; I'll only explain in one paragraph why this deserves a series of its own.
The Web Top 10 is largely about problems between the browser and the server: injection, XSS (Cross-Site Scripting), cookie settings, misconfiguration. In the API Top 10, three of the first five items are about authorization: at the object level (API1), the property level (API3) and the function level (API5). The reason is simple: the client of an API is not the Angular app you wrote, it is anyone who can send an HTTP (Hypertext Transfer Protocol) request. A hidden button, a disabled input or a route guard means nothing to the API. For every request, every id and every field, the question "may this person do this?" has to be answered on the server.
The hardest version of that question shows up in multi-tenant SaaS (Software as a Service) applications. In a single-user application you ask "does this record belong to this user?". In a multi-tenant application there are two questions: does the record belong to this tenant, and within the tenant, to this user? Forgetting the first one means opening one customer's data to another company; in the B2B (Business to Business) world that is called a breach of contract.
The Lab: Two Tenants, Three Roles, One Orders API
The Bookstore lab of the Web Security series had a single tenant. In this series we build a new lab: Bookstore.Orders.Api. ASP.NET Core 10 Minimal API, EF Core 10 and SQLite. There are two tenants: north (paid plan) and south (free plan). Each tenant has three roles: customer, support and admin. Users live in the seed data with fixed ids:
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 and Grace are North customers, Margaret is on North's support team and Ada is North's administrator. On the South side Linus is a customer, Barbara is support and Edsger is the administrator. Each customer has one order and one invoice attached to it. The ids are UUIDv7 (Universally Unique Identifier, version 7); I keep them fixed so that tests and curl examples can refer to these records by name.
Every tenant-owned entity implements the same small interface. In the next post this interface lets us set up the global query filter in one place:
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 is what the order costs us; the support team may see it, the customer should not. This field will be useful in the API3 post. For now let's look at Program.cs:
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()
// Every endpoint not explicitly marked requires at least authentication
.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();
We saw what the fallback policy does in the A01 Broken Access Control post of the Web Security series: even if an endpoint has no authorization metadata, authentication is required. MapInboundClaims = false keeps the sub, tenant_id and role claim names in the JWT (JSON Web Token) as they are instead of mapping them to .NET's old long claim URIs (Uniform Resource Identifiers). ICurrentUser is a small service that reads these three claims:
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.");
}
If a claim is missing, it throws instead of inventing a default. A token without tenant information running a query with an "empty tenant" can lead to something worse than silently returning wrong data; fail closed applies here too.
The First Version of the Endpoints
The order endpoints are written like a typical first version. In the list endpoint the developer remembered the tenant and customer filter:
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);
});
In the details and invoice endpoints they did not. It doesn't stand out in code review either, because the endpoint is "protected": the fallback policy requires authentication.
// Details: read by id alone
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());
// Sub-resource: nobody checks that the invoice id belongs to the order id
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 is open only to the customer role and finds the book with db.Books.FirstOrDefaultAsync(b => b.Id == request.BookId). There is no tenant in that line either; the matrix will show shortly that an id in the body is as dangerous as an id in the URL. /admin/users sits in a route group protected by the TenantAdmin policy.
Tokens: A Small Tool Instead of an Identity Provider
We don't set up an identity provider (Keycloak, OpenIddict or Entra ID) in the lab; the details of token validation and OAuth flows belong to the upcoming Authentication and Authorization series. Instead, a small console app called tools/Bookstore.Orders.TokenTool mints short-lived JWTs with a key we generate in the working folder:
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));
We generate the key file with openssl and never commit it; the .lab/ folder is in .gitignore. Let's run the API on port 19510 and get tokens for two users:
$ 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)
First without a token, then with Alan's token, against the list endpoint:
$ 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"}]
The list is correct: Alan sees only his own order. Now let's give Linus's order id to the details endpoint. Where Alan learned that id doesn't matter; he may have seen it in a support email, a shared screenshot or a log line.
$ 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"}
A North customer just read a South customer's address and, on top of that, our cost. You can find this one by one with curl, but it doesn't scale: ten endpoints, seven users and a few resources each means hundreds of combinations. We need a table.
The Authorization Matrix: Separating the Rule from the Code

An authorization matrix is a table that states the expected HTTP status code for each endpoint × actor × resource triple. What matters is that the table lives as data, separate from the code. The code says "what is done", the matrix says "what should be done". If both live in the same place, whoever changes the code quietly changes the expectation too. We keep the matrix in the test project as a CSV (Comma-Separated Values) file:
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
When reading the rows, pay attention to three columns. actor is a user name from the seed or anonymous. resource is a name that says which records the ids in the route come from: alan-order fills both {orderId} and {invoiceId} with Alan's records, while alan-order+grace-invoice takes the order id from Alan and the invoice id from Grace. expected is our decision. For another tenant's or another customer's record we chose 404, not 403; we discussed why in the A01 post of the Web Security series and will revisit it from the API angle in the next post. Linus ordering a North book gets 400: an id in the body should behave like an unknown book.
The matrix is not made only of "forbidden" rows. Allow rows such as alan → alan-order → 200 matter at least as much. A test set that only expects denials will also pass a broken API that returns 404 for everything.
Turning the Matrix into Tests
We write three pieces to run the matrix. The first is a small helper that turns a CSV row into an HttpRequestMessage:
public static class AuthorizationMatrix
{
// Resource names in the matrix resolve to fixed ids from the seed data
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()));
}
The second is a test authentication handler. Minting a JWT for every test is possible but unnecessary; the test is not about token validation, it is about authorization decisions. The handler finds the user name from the X-Test-User header in the seed and builds a principal with the same three claims:
// In tests we pick the user from a header instead of minting a JWT; registered only in the test host
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)));
}
}
Without the header it returns NoResult, so the anonymous rows really are unauthenticated requests and the 401 comes from the framework's own challenge. The third piece is the WebApplicationFactory. It registers the handler only in the test host and switches the default scheme to it; for the database it uses a shared in-memory SQLite:
public sealed class OrdersApiFactory : WebApplicationFactory<Program>
{
// Shared in-memory SQLite: the database lives as long as this connection stays open
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> no longer needs the public partial class Program; line; in .NET 10 a source generator emits that declaration for projects with top-level statements. We covered the general integration test setup in the WebApplicationFactory post of the ASP.NET Core Web API series; here there are just two settings that security needs: the test scheme and a separate database per factory.
The test class is almost boringly short. Every row of the matrix is an xUnit Theory row:
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);
}
}
I pass the Theory parameters as four primitives instead of a MatrixRow because of test names: xUnit writes these values into the test name, and whoever reads a red row sees which combination broke without opening the CSV.
The First Run: Eight Red Rows
$ 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
I shortened the output; under each row there is the same Expected/Actual block and a stack trace. Look at how the reds are distributed: all the 401 and 403 rows are green. Authentication and role checks work because the framework does them. All the reds are about "whose data": another customer, another tenant, another order's invoice and another tenant's book in the body. That is exactly the definition of API1, BOLA.
Notice one more thing: the list endpoint is green, because we wrote the filter by hand there. A filter written by hand is missing wherever it is forgotten; that is why in the next post we take the filter out of the endpoints and move it into the data access layer.
Production Notes
A matrix is only valuable if it covers the real decisions. From day one, set the rule "for every endpoint, at least one allow row, one other-customer row, one other-tenant row and one anonymous row". Our 23 rows were written with that rule; by the end of the series they will grow past a hundred as endpoints are added, and the run will still take a few seconds because everything runs in the same in-memory test host.
Who changes the matrix matters as much as the code. Tying the CSV file to the security owner in CODEOWNERS (the GitHub/GitLab file that defines owners of files in a repository) makes the "change the expectation to turn the test green" reflex visible. A pull request in which a 404 in the matrix turns into a 200 is far easier to notice than a change in endpoint code.
The test authentication handler must live only in the test project. Putting the same handler into the API project "for developer convenience" and enabling it with IsDevelopment() means that a wrong environment variable opens a door in production for choosing your identity with a header. That is also why the lab's TokenTool lives in a separate project instead of the API project.
Finally, fixed seed ids are right for tests, not for production. In the real system ids are generated with Guid.CreateVersion7(); being hard to guess is nice but does not replace authorization. We'll come back to this in the next post.
Common Pitfalls
The first pitfall is testing only 401 and 403. Many teams' "authorization tests" are a few tests that expect 401 for an anonymous request and 403 for the wrong role. Those test the framework. The real bug, as we saw in the first run, is in requests that have the right role but reach the wrong data.
The second is burying the matrix in code. Stacking [InlineData("alan", "grace-order", 404)] lines above the test method also works, but then the matrix becomes part of the test code and there is no single readable answer to "what does which endpoint return for which role". A product owner can read the CSV too.
The third is sharing the seed and assuming an order. In our matrix the POST /orders row creates a new order. If another row counted "Alan has exactly one order", the result would depend on test order. Matrix rows only check status codes; tests that need counts should use a separate factory.
The fourth is mixing up 404 and 403. If a row expects 404 and the endpoint returns 403, the test breaks, and that is a good thing: inconsistency is a leak too. 403 says "this record exists but you can't have it" and confirms the id is valid.
A Small Experiment
★☆☆ Run the lab, get a token for Linus and call GET /orders/{orderId}/invoices/{invoiceId} with Alan's order id. Which row of the matrix represents this request?
★★☆ Add a row to the matrix for GET /orders/{orderId}/invoices/{invoiceId} where Margaret (North support) accesses Linus's order. Write the expected value first, then run the tests. Is it red?
★★★ Add a validation to AuthorizationMatrix.Load(): if an endpoint in the matrix doesn't have at least one 200/201 row and at least one anonymous row, fail before the tests start. Which endpoint did this rule tell you is missing rows?
In the next post we close all the reds, but not by adding a Where to each endpoint. With EF Core 10's named query filters we move tenant and owner scope into the DbContext, leave unscoped queries only in explicitly marked places, and generate other-tenant scenarios automatically from the matrix.