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?
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
.