Simplifying User and Role Based Permissions in .NET

Category

Security

Published on
Authors

Introduction

For my upcoming release of Craftsman I wanted to add better support for managing permissions to the various features in my projects. After a ton of research, I came across this talk by Dominick Baier that really struck a cord. Here's where I landed on managing my permissions in my .NET projects.

The Current State of Permissions in .NET

Let's start by differentiating 3 different levels of permissions:

Permission TypeDescription
Application AccessThese are generally configured in your auth server and passed along in your token (e.g. using audience (aud) claim to determine what apis a token can be used in).
Feature AccessPermission specific checks in a particular application boundary (e.g. can a user perform some action).
Application LogicCustom business logic specific to your application (e.g. given this certain set of criteria, can a user perform some action).

In this post, I want to focus on feature access.

The Gaps with Authorization Policies in .NET

Out of the box with .NET, we can easily decorate our controllers like this [Authorize(Policy = "RecipesFullAccess")] and register it in AddAuthorization, but how do we check if the user has that claim?

One of the most common solutions to this is to load up your policies in your security token.

{
  "sub": "145hfy662",
  "name": "John Smith",
  "aud": ["api1", "api2"],
  "permission": [
    "ManageRecipe",
    "CreateNewRecipe",
    "UpdateIngredients"
  ]
}

This can work, but but there are some downside here:

  • Your JWT gets quickly overloaded, potentially to the point of being too big to even put into a cookie. Ideally, your token is only passing along user identity information only.
  • You don't have context for the permission. Let's look at a couple examples:
    • As mentioned above, we generally use the aud claim (or maybe some custom one) to determine what apis your security token can be used in. So in the example above we have "aud": ["api1", "api2"], and one of my permissions is ManageRecipe. What if I am allowed to manage recipes in api1 but not api2? You could prefix them with something like api1.ManageRecipe, but that adds coupling, domain logic, and becomes a huge multipler in the amount of claims being passed around.
    • Say I have a permission CanDrinkAlcohol but depending on where I'm at in the world it may or may not be true based on my age. I could tag it with something like US.CanDrink, UK.CanDrink, etc. but this would be far from ideal for a variety of reasons.
  • Tokens are only given at authentication time, so if you need to update permissions, you need to invalidate all the issued tokens every time you make an update. You could also make token lifetimes very short to get more up to date info more often, but that is not ideal either and still has coupling of identity and permissions.

So, What Do We Do?

Well, we can still get identity state from our identity server like we usually do. Usually, that should include some kind of role or set of roles that the user has been assigned to. These roles can then be mapped to permissions and used as a reference to a group of permissions. Maybe something like this:

{
  "sub": "145hfy662",
  "name": "John Smith",
  "aud": ["api1", "api2"],
  "role": [
    "Chef",
    "Admin"
  ]
}

It's important to note that, if you're using roles (there are other options below) these roles should be identity based and make sense across your whole domain, not just a particular boundary. For instance, something like InventoryManager would be better than something like Approver.

So we have our user and their identity roles from our auth token, but how do we know what permissions go with our roles? Well, this can be done in a variety of ways to whatever suits your needs best for your api.

  • If you have a simple API or an API that rarely has modified permissions, maybe you just want keep a static list of role to permissions mappings in a class in your project or in your appsettings.
  • More commonly, you'll probably want to persist them in a database somewhere. This could be in your boundary/application database or it could be in a separate administration boundary. Maybe you have both and use eventual consistency to keep them in sync. You could even add a caching layer on top of this as well and reference that.
  • You could even forgo roles all together and just assign permissions directly tyo users, though this would probably get pretty messy.

At the end of the day, you can store your permission mappings anywhere you want, but you still need a way to easily access them and integrate them into your permissions pipeline.

Enter HeimGuard

Putting together a pattern for this permission mapping is where I was very much inspired by how Dominick et al. set up Policy Server and came up with a similar solution. I was even able to package it up into a (very) simple library that I called HeimGuard. It really is just a couple of files if you want to check it out (or even pull them out separately into your own project), but it does seem to fill this permission mapping gap quite well.

Below is an example of how the permission abstraction works to easily manage feature access in your .NET apps.

