Mert Özen Further With Every Line
Security Authentication and Authorization — #16

Authorization Models: RBAC, ABAC, ReBAC and Permission-Based Design in Multi-Tenant Scenarios

Mert Özen Nov 8, 2026 11 min 2 views
Authorization Models: RBAC, ABAC, ReBAC and Permission-Based Design in Multi-Tenant Scenarios

Stuffing conditions into roles leads to role explosion; the fix is to use RBAC, ABAC and ReBAC as layers. We reduce roles to permission bundles, write the refund rule as a policy that returns reasoned decisions from tenant, limit and time attributes, show the Zanzibar idea with a toy relationship graph, pin it all with tests, and discuss permissions in tokens, the tenant claim and auditing impersonation.

Version note: The code in this post was built and run with .NET 10, xUnit 2.9.3, Microsoft.Extensions.TimeProvider.Testing 10.11.0 and Keycloak 26.8.2.

Guess First

Bookstore's support team can issue refunds. Then the rules arrive: only for orders of stores in their own city, up to 500 TL, and only if the order is not older than 30 days. Next, the manager of the Kadıköy store wants to see her store's orders, but not Beşiktaş's. Do you solve these with roles? The day you create a role called SupportIstanbulRefundUpTo500, you have met this post's first problem.

The previous posts answered "who is this?". This post is about designing the answer to "may this person do this?": RBAC (Role-Based Access Control), ABAC (Attribute-Based Access Control) and ReBAC (Relationship-Based Access Control). How policies, requirements and handlers work in ASP.NET Core was covered in the "Authorization" post of the ASP.NET Core Web API (Application Programming Interface) series; the depth of the internal mechanism comes in post 01 of the upcoming "ASP.NET Core Security" series. Here we design the model, not the mechanism. The code lives in a framework-independent class library, and every rule is pinned down with a test.

Role Explosion

The idea of RBAC is simple and the right starting point for most apps: give the user a role, check the role in code. The trouble starts when the role begins to carry two things at once: "what job does this person do" and "under which conditions". As conditions creep into role names, roles multiply: a role per city, per limit, per store. In a chain with a hundred stores, a store manager role means a hundred roles. In code, lists like [Authorize(Roles = "Admin,Support,StoreManager")] pile up, and adding a new role means searching the code.

16-authz-models-en.png

The way out is to see the three models as layers, not rivals. Let RBAC answer "which jobs can this role do?" and reduce the result to permissions. Let ABAC answer "is it allowed under these conditions?" with the attributes of the resource and the environment. Let ReBAC answer "how is the user related to this object?" from a relationship graph. Bookstore has all three.

Permission-Based Design: A Role Is a Bundle

The first step is for code never to ask for a role name. Code asks for a permission; a role is only a bundle of permissions:

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 layer: a role is only a bundle of permissions; code asks for the permission, not the role name
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 roles arrive in realm_access.roles in the access token
    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 confines the authorization-server-specific part to a single place; that is the "access it behind an adapter" advice from post 15. If you take roles from Entra ID tomorrow, this is the only method that changes. I added an endpoint to the API from post 13; Angular will decide what to show in the UI (User Interface) based on it (post 17):

// For the SPA's UI decisions: the role -> permission mapping lives on the server, in one place
app.MapGet("/me/permissions", (ClaimsPrincipal user) =>
    RolePermissions.For(RolePermissions.KeycloakRealmRoles(user)).Order())
    .RequireAuthorization(policy => policy.RequireAuthenticatedUser().RequireEndUser());

With the three test users, through the BFF (Backend for Frontend) from post 14:

ada: ["books.read","orders.read"]
alan: ["books.read","orders.read","orders.refund"]
grace: ["books.read","books.write","orders.read","orders.refund"]

Ada is Customer, Alan Support, Grace Admin. Roles Keycloak adds to every user, such as default-roles-bookstore and offline_access, are not in the mapping, so they add no permission; an unknown role does not widen access. I pinned this "fail closed" behavior with a test.

Permissions in the Token, or from the Server on Every Request?

There are two paths. The first is putting permissions in the token: the API decides without asking anyone, and it's fast. It has two costs. The token bloats: Ada's access token, with only a few roles, is already 1411 characters in the lab; it travels in a header on every request, and for an admin account with hundreds of permissions that number multiplies. More important is staleness: when you remove Alan's refund permission, Alan keeps refunding until the token in his pocket expires (in post 13 we saw that a JWT (JSON Web Token), a JSON (JavaScript Object Notation) Web Token, cannot be revoked).

The second is to carry only identity and coarse roles in the token and resolve permissions on the server for each request: the role → permission mapping lives in the API, in the database if needed, behind a short-lived cache. Changes show up with a delay equal to the cache duration. In the lab I chose a middle path: roles in the token (managed by Keycloak, rarely changing), the mapping in the API (owned by the app, changing often). When a role assignment changes, staleness is bounded by the access token lifetime, 5 minutes; when the mapping changes, there is none.

The general rule: put in the token what you can tolerate being wrong for the token's lifetime. Identity, tenant and coarse roles usually qualify; fine-grained permissions and resource-level grants don't.

ABAC: Attributes Make the Decision

Alan has the orders.refund permission. But can he refund every order? The rules from Guess First are not in the permission itself; they are in the attributes of Alan, the order and the time:

// RefundLimit is an attribute of the user (null = unlimited): not a role, the size of the permission
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 layer: the permission is necessary but not sufficient; resource and environment attributes join the decision
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();
    }
}

There are two design decisions. First, the limit is not a role but an attribute of the user: when someone new joins support and starts with a 300 TL limit, you don't create a new role. With decimal?, null means "unlimited"; in C#, 1250 > null evaluates to false, so the Admin's check passes naturally. Second, the decision comes back with its reason, not as a bool; that is what goes into the audit log and the error message. The clock comes through TimeProvider so the 30-day rule can be tested at a fixed time:

