Archiboard

Set I · Identity & Access

Authorization policies, requirements and handlers: roles that belong to a tenant

A role claim without a tenant next to it is a global role, and a global role in a multi-tenant product is a bug waiting for the second tenant.

In February 2014 an accountant deleted an invoice belonging to a company she had never heard of. She was Admin in her own firm's tenant. Our forms authentication cookie said role: Admin. The [Authorize(Roles = "Admin")] attribute on the delete action believed the cookie, the invoice id in the URL belonged to a different tenant, and nothing in the chain asked "Admin of what". We fixed it in a day and understood it over a month.

In a multi-tenant web application, a role is an edge between a user and a tenant, not a property of the user. The moment you write a role into a claim without the tenant beside it, you have made a global role, and a global role is the wrong shape for a product where the same person is Owner in one company and Viewer in the next. This sheet is how I draw authorization so that the tenant is inside every decision, not bolted on after. The mechanism has changed twice since I first drew it; the mistake has not changed at all.

The drawing is one decision, end to end. The interesting part is the lookup on the right, and the key it is cached under.

One authorization decision with the tenant membership lookup request + cookie policy Invoice.Edit TenantRoleRequirement handler per-request membership check tenant context: acme membership lookup user 42, acme cache: user + tenant get memberships miss Succeed or Fail keyed by user AND tenant, never by user alone
Fig. 1. One authorization decision. The handler asks for the user's roles in the current tenant, never the user's roles in general, and the cache key carries both halves.

Why the role claim lies

The role support in ASP.NET Core, IsInRole and [Authorize(Roles = ...)], assumes a role is a fact about the principal. It inherited that assumption from MVC 5, which inherited it from an intranet, where it is true. In a multi-tenant product the same person is Owner in tenant A, Viewer in tenant B and nothing at all in tenant C, and a cookie can only carry one principal.

You can encode the tenant into the claim, acme:Admin, and write a policy that knows the current tenant. I did that first. It works until it does not: a consultant who works in forty tenants carries forty claims in a cookie that grows past the header limit, and a role change only takes effect at the next login, which for a "remember me" user is next month. The other option is to keep roles out of the cookie entirely and look membership up at decision time, from a table of (user, tenant, role). That is what I do now, with a cache in front, and the rest of the sheet is the mechanics.

Requirement and handler

The requirement carries the role name. The handler resolves the tenant from the scoped context that middleware filled (sheet T-02), takes the user from the sub claim, and asks a lookup service. Both types are shorter than they were on ASP.NET Core 1.0 thanks to primary constructors; the shape has not moved since the day these abstractions replaced my attribute.

public sealed class TenantRoleRequirement(string role) : IAuthorizationRequirement
{
    public string Role { get; } = role;
}

public sealed class TenantRoleHandler(ITenantContext tenant, IMembershipLookup members)
    : AuthorizationHandler<TenantRoleRequirement>
{
    protected override async Task HandleRequirementAsync(
        AuthorizationHandlerContext context, TenantRoleRequirement requirement)
    {
        var userId = context.User.FindFirst("sub")?.Value;
        if (userId is null || tenant.Id is null) return;

        var roles = await members.GetRolesAsync(userId, tenant.Id);
        if (roles.Contains(requirement.Role))
            context.Succeed(requirement);
    }
}

GetRolesAsync returns the expanded set: an Admin also holds Editor and Member, so the hierarchy lives in one place instead of in every handler. The handler is registered scoped, because ITenantContext is scoped; the docs show singletons, and that is fine only when the handler has no per-request dependencies.

Registration is the part that has changed most often. It was services.AddAuthorization(options => options.AddPolicy(...)) for most of a decade. Today:

builder.Services.AddAuthorizationBuilder()
    .AddPolicy("Tenant.Member", p => p.Requirements.Add(new TenantRoleRequirement("Member")))
    .AddPolicy("Tenant.Admin",  p => p.Requirements.Add(new TenantRoleRequirement("Admin")))
    .AddPolicy("Invoice.Edit",  p => p.Requirements.Add(new TenantRoleRequirement("Editor")))
    .SetFallbackPolicy(new AuthorizationPolicyBuilder().RequireAuthenticatedUser().Build());

builder.Services.AddScoped<IAuthorizationHandler, TenantRoleHandler>();
builder.Services.AddScoped<IAuthorizationHandler, InvoiceHandler>();

The fallback policy is not optional. Without it, an endpoint someone forgets to decorate is open to the world, and someone always forgets.

This invoice, not any invoice

A tenant role says the user may edit invoices in acme. It does not say the invoice with id 8841 belongs to acme. The global query filter from sheet T-01 makes that true for anything loaded through the tenant's DbContext, and I still check it again in the handler, because a filter is a default and defaults get bypassed with IgnoreQueryFilters() by a well-meaning report six months later.

Resource-based authorization is the tool: the handler receives the loaded entity and can compare its TenantId before it even looks at roles. It is invoked imperatively, after the entity is loaded, with IAuthorizationService.AuthorizeAsync(User, invoice, Operations.Update).

public sealed class InvoiceHandler(ITenantContext tenant, IMembershipLookup members)
    : AuthorizationHandler<OperationAuthorizationRequirement, Invoice>
{
    protected override async Task HandleRequirementAsync(
        AuthorizationHandlerContext context,
        OperationAuthorizationRequirement requirement, Invoice invoice)
    {
        if (invoice.TenantId != tenant.Id) { context.Fail(); return; }

        var userId = context.User.FindFirst("sub")!.Value;
        var roles = await members.GetRolesAsync(userId, tenant.Id);
        var allowed = requirement.Name switch
        {
            "Read"   => roles.Contains("Member"),
            "Update" => roles.Contains("Editor"),
            "Delete" => roles.Contains("Admin"),
            _        => false
        };
        if (allowed) context.Succeed(requirement);
    }
}

context.Fail() on the tenant mismatch is deliberate. A handler that simply does not succeed can be overruled by another handler that does; Fail cannot. The invoice from the opening would have hit that line.

Naming the policies

Name the capability, not the role. Invoice.Edit says what the endpoint is about; RequireEditorRole says how it was implemented in 2014, and the implementation will change. Keep the names in a static class of constants so a typo is a compile error instead of a 403 in production. Resist the urge to generate policies from a custom IAuthorizationPolicyProvider for every Tenant.{role} string; a dozen explicit names has been enough on every product I have drawn, and an explicit list is something a reviewer can read.

Caching membership

A lookup per decision means a database round trip per request, and often three: the fallback policy, the endpoint policy and the resource check. The cache is keyed membership:{userId}:{tenantId}, holds the expanded role set, and lives two minutes. On any membership change we evict the key explicitly, so the two minutes are a ceiling, not the normal case. A removed user can act for at most two minutes on an instance that missed the eviction, and I accept that.

For most of this sheet's life the cache was in process, HttpRuntime.Cache at first and IMemoryCache later, which is per instance, so the eviction only happened on the instance that handled the admin's click. With HybridCache in .NET 9 and later the same code gets a distributed layer (we use Redis) and the eviction reaches every instance. Cache the roles, never the AuthorizationResult; a result is bound to a resource and a policy, and you will cache a yes that belonged to a different invoice.

The decision I would draw today

Handler plus lookup for everything, resource-based checks for anything loaded by id, policies named after capabilities, a two-minute cache with explicit eviction. The trade-off I make is a round trip per cold decision in exchange for roles that take effect the moment an admin changes them, which is what an admin expects when they remove a leaver at five o'clock. I have not needed a permission matrix beyond roles on any of these products; if you do, the requirement grows a permission name and the lookup returns permissions instead of roles. The drawing does not change.

Drawn from