How It Works

  1. Let's start with adding an authorization attribute. Nothing new here, this is just normal .NET:

    using Microsoft.AspNetCore.Authorization;
    using Microsoft.AspNetCore.Mvc;
    
    [ApiController]
    [Route("recipes")]
    [Authorize(Policy = "RecipesFullAccess")]
    public class RecipesController : ControllerBase
    {
        [HttpGet]
        public IActionResult Get()
        {
            return Ok();
        }
    }
  2. Next, I'm going to put my user's role in my ClaimPrincipal. This isn't required for HeimGuard to work (you don't even need roles at all actually), but is what we'll use for this example.

    {
      "sub": "145hfy662",
      "name": "John Smith",
      "aud": ["api1", "api2"],
      "role": ["Chef"]
    }
  3. Now I'm going to implement an interface from HeimGuard called IUserPolicyHandler. This handler is responsible for implementing your permissions lookup for your user. HeimGuard doesn't care how you store permissions and how you access them. The only requirement is that it should return an IEnumerable<string> that stores all of the permissions that your user has available to them.

    Again, we can implement it however we want, but for this example, let's do something like this. More details in a minute.

    using System.Security.Claims;
    using RecipeManagement.Databases;
    using RecipeManagement.Domain;
    using HeimGuard;
    using Microsoft.EntityFrameworkCore;
    
    public class UserPolicyHandler : IUserPolicyHandler
    {
        // this is my database where I maintain a table called RolePermissions
        private readonly RecipesDbContext _dbContext;
    
        // this service has a method that can get the ClaimPrincipal for me
        private readonly ICurrentUserService _currentUserService;
    
        public UserPolicyHandler(RecipesDbContext dbContext, ICurrentUserService currentUserService)
        {
            _dbContext = dbContext;
            _currentUserService = currentUserService;
        }
        
        public async Task<IEnumerable<string>> GetUserPermissions()
        {
            var user = _currentUserService.User;
            if (user == null) throw new ArgumentNullException(nameof(user));
    
            var roles = user.Claims
                .Where(c => c.Type == ClaimTypes.Role)
                .Select(r => r.Value)
                .Distinct()
                .ToArray();
            
            // super admins can do everything
            if(roles.Contains(Roles.SuperAdmin))
                return Permissions.List();
    
            var permissions = await _dbContext.RolePermissions
                .Where(rp => roles.Contains(rp.Role))
                .Select(rp => rp.Permission)
                .Distinct()
                .ToArrayAsync();
    
            return await Task.FromResult(permissions);
        }
    }
  4. Then, we just need to register HeimGuard and our UserPolicyService. Again, more details on what's happening here below.

    public void ConfigureServices(IServiceCollection services)
    {
        //... other services
        services.AddHeimGuard<UserPolicyHandler>()
            .AutomaticallyCheckPermissions()
            .MapAuthorizationPolicies();
    }

That's it! Now any controller with an Authorize attribute will automatically be protected and only allowed access if a user has the given permission mapped to their role (again, doesn't have to be role based, but that's what I'm doing in this example.

Breaking It Down: Permissions

First, permissions. You can store these however you want, but in this example, I'm storing my permissions in a static class. This seems pretty reasonable to me as permission assignments to our features are only added or updated as code changes anyway.

using System.Reflection;

public static class Permissions
{
    public const string CanDeleteRecipe = "CanDeleteRecipe";
    public const string CanUpdateRecipe = "CanUpdateRecipe";
    public const string CanAddRecipe = "CanAddRecipe";
    public const string CanReadRecipes = "CanReadRecipes";
    public const string CanDeleteRolePermission = "CanDeleteRolePermission";
    public const string CanUpdateRolePermission = "CanUpdateRolePermission";
    public const string CanAddRolePermission = "CanAddRolePermission";
    public const string CanReadRolePermissions = "CanReadRolePermissions";

    public static List<string>List()
    {
        return typeof(Permissions)
            .GetFields(BindingFlags.Public| BindingFlags.Static| BindingFlags.FlattenHierarchy)
            .Where(fi => fi.IsLiteral && !fi.IsInitOnly && fi.FieldType == typeof(string))
            .Select(x => (string)x.GetRawConstantValue())
            .ToList();
    }
}

Breaking It Down: Roles

Next, in my case, I'd like to group permissions together using a role, so I'm going to establish what roles I'm able to have. I also chose a static class here, but in many cases this you might want to abstract this out elsewhere.

using System.Reflection;

