Implement resource-based authorization with ASP.NET Core

Implement resource-based authorization with ASP.NET Core

In my previous article, I wrote about my approach for Resource Based Authorization. This article will continue the story and show how this can be implemented in ASP.NET Core.

The traditional ASP.NET and WebAPI ASP.NET support only role-based authorization and with the ASP.NET Identity Model extension it was possible to use claim-based authorization. Both were limited in terms of resource-based authorization and a lot of manual work was required. Luckily, ASP.NET Core made a big progress with its Policy-based Authorization. This allows us to integrate our resource-based permission into the framework.

Resource Permissions

Let's start with the resource permissions. I have the following base implementation of the ResourcePermission class:

public enum PermissionAction
{
    Read,
    Write,
}

public class ResourcePermission : IEquatable<ResourcePermission>
{
    public Guid Id { get; set; }

    public string Resource { get; set; } = null!;

    public string? UserGroup { get; set; }

    public string? User { get; set; }

    public ICollection<PermissionAction> Actions { get; set; } = new Collection<PermissionAction>();
}

It is an entity which is stored in our database. So it has an Id, the Resource, the PermissionAction and the User resp. UserGroup navigation properties. In the simplest case, this can be strings, storing the AD Account Name and the AD Group. But you also can properly model that in your database depending on your needs. The PermissionAction is a simple enum containing the actions we want to protect.

We also need to be able to load the ResourcePermission entities for a specific user or his/her user group(s). We had several sources from where these resource permissions could originate:

  • Static resource permissions

  • Manually assigned and stored in the database

  • Generated from other entities such as team members, etc.

To cover that, we implemented a PermissionService, which retrieved and aggregated all permissions from several providers.

public interface IResourcePermissionsProvider
{
    Task<IReadOnlyCollection<ResourcePermission>> GetPermissionsAsync(string userName, IReadOnlyCollection<string> userGroups);
}

public class ResourcePermissionsService : IResourcePermissionsService
{
    private readonly IEnumerable<IResourcePermissionsProvider> _providers;

    public ResourcePermissionsService(IEnumerable<IResourcePermissionsProvider> providers)
    {
        _providers = providers;
    }

    public async Task<IReadOnlyCollection<ResourcePermission>> GetPermissionsAsync(string userName, IReadOnlyCollection<string> userGroups)
    {
        var result = new List<ResourcePermission>();

        foreach (var provider in _providers)
        {
            var permissions = await provider.GetPermissionsAsync(userName, userGroups);
            result.AddRange(permissions);
        }

        return result;
    }
}

Authorization Policies

To describe the authorization policies, we need to define the required permissions first. Compared to the RequiredPermission class, we only need one PermissionAction to query what the user should be able to do on the provided resource.

public class RequiredPermission
{
    public RequiredPermission(string resourceId, PermissionAction action)
    {
        ResourceId = resourceId;
        Action = action;
    }

    public string Resource { get; }

    public PermissionAction Action { get; }
}

The implementation for the AuthorizationPolicy class is based on the Enumeration Class pattern, described by Microsoft. This includes a similar behavior as the traditional enum type, but allows us to enrich them with additional attributes. The key of the policy is also declared as a constant. Later on we will need it to access the policy in the standard ASP.NET Core AuthorizationAttribute.

public interface IAuthorizePolicy
{
    RequiredPermission[] Permissions { get; }

    string Key { get; }
}

public class AuthorizePolicy : Enumeration<string>, IAuthorizePolicy
{
    public const string DepartmentRead = "DEPARTMENT_READ";
    public const string DepartmentWrite = "DEPARTMENT_WRITE";

    public static readonly AuthorizePolicy DepartmentReadPolicy = new(
        DepartmentRead,
        new[] { new RequiredPermission("/departments/{departmentId}", PermissionAction.Read) });

    public static readonly AuthorizePolicy DepartmentWritePolicy = new(
        DepartmentWrite,
        new[] { new RequiredPermission("/departments/{departmentId}", PermissionAction.Write) });

    private AuthorizePolicy(string key, RequiredPermission[] permissions)
        : base(key)
    {
        Permissions = permissions;
    }

    public RequiredPermission[] Permissions { get; }
}

Checking the permission

After we have the ResourcePermission and AutorizationPolicy ready, it's time to implement the permission check. We do that with plain C# first. The ASP.NET core integration will follow afterward.

To check if a ResourcePermission matches with a RequiredPermission we need to check two things:

  1. Has the ResourcePermission the required PermissionAction?

  2. Is the resource of the ResourcePermission matching with the explicit resource id string from the RequiredPermission?