[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's weak spot shows here: as rules spread through the code, it gets hard to understand which combination allows what. The cure is to keep rules in one place and pin every branch with a test; if the need grows, tools that write rules in a policy language separate from code come in (OPA (Open Policy Agent) with Rego, AWS's (Amazon Web Services) Cedar).

ReBAC: Relationships and the Zanzibar Idea

Alan, manager of the Kadıköy store, should see his store's orders; Grace, manager of Beşiktaş, should not. You could write this with ABAC too (order.StoreId == actor.StoreId), but once relationships start to chain (a regional manager sees all stores in the region, an order can be shared with a customer), attributes are not enough. The Zanzibar paper Google published in 2019 (Zanzibar: Google's Consistent, Global Authorization System, USENIX (Advanced Computing Systems Association) ATC (Annual Technical Conference) 2019) solves this problem with relationship tuples: object#relation@subject. The rules are relation definitions such as "an order's viewer is its owner or a viewer of the store it belongs to". OpenFGA and SpiceDB are open-source implementations of the idea. A toy version to see the idea:

// ReBAC layer: a toy version of the Zanzibar idea. Relations are stored as tuples: 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));

    // Rules (same logic as an OpenFGA model):
    //   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; // limit against cycles
        }
        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;
}

The test output for the whole model:

$ 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's strength is in the Removing_a_relation_revokes_access_immediately test: the moment Alan's manager relation is deleted, his access ends without any token being renewed, because the decision is read from the graph on every request. Its cost is in the same sentence: every authorization check is a query, and relationship data is a separate source that must be kept in sync with the app's database. A real ReBAC service (OpenFGA, SpiceDB) has caching, consistency tokens and reverse queries like "list the orders this user can see" for these; the toy version doesn't. Move to ReBAC when you really have sharing, hierarchies or an organization tree; for just "is this the owner?", order.CustomerId == userId is enough.

Multi-Tenant: The Tenant Claim, Switching Tenants and Impersonation

If Bookstore serves separate businesses in several cities, every user and every record belongs to a tenant. The actor.TenantId != order.TenantId check in the ABAC example is the smallest form of this. The rule is simple but must be applied consistently: the tenant identity comes from a claim in the token (tenant_id), not from a parameter or header the client sends. Every query must also filter by tenant in data access; EF (Entity Framework) Core's global query filters are a good tool for this (the "Global Query Filter: Soft Delete, Multi-Tenancy and IgnoreQueryFilters" post of the EF Core series), and RLS (Row-Level Security) in the database is a second line of defense.

If a user works in more than one tenant (a regional manager who manages both the Istanbul and Ankara stores), keep the active tenant in the token and design switching tenants as getting a new token. Reading the active tenant from a query string means the user can pick a tenant on every request and the check is forgotten somewhere.

Support may need impersonation to "see it through the customer's eyes". Don't do it with Ada's password or by copying Ada's token. In the right design the token carries two identities together: the subject (sub: Ada) and the actor (act: Alan). RFC (Request for Comments) 8693, OAuth 2.0 Token Exchange, defines the act claim exactly for this. The API writes both identities to the audit log on every write operation, and the impersonation token is short-lived with narrowed permissions (it cannot refund or change passwords). The audit log should not say "Ada requested a refund" but "Alan requested a refund on behalf of Ada".

In Production

When logging authorization decisions, write the reason too; that is why Decision.Reason exists. When a user asks "why can't I refund?", the answer should be in the log. But returning the reason to the client as is isn't always right: the message "order belongs to another tenant" reveals that the order exists. Return a 404 or a generic 403 to the outside and write the details to the log; the IDOR (Insecure Direct Object Reference) discussion in the "Broken Access Control (1)" post of the Web Security (OWASP Top 10) series applies unchanged.

If you move the role → permission mapping into the database, record who changed what and when, and put privilege-raising changes (adding orders.refund to a role) behind a two-person approval. Keep the permission list fixed in code: creating a new permission name in the database protects no code.

Measure for performance: the authorization check runs on every request. An in-memory FrozenDictionary lookup takes nanoseconds; a permission query that goes to the database on every request can add tens of milliseconds per page. Cache it, but limit the cache duration to the revocation delay you can tolerate.

Common Pitfalls

First, checking role names in code (User.IsInRole("Admin")); every new role becomes a code change. Second, embedding conditions in role names and walking into role explosion. Third, taking the tenant identity from the client. Fourth, treating a hidden button in the UI as an authorization check; the real check is in the API, and we will talk about it separately in post 17.

Fifth, interpreting an unknown role or a missing attribute as "allow"; every ambiguity must be denied. Sixth, setting up ReBAC without a need; keeping relationship data in sync is a project in itself. Seventh, doing impersonation without an audit log.

A Small Experiment

★☆☆ Add a StoreManager role to RolePermissions (books.read, orders.read) and verify that the unknown-role test still passes.

★★☆ Add a tenant_id attribute to users in Keycloak, put it in the access token with a protocol mapper, and write a POST /orders/{id}/refund endpoint in the API that runs RefundPolicy with the tenant from the token. Send a request for an order in the Ankara tenant as Alan from Istanbul, and check that the response does not leak the order's existence.

★★★ Add a region object to RelationshipGraph: a regional manager becomes a viewer of every store in the region. Then build the same model in OpenFGA running in Docker and show that both implementations give the same answer on the same tests.

We designed how authorization decisions are made on the server. In the next post we move to the Angular side: a signal-based auth state service, functional guards, a permission directive, a single refresh for concurrent 401s in the token-based scenario, and session sync across tabs; and why all of it is for user experience only.