Search code examples
c#asp.net-coreauthorizationauthorize-attribute

Bind AuthorizationPolicy to Controller/Action without using AuthorizeAttribute


I want to add authorization to my .NET Core API. Lets say I have a PersonController with the following actions:

  • GetPerson (retrieves a Person based on id)

  • PostPerson (adds a new Person)

  • DeletePerson (Deletes a Person)

     [Route("[controller]")]
     [ApiController]
     public class PersonController : ControllerBase
     {
         [HttpGet("{id}")]
         public async Task<ActionResult<PersonModel>> GetPerson(int id)
         {
             //
         }
    
         [HttpPost]
         public async Task<ActionResult<PersonModel>> PostPerson(PersonModel model)
         {
            //
         }
    
         [HttpDelete("{id}")]
         public async Task<ActionResult> DeletePerson(int id)
         {
            //
         }
     }
    

For this example I will use two roles. 'SuperAdmin' which should be able to do all actions and 'PersonReader' which should only be able to do the GetPerson call. Trying to do PostPerson or DeletePerson as a PersonReader should fail.

I created the following authorization policies:

                options.AddPolicy("SuperAdmin", policy =>
                    policy.RequireAuthenticatedUser()
                    .RequireRole("SuperAdmin")
                );
                options.AddPolicy("PersonReader", policy =>
                    policy.RequireAuthenticatedUser()
                    .RequireRole("PersonReader")
                );

But now I want to bind these policies to the controller actions, to say what policies are required to be able to do the controller actions. I know this can be done with an authorizationAttribute like this: [Authorize(Policy="X"] But I want to be able to do this without using AuthorizationAttributes.

Why can I not use [Authorize] attributes?
I won't go in too much detail, but the source code of the Controller is generated. This means all manual changes will be overwritten once it is generated again. Because of this the authorization should not be in the controller.

In the startup.cs I map the controllers to endpoints like this:

app.UseEndpoints(endpoints =>
    {
        endpoints.MapControllers();
    });

It is possible to bind one policy for all controllers like this:

endpoints.MapControllers().RequireAuthorization("SuperAdmin");

But this means I will require the 'SuperAdmin' policy for all controller actions. With this I cannot define required policies for a specific action. I was hoping to do something like this:

// pseudo-code  
// endpoints.MapControllerAction("GetPerson").RequireAuthorization("SuperAdmin", "PersonReader");

Unfortunately I cannot find any way of doing this. Is there a way to bind policies to controller actions without using the [Authorize] attribute?


Solution

  • You can apply AuthorizeAttribute or any other kinds of attribute programmatically via the application model convention IApplicationModelConvention. There you can have access to the root ApplicationModel which contains all the loaded controllers and you can add the AuthorizeAttribute there. Each controller is represented by a class called ControllerModel. It implements IFilterModel which exposes a list of IFilterMetadata. The model also implements ICommonModel as well, which exposes a list of attributes, however this list is readonly. So to modify that list, you may have to create a new model to overwrite the old one, which is fairly complicated. Each action is represented by ActionModel which also implements IFilterModel. So in this case we don't try applying the AuthorizeAttribute by adding it to the list of attributes, instead we convert it into an AuthorizeFilter which is also an IFilterMetadata so that it can be added to the list of filters exposed by IFilterModel.

    Here is the detailed code:

    public class AuthorizeAttributeInjectingConvention : IApplicationModelConvention
    {
        readonly string _controller;
        readonly string _action;
        readonly AuthorizeFilter[] _authorizeFilters;
        public AuthorizeAttributeInjectingConvention(string controllerName, params AuthorizeAttribute[] authorizeAttributes) 
            : this(controllerName, null, authorizeAttributes)
        {                  
        }
        public AuthorizeAttributeInjectingConvention(string controllerName, string actionName, params AuthorizeAttribute[] authorizeAttributes)
        {
            _controller = controllerName;
            _action = actionName;
            _authorizeFilters = authorizeAttributes.Select(e => new AuthorizeFilter(new[] { e })).ToArray();
        }
    
        public void Apply(ApplicationModel application)
        {
            var filterModels = application.Controllers
                                         .Where(e => string.Equals(e.ControllerName, _controller, StringComparison.OrdinalIgnoreCase))
                                         .ToList<IFilterModel>();
            if(filterModels.Count > 0 && !string.IsNullOrWhiteSpace(_action))
            {
                filterModels = filterModels.Cast<ControllerModel>()
                                           .SelectMany(e => e.Actions.Where(o => string.Equals(o.ActionName, _action, StringComparison.OrdinalIgnoreCase)))
                                           .ToList<IFilterModel>();
            }
            foreach(var filterModel in filterModels)
            {                
                foreach(var af in _authorizeFilters)
                {
                    filterModel.Filters.Add(af);
                }
            }
        }
    }
    

    To register the IApplicationModelConvention, you can add an instance to the list of conventions exposed via the MvcOptions. For convenience, I create a set of extension methods like this:

    public static class AuthorizeAttributeInjectionMvcOptionsExtensions
    {
        public static MvcOptions ApplyAuthorizeAttributes(this MvcOptions options, string controllerName, params AuthorizeAttribute[] authorizeAttributes)
        {
            return options.ApplyAuthorizeAttributes(controllerName, null, authorizeAttributes);
        }
        public static MvcOptions ApplyAuthorizeAttributes(this MvcOptions options, string controllerName, string actionName, params AuthorizeAttribute[] authorizeAttributes)
        {
            options.Conventions.Add(new AuthorizeAttributeInjectingConvention(controllerName, actionName, authorizeAttributes));
            return options;
        }
        public static MvcOptions ApplyAuthorizationPolicy(this MvcOptions options, string controllerName, string actionName, params string[] policies)
        {
            return options.ApplyAuthorizeAttributes(controllerName, actionName, policies.Select(e => new AuthorizeAttribute(e)).ToArray());
        }        
    }
    

    Now in the Startup.ConfigureServices, you can apply the AuthorizeAttribute of your choice to a specific controller or action (via its name), like this:

    services.AddMvc(o => {
        //...
    
        //by AuthorizeAttribute
        var withSuperAdminAttr = new AuthorizeAttribute("SuperAdmin");
        o.ApplyAuthorizeAttributes("your_controller", "your_action", withSuperAdminAttr);
    
        //by policy
        o.ApplyAuthorizationPolicy("your_controller", "your_action", "SuperAdmin");
        //...
    });
    

    Note that the code above is not perfect, it introduces how basically you can implement it. The logic you can improve more is how you filter for the targeted controllers & actions. As in my example, it just filters based on controller name & action name. I think that should work in almost cases as long as you have unique controller names & unique action names. Otherwise you may have to add more custom logic to target the right controllers & actions before actually applying the AuthorizeAttribute.