To match the resource path, we split both, the permission resource id and the required resource id into its segments by the / character. Now it is quite easy to compare the resource IDs. If every segment is equal, or if the permission resource id segment has a wild card * the resource IDs are matching. It is important to start with the root segment and work down the path. If the permission resource id segment has a deep wild card ** the matching can be aborted because every sub-segment will be a match anyway.

The following code snippet shows how the matching can be implemented.

private static bool MatchesPermission(ResourcePermission permission, string specificResourceId, PermissionAction requiredAction)
{
    if (!permission.Actions.Contains(requiredAction))
    {
        return false;
    }

    var requiredResourceId = specificResourceId.Split('/');
    var permissionResourceId = permission.Resource.Split('/');

    for (var i = 0; i < requiredResourceId.Length; i++)
    {
        if (permissionResourceId.Length <= i)
        {
            return false;
        }

        if (permissionResourceId[i] == "**")
        {
            return true;
        }

        if (permissionResourceId[i] != "*" && permissionResourceId[i] != requiredResourceId[i])
        {
            return false;
        }
    }

    if (permissionResourceId.Length != requiredResourceId.Length)
    {
        return false;
    }

    return true;
}

But this is not the complete permission check yet. An AuthorizationPolicy can contain multiple required permissions and we still need to replace the parameters in the required permissions with its target value.

private static readonly Regex Regex = new("{(.*?)}", RegexOptions.Compiled);

public static bool IsMetByPermissions(this IAuthorizePolicy policy, IReadOnlyList<ResourcePermission> permissions, Dictionary<string, string?> paramMap)
{
    foreach (var requiredPermission in policy.Permissions)
    {
        var specificResourceId = requiredPermission.ResourceId;

        // replacing the parameters in the required permission resoruce id
        var matches = Regex.Matches(requiredPermission.ResourceId).ToList();
        foreach (var match in matches)
        {
            var parameterName = match.Groups[1].Value;
            if (!paramMap.ContainsKey(parameterName))
            {
                throw new ArgumentException($"Parameter with name {parameterName} was not found.", nameof(paramMap));
            }

            specificResourceId = specificResourceId.Replace(match.Value, paramMap[parameterName], StringComparison.InvariantCultureIgnoreCase);
        }

        // checking whether the user has a matching permission
        if (!permissions.Any(permission => MatchesPermission(permission, specificResourceId, requiredPermission.Action)))
        {
            return false;
        }
    }

    return true;
}

Now we can call the permission check on the authorization policy. We need to retrieve the permissions of the corresponding user and provide the parameters that are required for the target policy.

var user = "my-user";
var groups = new[] { "group1", "group2" };

// get the permissions from the resource service
var permissions = await permissionService.GetPermissionsAsync(user, groups);

// specify the corresponding resource ids which are required
var parameters = new Dictionary<string, string>
{
    { "departmentId", "A" },
};


var userHasAccess = AuthorizePolicy.DepartmentReadPolicy.IsMetByPermissions(permissions, parameters);

ASP.NET Core integration

Microsoft has built a completely new and flexible authorization handling into ASP.NET Core. Besides the traditional role-based authorization and claim-based authorization, it provides the so-called policy-based authorization. We can cover almost every scenario we could think about with this approach.

To marry our resource-based authorization with the policy-based authorization from ASP.NET Core, we need four things:

  1. Retrieve the user permissions

  2. An authorization handler that calls the permission check

  3. An authorization requirement that maps an ASP.NET Core policy with our AuthorizationPolicy

  4. Register our implementations in the ASP.NET Core application

Retrieve the user permissions

We could do that simply by injecting the PermissionService wherever we need the user permissions. But this way it might happen that we load the permissions several times in a single endpoint call. To prevent this, we load the permissions in the token-validated event, after the user has been authenticated.

var authenticationBuilder = services
   .AddJwtBearer(OidcConstants.AuthenticationSchemes.AuthorizationHeaderBearer, options =>
{

    options.Events = new JwtBearerEvents
    {
        OnTokenValidated = async context =>
        {
            // get the permission service
            var permissionService = context.HttpContext.RequestServices
                .GetRequiredService<IResourcePermissionsService>();

            // get user name and role from the claims principal
            var loginName = context.Principal!.GetLoginName();
            var roles = context.Principal!.GetRoles()
                .ToList();

            // load the permissions
            var permissions = await permissionService.GetPermissionsAsync(loginName, roles);

            // add the permissions as claims to the claims principal
            var appIdentity = permissions.ToClaimsIdentity();
            context.Principal!.AddIdentity(appIdentity);
        },
    };
});

