Resource Based Authorization Rules in .NET Core

Most .NET developers are familiar with using the [Authorize] attribute on their controller actions to specify access requirements for certain operations. It’s often combined with a Role property to require the current user to belong to a certain role. Recent versions of .NET and .NET Core introduced a Policy authorization mechanism as well.

Instead of specifying a Role you can specify a required Policy. This is an improvement because we can get a little more precise: an operation requires the Read permission policy, not simply that the user belongs to the Admin role. Flexibility! We can easily change what permissions are granted by each role, and have very little code to change.

This all works pretty well for coarse permissions like “can this user read things in general?” but it is insufficient for the more complicated case of evaluating access to particular resources and entities in the system. Just because the user is in the right role or group to grant them the ReadRecipe permission doesn’t mean they have access to “this particular recipe”. Handling rules like that requires a bit of custom code, but ASP.NET Core provides some hooks that can help us make our business logic clear, without being too muddled with authorization concerns.

What follows is a sketch of my approach to resource based authorization.

Heads up! Permission management is a complex topic that you absolutely have to get right to build a secure application. We’re going to explore our options through a mixture of code examples and explanations. I’m trying to get the gist of it across, there’s very little here that is meant to be copy-pasted into your own codebase. Instead, focus on understanding the concepts and mechanisms behind each step.

Lets imagine we are building the Worlds Greatest Recipe App. Users can sign up and collect their favorite recipes. Users have to be logged in to create recipes, and users can only see other people’s recipes when that recipe is explicitly shared. When sharing a recipe, a user can specify if the other person can only see it or if they can also update it.

In addition, we have a subscription model. The Free tier lets a user read recipes that have been shared with them. The Paid tier lets the user create and manage his or her own recipes.

Lets model a few of the concepts in this system, particularly the ones that relate most closely to authorization.

enum SubscriptionLevel
{
    Free = 1,
    Paid = 2
}

class UserAccount
{
    public Guid Id { get; set; }
    public string Email { get; set; }
    public SubscriptionLevel { get; set; }
    // other fields elided
}

class Recipe
{
    public Guid Id { get; set; }
    public string Name { get; set; }
    public Guid OwnerId { get; set; } // Foreign Key to the UserAccount who owns the recipe
    // other fields elided
}

So far this lets us model some of our requirements, but we haven’t gotten to the sharing model quite yet. Right now we’re concerned with how we’re going to do the subscription management. We have a UserAccount model, and its SubscriptionLevel property can tell us whether that user has paid for access yet or not.

We need to enforce the rules that Free tier users can only read recipes, but Paid tier users can also create, update, delete, and share their recipes. We could write lots of if checks throughout the system that guard on the user’s SubscriptionLevel. But this would be error prone if and when our product manager decides to switch up what kinds of things the different tiers are allowed to do. What if they introduce a middle tier that allows creating recipes but not sharing them? We’d have a lot of code to carefully review and update the if checks.

Instead, we’d like to manage the relationship between SubscriptionLevel and the included permissions in one place. Then code only needs to check if the current user has the right permission, and not be concerned with the specific SubscriptionLevel.

Here’s a take on that:

enum Permission
{
    CreateRecipe = 1,
    ReadRecipe = 2,
    UpdateRecipe = 3,
    DeleteRecipe = 4,
    ShareRecipe = 5
}

static class PermissionMap
{
    public static IEnumerable<Permission> GetPermissions(SubscriptionLevel level)
    {
        switch(level)
        {
            // Requirement: Free tier can only read recipes
            case SubscriptionLevel.Free:
                yield return Permission.ReadRecipe;

            // Requirement: Paid tier can do everything
            case SubscriptionLevel.Paid:
                yield return Permission.CreateRecipe;
                yield return Permission.ReadRecipe;
                yield return Permission.UpdateRecipe;
                yield return Permission.DeleteRecipe;
                yield return Permission.ShareRecipe;
        }
    }
}

Great, this class will help us convert a subscription level into a set of actions the user can perform. If our product owner ever decides to change what things a subscription tier can do, theres only one place to fix it. We don’t have to go trawling through our code looking for all kinds of if checks on SubscriptionLevel.

For maximum future flexibility, we want to check permissions not roles or subscription levels when performing authorization. It allows us to be more configuration driven as we design for future product changes.

SubscriptionLevel Checks

It would be really nice if we could apply a [Authorize(Policy="CreateRecipePolicy")] attribute to the POST /recipes API endpoint. That’s a nice inexpensive check that could quickly block out access to people in the Free tier.

To support custom [Authorize] attributes, we need to teach ASP.NET Core about our permission model.

Registering Policies for Each Permission

