Recently while working on a .Net Core API project I had to add some authorization features to further protect endpoints based on user-level security. This security scheme was conceptually pretty simple, but a little complicated to implement. In the end I had to implement some custom authorization middleware myself, so I would have just the right level of granularity and control.
The Problem
The project I was working on has some fairly granular and customizable security controls originally implemented in the legacy codebase. This API needed to reimplement the same security controls for parity with the legacy codebase, so that from a user’s standpoint nothing changes at all.
To do this, I wanted to put a top-level auth check on a resource, or endpoint, so the API could reject unauthorized requests right off the bat without even thinking of going any further. A simple want, but the details were a little hairier.
In this system, each user is assigned a security group. These security groups determine the accessibility of roughly 800 controls, actions, data points, and more. This translates to roughly 800 VMAD-style permission combinations. Further, security groups were customizable per-installation. So an ADMIN at one site might be a SYSADMIN at another—or, even if the names are the same, the permissions for the ADMIN groups at each site might be just slightly different.
This immediately ruled out any baked-in authorization feature. I couldn’t use role-based and policy-based authorization, because these rely on roles or policies to be named in a standard fashion, and for that I had zero guarantee.
Claims-based authorization was likely out. Stuffing the required data into claims data itself didn’t appeal to me. Neither did having to write out the requirements for every possible VMAD permission needed.
Fortunately, there was one constant across all this: because of the way the permission data was stored, the index of the permission would never change. So the ability to “view(44)” meant the same no matter what configurations you made.
So I decided my end goal would be simple: slap a custom authorize attribute on the endpoints that need one, and then move on. It would look like this1:
[CanView(44)]
The Solution
In the end I needed to implement my own IAuthorizationProvider, along with custom attributes and an in-memory cache storing configured security information.
I’ll show some examples for a theoretical “CanView” requirement, assuming we’re implementing a classic VMAD permission scheme with the structure I outlined above.2
Defining an Authorize Attribute
We’ll need to start with the authorization attribute first. This includes the IAuthorizationRequirement as well as a new attribute implementation.
public class CanViewRequirement : IAuthorizationRequirement { public int Index { get; } public CanViewRequirement(int index) { Index = index; } } [AttributeUsage(AttributeTargets.Class | AttributeTargets.Method, AllowMultiple = true)] internal class CanViewAttribute : AuthorizeAttribute { const string POLICY_PREFIX = "CanView"; public int Index { get { if (int.TryParse(Policy.Substring(POLICY_PREFIX.Length), out var index)) { return index; } return default; } set { Policy = $"{POLICY_PREFIX}{value}"; } } public CanViewAttribute(int index) { Index = index; } } internal class CanViewAuthorizationHandler : AuthorizationHandler<CanViewRequirement> { private readonly IServiceProvider Services; public CanViewAuthorizationHandler(IServiceProvider services) { Services = services; } protected override async Task HandleRequirementAsync(AuthorizationHandlerContext context, CanViewRequirement requirement) { if (!context.User.Identity.IsAuthenticated) { return; } SecurityGroup security; using (var scope = Services.CreateScope()) { var securityService = scope.ServiceProvider.GetRequiredService<MySecurityGroupService>(); security = await securityService.GetSecurityGroupInfoAsync(context.User).ConfigureAwait(false); } if (security.CanView(requirement.Index)) { context.Succeed(requirement); } else { // reject and log the request accordingly } return; } }
Okay, there’s a bit going on here. First, we create a class that implements the IAuthorizationRequirement
interface. The CanViewRequirement
simply holds the index of the permission in our data, and implements that interface so we can use it for our AuthorizationHandler
down below in HandleRequirementsAsync
.
In HandleRequirementsAsync
, we check an in-memory cache holding the security groups to see if the calling user’s security group does indeed have the requested permission. If so, context.Succeed(requirement)
allows the request through—otherwise, we log the failed request and it’s rejected by default.
The CanViewAttribute class simply lets me set all this up as defined above, by using an attribute on a method or endpoint like this: [CanView({index})]
A note about attributes
In C# attributes can be used for metadata and code extensions. They go above your method declaration and provide helpful documentation, often extending behavior in a standardized way. It’s helpful to think of attributes as wrappers around methods, especially in this case. If you look at a method like this:
[CanView(44)] public async Task<IActionResult> GetFooAsync() { // do things return Ok(); }
And then unbox it, it might look something like this (pseudocode):
public async Task<IActionResult> CanGetFooAsync(int index, GetFooAsync getMethod) { if (authService.IsAllowed(CanView, index)) { return await getMethod; } return Unauthorized(); } public async Task<IActionResult> GetFooAsync() { // do things return Ok(); }
The Authorization Provider
All the above is well and good, but none of it does anything on its own. We have to set up a policy provider for the right code to get called when the attribute is reached. For this, we need to implement IAuthorizationPolicyProvider
:
internal class VmadPolicyProvider : IAuthorizationPolicyProvider { const string POLICY_PREFIX_VIEW = "CanView"; public DefaultAuthorizationPolicyProvider FallbackPolicyProvider { get; } public VmadPolicyProvider(IOptions<AuthorizationOptions> options) { FallbackPolicyProvider = new DefaultAuthorizationPolicyProvider(options); } public Task<AuthorizationPolicy> GetDefaultPolicyAsync() => FallbackPolicyProvider.GetDefaultPolicyAsync(); public Task<AuthorizationPolicy> GetFallbackPolicyAsync() => FallbackPolicyProvider.GetFallbackPolicyAsync(); public Task<AuthorizationPolicy> GetPolicyAsync(string policyName) { if (policyName.StartsWith(POLICY_PREFIX_VIEW, StringComparison.OrdinalIgnoreCase) && int.TryParse(policyName.Substring(POLICY_PREFIX_VIEW.Length), out var index)) { var policy = new AuthorizationPolicyBuilder(JwtBearerDefaults.AuthenticationScheme); policy.AddRequirements(new CanViewRequirement(index)); return Task.FromResult(policy.Build()); } return FallbackPolicyProvider.GetPolicyAsync(policyName); } }
All this is a semi-fancy way to generate the policies we need on an as-needed basis. Instead of hardcoding every CanViewRequirement
possibility from 1 to 800, these are built for us by the attributes and requirements as we go. The interface also specifies that we add a default and fallback policy provider, from which I simply grabbed the defaults from the default, created in the class’s constructor.3
This is the ultimate goal of the code: do the work once, and use it everywhere. If we wanted to extend or refine details behind our CanView
security, that is only done in one place.
Plugging it All In
Since this is .Net Core 3, we’re relying on dependency injection to keep things afloat. So we need to add services for everything we’re using here:
services.AddSingleton<IAuthorizationHandler, CanViewAuthorizationHandler>(); services.AddSingleton<IAuthorizationPolicyProvider, VmadPolicyProvider>(); services.AddAuthorization();
This tells the software that we’re using some custom authorization handlers and policy providers and points it to their definitions.
After this, using custom authorization attributes for our endpoints is a breeze. With just one line, we can narrow access to a single flag in the database out of thousands. Extending this would likewise be a breeze. Definitely easier than defining every single one manually, or crossing our fingers and hoping a security group name doesn’t change!
Wrapping Up
This may or may not be the best way to solve this particular problem. But we analyzed the trade-offs and made a calculated decision. If you were to go through and comment the code above with the intentions behind it all, I personally believe it would be easier for a new developer to hop on and get going than the alternatives. While complexity has to go somewhere, it’s better for these types of abstractions to have a gentle curve. Let someone care about the details only if they need to.
- Without getting into how objectively good this is (yes, I know magic numbers are bad), this was the best way to keep functionality inline with the legacy system. It was more important to lower the mental overhead of translating these requirements than it was to restructure the permission system to something more human readable. Magic numbers, it is.
- This code is for demonstration purposes only! It is not meant to demonstrate performant or real code, though I do welcome comments.
- For our purposes here, I didn’t feel it necessary to do much more with these.