The permissions are converted to claims and added to the ClaimsPrincipalof the user. This way we can retrieve them whenever we want.

public static Claim ToClaim(this ResourcePermission permission)
{
    return new Claim(ShopDbClaimTypes.Permission, JsonSerializer.Serialize(permission), "json");
}

public static IEnumerable<Claim> ToClaims(this IEnumerable<ResourcePermission> permissions)
{
    return permissions.Select(ToClaim);
}

public static ClaimsIdentity ToClaimsIdentity(this IEnumerable<ResourcePermission> permissions)
{
    return new ClaimsIdentity(permissions.ToClaims());
}

Authorization Requirement

Let's start with the authorization requirement. Our authorization requirement implementation is only a wrapper around one or more authorization policies to make them compatible with the ASP.NET Core policy-based authorization.

public class AuthorizePolicyRequirement : IAuthorizationRequirement
{
    public AuthorizePolicyRequirement(params IAuthorizePolicy[] requiredPolicies)
    {
        RequiredPolicies = requiredPolicies;
    }

    public IAuthorizePolicy[] RequiredPolicies { get; }
}

We also need to register our authorization policies in ASP.NET Core. To do that, we convert them into requirements and add them to the ASP.NET Core authorization policies. If you have a look at the code snippet below, you may notice that we register the requirements with the key of the mapped authorization policy. This allows us later to just use the standard AuthorizeAttribute and pass in the policy name. See also (docs.microsoft.com/en-us/aspnet/core/securi..).

services.AddAuthorization(options =>
{
    // this adds all our authorize policies see
    foreach (var policy in AuthorizePolicy)
    {
        options.AddPolicy(policy.Key, p => p.Requirements.Add(new AuthorizePolicyRequirement(policy)));
    }
});

Authorization Handler

The authorization handler gets called when we enforce an authorization, e.g, by using the AuthorizeAttribute. The authorization handler does basically the same as the example earlier, where I illustrated how to use the resource-based authorization.

  • Retrieving the permissions of the user

  • Providing the parameters that are required for the authorization policy

  • Call the permission check

We added the permission to the ClaimsPrincipal of the user. So it is quite easy to retrieve them in the authorization handler.

One of the benefits of this approach comes into place now. We can model our resource identifiers exactly the same or similar as our REST URI's. That means we have all the parameters that are required from the policy present in the ASP.NET Core route data. We just need to extract them and provide them in a shape we can use it. One thing has to be ensured in order to make that work, the parameter names in the ASP.NET Core route must match the parameter name of the used authorization policy.

And then we only need to call the permission check as we already have seen before.

public class AuthorizePolicyHandler : AuthorizationHandler<AuthorizePolicyRequirement>
{
    private readonly IHttpContextAccessor _httpContextAccessor;

    public AuthorizePolicyHandler(IHttpContextAccessor httpContextAccessor)
    {
        _httpContextAccessor = httpContextAccessor;
    }

    protected override async Task HandleRequirementAsync(AuthorizationHandlerContext context, AuthorizePolicyRequirement requirement)
    {
        if (!requirement.RequiredPolicies.Any())
        {
            return;
        }

        // get the routing parameters and provide them as parameters required by the authorization policy
        var routeData = _httpContextAccessor.HttpContext!.GetRouteData();
        var paramMap = routeData.Values.ToDictionary(x => x.Key, x => x.Value?.ToString());

        // get the permissions from the claims principal
        var permissions = context.User.GetPermissions();

        // check the permissions
        if (requirement.RequiredPolicies.All(policy => policy.IsMetByPermissions(permissions.ToList(), paramMap)))
        {
            context.Succeed(requirement);
        }

        await Task.CompletedTask;
    }
}

Also, the authorization handler needs to be registered in ASP.NET Core. This can be done by simply registering it as a service.

services.AddTransient<IAuthorizationHandler, AuthorizePolicyHandler>();

With that in place, we can use our resource-based authorization through the ASP.NET Core authorization.

[HttpGet("{departmentId}")]
[Authorize(AuthorizePolicy.DepartmentRead)]
public async Task<ActionResult> GetDepartmentAsync(Guid departmentId)
{
    return Ok();
}

Summary

In part one of the article series we explained our approach to a resource-based authorization model. In this article, we integrated this approach into the ASP.NET Core framework by using the ASP.NET Core policy-based authorization. Now we are able to protect the resources in our ASP.Net Core applications.

The next step will be to extend it with an Angular implementation to provide user-friendly authorization handling.