ASP.NET’s authorization system is Policy based. A Policy is a collections of Requirements that must be met. In our case, requirements are things like

  • The user must be logged in
  • The user must have a the ReadRecipe permission
  • The user must have had the recipe shared with them

We can teach ASP.NET about our custom policies and how to evaluate them. We have a CreateRecipePolicy that requires the user to have the ReadRecipe permission. When ASP.NET Core sees an [Authorize(Policy="CreateRecipePolicy")] attribute on a controller action, it looks up the policy by name and gets a list of Requirements that must be met. Out of the box, it knows how to evaluate Requirements like “the user must have a specific Claim“.

Our goal then is to register a Policy for each Permission where the Requirement is that the user has that Permission in their Claims set. With a bit of reflection, we can do that pretty easily!

In StartUp.cs:

services.AddAuthorization(options =>
{
    // users must be logged in by default
   options.DefaultPolicy = new AuthorizationPolicyBuilder()
      .RequireAuthenticatedUser()
      .Build();


    // Add policies for each permission, so that we can do [Authorize(Policy="CreateRecipePolicy")] on controllers
    string[] permissions = Enum.GetNames<Permissions>();
    foreach (var permission in permissions)
    {
       options.AddPolicy($"{policy}Permission",
           policy => policy.RequireClaim("permission", permission));
    }
});

Here we use reflection to get the names of all the values in our Permissions enum. For each one, we add a new Policy that requires that permission to exist in the user’s ClaimsPrincipal under the permission claim type. For example, we would register the CreateRecipePolicy that requires that a Claim exists with the claim type of permission and the value of "CreateRecipe".

Users can have more than one entry under a given claim type. As long as one exists for the Policy it can pass.

Sweet! ASP.NET now knows about our permissions. We can start decorating our controller endpoints with the right [Authorize] attributes. But if we test it out, we will find that access is always denied because out of the box ASP.NET won’t have any Claims of type permission. We next need to get the permissions for the current user into the current ClaimsPrincipal.

Enhancing the Request’s ClaimsPrincipal

There are a few ways we can do this. If we are doing our own login and using a JWT, we can add the claims during login so that they are already baked into the token. ASP.NET will automatically put them into the current request’s ClaimsPrincipal.

However, this can bloat the token, and if we are using external authentication solutions, it can be tricky to add custom claims. Instead, we can look it up from our own database using some custom middleware. I generally set up a custom AuthContextMiddleware to resolve permissions and other details to populate the ClaimsPrincipal.

Here’s what that might look like:

class AuthContextMiddleware
{
    private readonly RequestDelegate _next;

    public AuthContextMiddleware(RequestDelegate next)
    {
        _next = next;
    }

    // in middleware, services injected in the constructor need to be singletons, since middleware
    // is itself a singleton. Thus if you need per request scoped dependencies, you can take them
    // as parameters to InvokeAsync and ASP.NET will resolve them for you
    public async Task InvokeAsync(HttpContext context, AppDbContext dbContext)
    {
        if (context.User.Identity == null || !context.User.Identity.IsAuthenticated)
        {
            // user is not authenticated, so run the next middleware in the pipeline and bail out
            await _next(context);
            return;
        }

        List<Claim> claims = new();

        // get the user record from the database. Presumably the JWT contains a claim
        // that we can use to look up the row
        AccountUser user = await GetAccountUser(dbContext, context.User);

        // convert the user's subscription level to a list of permissions
        IEnumerable<Permission> permissions = PermissionMap.GetPermissions(user.SubscriptionLevel);

        // convert the list of permissions into Claims
        claims.AddRange(permissions.Select(p => new Claim("permission", p.ToString()));

        // enhance the request's current user with the new claims
        ClaimsIdentity enhancedIdentity = new ClaimsIdentity(claims);
        context.User.AddIdentity(enhancedIdentity);

        // run the next middleware in the pipeline
        await _next(context);
    }

    private Task<AccountUser> GetAccountUser(AppDbContext dbContext, ClaimsPrincipal principal)
    {
        // todo: using the claims in the claims principal, find the AccountUser record
        // in the database
    }
}

This snippet will check if the current user is logged in. If they’re not, it will run the next middleware in the pipeline and then quit. If the user is logged in, it will extract their user Id from the existing ClaimsPrincipal and go fetch the corresponding row from the database. It will then look up the permissions for that user’s AccountLevel and create claims for each one. Finally, it enhances the existing ClaimsPrincipal with the new claims.

Next we need to register it in Startup, after UseAuthentication(). Remember that order matters in Configure. Each Use call adds a new middleware to the pipeline, in the order in which it was invoked. We want ours to run after UseAuthentication so that the basic ClaimsPrincipal has been created and added to the request context.

app.UseAuthentication();
app.UseMiddleware<AuthContextMiddleware>(); // add this

Great! What we’ve got now should enable us to use the [Authorize] attribute on our controller methods. The attribute will guard access to the controller method and require the user to at least have the right permissions. If we ever change what permissions are granted by each SubscriptionLevel we only have to update the PermissionMap class, and not go through each controller endpoint.

We’ve got the starts of a really flexible and powerful permission system, but there’s still more to come.

Authorizing Specific Recipes

As we discussed at the top, checking for the ReadRecipe permission doesn’t tell us the whole story. It doesn’t tell us if the user has access to the particular recipe they are trying to read. We have to implement the rule that recipes can only be read by their owner, or users with whom the owner has specifically shared the recipe.

Let’s create some models that will help us manage sharing.

enum RecipeAccessLevel
{
    Read = 1,
    Write = 2
}

class RecipeAccess
{
    public Guid RecipeId { get; set; }
    public Guid SharedWithId { get; set; }
    public RecipeAccessLevel AccessLevel { get; set; }
    // other fields elided
}

If you’re thinking “this looks like an Access Control List”, you’re right! When an owner shares a recipe with another user, we’ll insert a row into this table giving them access to either Read or Write to the recipe referenced by the RecipeId field.

Once we have this in place, all our controller methods and services will need to check this table to make sure the user is authorized to access the requested recipe.