public static class Roles
{
    public const string SuperAdmin = "SuperAdmin";
    public const string User = "User";
    
    public static List<string> List()
    {
        return typeof(Roles)
            .GetFields(BindingFlags.Public | BindingFlags.Static | BindingFlags.FlattenHierarchy)
            .Where(fi => fi.IsLiteral && !fi.IsInitOnly && fi.FieldType == typeof(string))
            .Select(x => (string)x.GetRawConstantValue())
            .ToList();
    }
}

Breaking It Down: Getting User Permissions

Let's look at the GetUserPermissions method that we implemented again in more detail.

First, let's look at the constructor. I have two services here; one to get the current user (i.e. from the ClaimsPrincipal) and another to get my db where I'm storing how my permissions map to my roles.

private readonly RecipesDbContext _dbContext;
private readonly ICurrentUserService _currentUserService;

public UserPolicyHandler(RecipesDbContext dbContext, ICurrentUserService currentUserService)
{
    _dbContext = dbContext;
    _currentUserService = currentUserService;
}

I start the method by getting the user that we want to work with and making sure they exist:

var user = _httpContextAccessor.HttpContext?.User;
if (user == null) throw new ArgumentNullException(nameof(user));

Next, I get the roles for that user. In this case, the role is in the user's ClaimPrincipal, but this could be stored in a UserRole table in an administration database or wherever else makes sense for your project.

var roles = user.Claims
    .Where(c => c.Type == ClaimTypes.Role)
    .Select(r => r.Value)
    .Distinct()
    .ToArray();

Then, if the user is a SuperAdmin I want to give them all permissions, otherwise, I get the permissions for all the roles I found out of the database. Regardless, I finish by returning the permissions for HeimGuard to use.


if(roles.Contains(Roles.SuperAdmin))
    return Permissions.List();

var permissions = await _dbContext.RolePermissions
    .Where(rp => roles.Contains(rp.Role))
    .Select(rp => rp.Permission)
    .Distinct()
    .ToArrayAsync();

return await Task.FromResult(permissions);

Breaking It Down: Registering HeimGuard Services

So we set up HeimGuard like so:

public void ConfigureServices(IServiceCollection services)
{
    //... other services
    services.AddHeimGuard<UserPolicyHandler>()
      .AutomaticallyCheckPermissions()
      .MapAuthorizationPolicies();
}
  1. AddHeimGuard<UserPolicyHandler>() is there to register the UserPolicyHandler implementation that we made.
  2. AutomaticallyCheckPermissions is optional, but a big timesaver. When this is added, you HeimGuard will automatically check the user's permissions against any controller that has an Authorize attribute. Without this, you can manually check a user's permissions like so:
    using HeimGuard;
    using Microsoft.AspNetCore.Authorization;
    using Microsoft.AspNetCore.Mvc;
    
    [ApiController]
    [Route("recipes")]
    [Authorize]
    public class RecipesController : ControllerBase
    {
        private readonly IHeimGuardClient _heimGuard;
    
        public RecipesController(IHeimGuardClient heimGuard)
        {
            _heimGuard = heimGuard;
        }
        
        [HttpGet]
        public IActionResult Get()
        {
            return _heimGuard.HasPermissionAsync("RecipesFullAccess") 
              ? Ok()
              : Forbidden();
        }
    }

⭐️ Since writing this post, I've come to find that I actually prefer just using an Authorize attribute like this and using HeimGuard to directly check permissions like this my injecting it into my MediatR handlers that I call from my controllers. I like this because it consolidates the business permissions to one spot (the vertically sliced handler) and makes it so I don't have to worry about a policy config. Regardless, whatever floats your boat!

  1. MapAuthorizationPolicies will automatically map authorization attributes to ASP.NET Core authorization policies that haven't already been mapped. That means you don't have to do something like this for all your policies:

    services.AddAuthorization(options =>
    {
        options.AddPolicy("RecipesFullAccess",
            policy => policy.RequireClaim("permission", "RecipesFullAccess"));
    });

Conclusion

I'm going to dog food the process some more to see what tweaks I want to make, but so far I've really enjoyed using this process for managing my permissions! Whether you decide to give HeimGuard a try or not, I hope this post was helpful.

If you liked this write-up and the library, I'd really appreciate a star on Github! ⭐️

🐦 Regardless, as always, I'm available on Twitter or Discord if you have any questions or comments!