  • If the current user is the owner of the recipe, ie.e the recipe’s OwnerId matches the current user’s Id, then they can perform any action
  • If the current user is not the owner, then there must be a record in RecipeAccess where the current user’s Id matches SharedWithId and the requested recipe’s Id matches the RecipeId column.

As our system grows, we would wind up duplicating these checks in many places if we didn’t manage it in a central location. We could put it in a service of course, but ASP.NET Core provides some useful patterns to help manage the eventual growth in complexity.

Evaluating Custom Policies for Resources

ASP.NET Core has an IAuthorizationService that we can inject into our controllers or services. We can call its AuthorizeAsync method passing

  • the current request’s ClaimsPrincipal
  • the requested resource (the Recipe model in this case)
  • and the list of policy Requirements the user must meet.

The service then looks for AuthorizationHandler classes that know how to evaluate the Requirement for that particular resource type. By writing a handler and registering it with the dependency injection system, we can hook into this process to perform our custom checks. Neat!

Now there could be more than one handler for each Requirement and Resource type. Each handler gets a chance to vote on access. The handler can

  • Succeed, informing the system that the requirement was met
  • Fail informing the system that the requirement was explicitly not met
  • or abstain from voting altogether.

In order for a Requirement to be met, at least one handler must vote Succeed, and no handler can vote Fail. For example, imagine we have two handlers: one for checking that the user is in the shared list for the recipe, and another for checking that the user hasn’t been banned.

SharedListHandler will call Succeed if the user is in the shared list appropriately. If not, it will abstain, because another handler might grant access for a different reason. BannedHandler will call Fail if the user is banned. If not, it will abstain. Here are the results from different scenarios:

SharedListHandler BannedHandler Result
abstain abstain Access Denied
Succeed abstain Access Granted
Succeed Fail Access Denied

A voting system like this is very flexible. Each type of handler only has to concern itself with the things it is specifically written to check. Additional handlers can apply different checks without needing to considering rules outside of their own scope.

Lets start setting this up.

Modeling Policies and Requirements

First we need to model our access policies and requirements. A Requirement only has to implement a methodless marker interface called IAuthorizationRequirement. We could make a bunch of classes that implements it, but thats tedious, so instead we can leverage the OperationAuthorizationRequirement class. It exposes a Name property to help you differentiate between different requirement types.

// this is just a marker class to have a more specific type. OperationAuthorizationRequirement
// is a base class provided as a utility from the framework.
class RecipeOperationRequirement: OperationAuthorizationRequirement {}

// This defines the `Requirements` that must be met, basically having permission to perform
// this action against a particular resource (Recipe). You can get as detailed as you want.
static class RecipeOperations
{
    public static RecipeOperationRequirement CreateRecipeRequirement = new() { Name = "CreateRecipe" };
    public static RecipeOperationRequirement ReadRecipeRequirement = new() { Name = "ReadRecipe" };
    public static RecipeOperationRequirement UpdateRecipeRequirement = new() { Name = "UpdateRecipe" };
    public static RecipeOperationRequirement DeleteRecipeRequirement = new() { Name = "DeleteRecipe" };
    public static RecipeOperationRequirement ShareRecipeRequirement = new() { Name = "ShareRecipe" };
}

// set up some policies, that use the above requirements
static class RecipePolicies
{
     public static AuthorizationPolicy CreateRecipe = new AuthorizationPolicyBuilder()
            .AddRequirements(RecipeOperations.CreateRecipeRequirement)
            .Build();
    public static AuthorizationPolicy ReadRecipe = new AuthorizationPolicyBuilder()
            .AddRequirements(RecipeOperations.ReadRecipeRequirement)
            .Build();
    public static AuthorizationPolicy UpdateRecipe = new AuthorizationPolicyBuilder()
            .AddRequirements(RecipeOperations.UpdateRecipeRequirement)
            .Build();
    public static AuthorizationPolicy DeleteRecipe = new AuthorizationPolicyBuilder()
            .AddRequirements(RecipeOperations.DeleteRecipeRequirement)
            .Build();
    // ...
}

First we set up a list of requirements, named after the operation that is to be performed. Then we set up a Policy for each Requirement.

Using the Authorization Service

OK, at this point we have what we need to call the authorization service from our controller. We will now be able to to inject the service and the claims principal and get to work.

We’ll load the Recipe from the database, then pass the model into the authorization services, specifying who the current user is and what policy must be met in order to permit access. If access is denied, we’ll bail out with a 401, otherwise we can return the recipe successfully.

Here’s what that looks like:

class RecipeController
{
    // todo: inject these from the constructor
    private ClaimsPrincipal _claimsPrincipal;
    private IAuthorizationService _authService;
    private AppDbContext _dbContext;

    public ActionResult GetRecipe(Guid recipeId)
    {
        var recipe = _dbContext.Recipes.Find(recipeId);

        // evaluate the policy requirements
        var result = await _authService.AuthorizeAsync(_claimsPrincipal, recipe, RecipePolicies.ReadRecipe.Requirements);
        if (!result.Succeeded)
        {
            return NotAuthorized();
        }

        return Ok(recipe);
    }
}

If we give this a try, we’ll find that access is always denied! What did we do wrong?

Right now ASP.NET will check the dependency injection system and not find any handlers for the ReadRecipe requirement, and thus deny access. The requirement was not met since no handler exists that could evaluate it! So we have to write that handler and tell ASP.NET where to find it.

Lets do that:

class RecipeAuthHandler : AuthorizationHandler<RecipeAuthorizationRequirement, Recipe>
{
    // todo: inject this from constructor
    private readonly AppDbContext _context;

    protected override async Task HandleRequirementAsync(AuthorizationHandlerContext context,
        RecipeAuthorizationRequirement requirement,
        Recipe resource)
    {
        Guid userId = GetUserIdFromClaimsPrincipal(context.User);
        if (resource.OwnerId == userId)
        {
            // owner can do anything to a recipe, so we Succeed: the requirement has been met
            context.Succeed(requirement);
            return;
        }

         // for others, we need to check the shared list
        RecipeAccess acl = await _context.RecipeAccess.FirstOrDefault(a => a.RecipeId == resource.Id && a.SharedWithId = userId);
        if (acl == null) return;

        if (requirement == RecipeOperations.ModifyRecipeRequirement)
        {
            // users can edit the recipe if its been shared with them with edit permission
            if (acl.AccessLevel == RecipeAccessLevel.Modify))
                context.Succeed(requirement);
        }
        else if (requirement == RecipeOperations.ReadRecipeRequirement)
        {
            // users can read a recipe if its been shared with them. Write implicitly
            // includes Read, so they don't need both
            if (acl.AccessLevel == RecipeAccessLevel.Write || acl.AccessLevel == RecipeAccessLevel.Read)
                context.Succeed(requirement);
        }
        else if (requirement == RecipeOperations.DeleteRecipeRequirement)
        {
            // can only be deleted by Owner, which is covered at the top of the method
            // noop
        }
        else if (requirement == RecipeOperations.ShareRecipeRequirement)
        {
            // can only be shared by Owner, which is covered at the top of the method
            // noop
        }
    }
}

In Startup.cs, register the handler with the dependency injection container so ASP.NET can find it:

services.Add<IAuthorizationHandler, RecipeAuthHandler>();

OK, we’re done! We’ve implemented authorization in a decoupled way that doesn’t clutter our controllers with access concerns, outside of a couple lines.

Our system is extensible: more complicated policies can have multiple requirements that each must be met, and each one can have its own handler to evaluate them. As new features come online and authorization rules get more precise, we’ll be able to add code to support those new use-cases without having to go back and modify existing things.

This way of breaking down permissions and handlers also serves as great documentation on the access rules. Rather than having rules scattered all over the place, they are all centralized. We can write unit or integration tests against the handlers that enumerate all the different rules and scenarios that we need to support. When a new engineer joins the team and wants to understand how access control is implemented, these handlers and tests provide one place to look, rather than having to trace through every controller and service.

I’ve used this pattern on a recent project with great success. We were able to easily add new access rules for new features and keep moving the product forward in a secure and efficient fashion.