Skip to content

Using Authorize Attributes to Implement Menu Filtering

Brent McSharry edited this page Jul 19, 2017 · 9 revisions

Using Authorize Attributes to Implement Menu Filtering

While security authorization is a separate concern to displaying menus and sitemaps, the majority of the time the developer will wish to display only the pages a particular user is authorized to view.

The Navigation tree nodes [NavNode] has a ViewRoles property(JSON)/attribute(XML) containing a comma separated list of the roles allowed to see the node (and its child nodes).

Commonly, these roles will also be in the roles property of an Authorize Attribute adorning the relevant Controller or Action. Similarly, if your project is using policy based Authorization, you may only wish to show users a page if they have an 'EmployeeId' claim, or if they have a 'Date of Birth' claim such that they are over 21 years of age. More Detail on policy based authorization is in this Microsoft documentation.

The example project for this page is on GitHub. It is a copy of the NavigationDemo.Web project from within this repository, but with the EmployeeId policy applied to the Controller 'Area51' and over21 applied to the Action 'Aliens'. All the ViewRoles attributes have been removed from Navigation.xml.

To implement this, we need to create our own implementation of INavigationNodePermissionResolver. Performance wise it will be wise to build a dictionary of which Authorization Filters apply to which actions only once per application.

To access this dictionary, we create an interface:

public interface IActionFilterMap
{
    IEnumerable<IAsyncAuthorizationFilter> GetFilters(string area, string controller, string action);
}

next we obtain the list of all controllers and actions in the project. This is done via the ApplicationModelProviderContext which can be obtained by creating a class which implements the IApplicationModelProvider interface.

public class CustomApplicationModelProvider : IApplicationModelProvider, IActionFilterMap
{
    //It will be executed after AuthorizationApplicationModelProvider, which has order -990
    public int Order => 0;

    private ReadOnlyDictionary<ActionKey, IEnumerable<IAsyncAuthorizationFilter>> _authDictionary;

    public IEnumerable<IAsyncAuthorizationFilter> GetFilters(string area, string controller, string action)
    {
        var key = new ActionKey(area, controller, action);
        if (_authDictionary.TryGetValue(key, out IEnumerable<IAsyncAuthorizationFilter> returnVar))
        {
            return returnVar;
        }
        return null;//returning null rather than Enumerable.Empty so consuming method can detect if the is action found and has no Authorization, or the action is not found at all
    }

    public void OnProvidersExecuted(ApplicationModelProviderContext context)
    {
        var returnVar = new Dictionary<ActionKey, IEnumerable<IAsyncAuthorizationFilter>>();
        foreach (var controllerModel in context.Result.Controllers)
        {
            var controllerFilters = controllerModel.Filters.OfType<IAsyncAuthorizationFilter>().ToList();
            string area = controllerModel.Attributes.OfType<AreaAttribute>().FirstOrDefault()?.RouteValue;
            foreach (ActionModel action in controllerModel.Actions)//todo restrain to get
            {
                var method = action.Attributes.OfType<HttpMethodAttribute>().FirstOrDefault();
                if (method == null || method.HttpMethods.Contains("GET"))
                {
                    var key = new ActionKey(area, controllerModel.ControllerName, action.ActionName);
                    if (action.Filters.OfType<AllowAnonymousFilter>().Any())
                    {
                        returnVar.Add(key, Enumerable.Empty<IAsyncAuthorizationFilter>());
                    }
                    else
                    {
                        var filters = controllerFilters.Concat(action.Filters.OfType<IAsyncAuthorizationFilter>()).ToArray();
                        returnVar.Add(key, filters);
                    }
                }
            }
            _authDictionary = new ReadOnlyDictionary<ActionKey, IEnumerable<IAsyncAuthorizationFilter>>(returnVar);
        }
    }

    public void OnProvidersExecuting(ApplicationModelProviderContext context)
    {
        //empty
    }

    private class ActionKey : Tuple<string, string, string>
    {
        public ActionKey(string area, string controller, string action) : base(area ?? string.Empty, controller, action)
        {
        }
    }
}

The OnProvidersExecuting is simply building the dictionary by iterating through all the controllers and actions in the project, filtering out to only contain the actions which service GET requests, and then finding the associated Authorization Filters. The Actionkey class calculates the combined hash of the 3 strings (areaName, controllerName, actionName).

The INavigationNodePermissionResolver implementation looks like this

public class NavigationNodeAutoPermissionResolver : INavigationNodePermissionResolver
{

    public NavigationNodeAutoPermissionResolver(IHttpContextAccessor httpContextAccessor, 
        IActionContextAccessor actionContextAccessor, 
        IActionFilterMap filterMap,
        ILogger<NavigationNodeAutoPermissionResolver> logger)
    {
        _httpContext = httpContextAccessor.HttpContext;
        _actionContext = new ActionContext(actionContextAccessor.ActionContext);
        _filterMap = filterMap;
        _logger = logger;
    }
    
    private HttpContext _httpContext;
    private ActionContext _actionContext;
    private IActionFilterMap _filterMap;
    private ILogger _logger;

    public const string AllUsers = "*"; //note  - this is "AllUsers;" in the default implementation

    public virtual bool ShouldAllowView(TreeNode<NavigationNode> menuNode)
    {
        if (menuNode.Value.ViewRoles.Length == 0) {
            if (menuNode.Value.NamedRoute.Length > 0)
            {
                //this could be implemented, but as I never use named routes, feel free to implement yourself
                throw new NotImplementedException("The current implementation does not know which named routes map to which actions");
            }
            //if no namedroute attribute and no action attribute a url must have been provided
            //we could also use something like if (menuNode.Value.Url[0] != '~')
            if (menuNode.Value.Action.Length == 0) {
                return true; 
            } 
            var authFilters = _filterMap.GetFilters(menuNode.Value.Area, menuNode.Value.Controller, menuNode.Value.Action);
            if (authFilters == null)
            {
                _logger.LogWarning($"could not find area:'{menuNode.Value.Area}'/controller:'{menuNode.Value.Controller}'/action:'{menuNode.Value.Action}'");
            }
            else if (authFilters.Any())
            {
                return Task.Run(()=>IsValid(authFilters, _actionContext)).GetAwaiter().GetResult();
            }
            else
            {
                return true;
            }
        }
        if (AllUsers.Equals(menuNode.Value.ViewRoles, StringComparison.OrdinalIgnoreCase)) { return true; }

        return _httpContext.User.IsInRoles(menuNode.Value.ViewRoles);
    }

    private static async Task<bool> IsValid(IEnumerable<IAsyncAuthorizationFilter> filters, ActionContext actionContext)
    {
        var context = new AuthorizationFilterContext(actionContext, filters.Cast<IFilterMetadata>().ToList());
        foreach (var f in filters)
        {
            await f.OnAuthorizationAsync(context);
            if (context.Result != null)
            {
                return false;
            }
        }
        return true;
    }
}

The ShouldAllowView method uses the dictionary created in the last block of code to find the list of authorization filters for the given action which the node (or leaf) refers to. It then creates a new AuthorizationFilterContext and iterates through each filter, checking after each call to the OnAuthorizationAsync method if the AuthorizationFilterContext.Result has a non null value (which will occur if the call to OnAuthorizationAsync did not validate).

A few caveats:

  • This will become a performance issue if the authorization handlers are complex or making calls to a database or web service each time they are called. Solutions include using the ViewRoles attributes of the NavNode only for such actions (which will bypass calling the AuthorizationHandlers for that node). Alternatively, use dependency injection to inject a per-request or per-application instance of a class which caches such lookups (Entity Framework has this built in and the filter could use the Find method or search the Local property of the DbSet prior to sending off the database request).
  • If the AuthorizationHandler examines the AuthorizationFilterContext for RouteData it could be problematic as the RouteData will likely refer to a call to an action which is different to the one which is adorned by that particular Authorization Policy. If looking for a 'DepartmentId' key within the RouteData this will probably not be an issue (and will probably be useful in determining if a page can be displayed). However, if looking for an 'Id' key which might relate to a different table (and said Id is not a universally unique identifier, for example an int) this may be problematic. One option here would be to assign a new (empty) RouteData instance to the _actionContext.RouteData property.

Finally we need to hook up the dependency injection. We have the following requirements:

  • It must be the same instance of the CustomApplicationModelProvider which is injected for the IApplicationModelProvider, IActionFilterMap - therefore it will build the dictionary when OnProvidersExecuted is executed, and have that dictionary available for our custom INavigationNodePermissionResolver.
  • The CustomApplicationModelProvider must be instantiated once for each request

Therefore the ConfigureServices method of the Startup Class will look like this

public void ConfigureServices(IServiceCollection services)
{
    //create a single instance and have the same instance injected into both interfaces it implements
    var customAppModelProvider = new CustomApplicationModelProvider();
    services.AddSingleton<IApplicationModelProvider>(customAppModelProvider);
    services.AddSingleton<IActionFilterMap>(customAppModelProvider);
    //our autopermission resolver must be added before call to AddCloudscribeNavigation
    services.AddScoped<INavigationNodePermissionResolver, NavigationNodeAutoPermissionResolver>();
    services.AddCloudscribeNavigation(Configuration.GetSection("NavigationOptions"));
    ... remainder of configuration ...

Any thoughts or improvements to the code above or the Wiki - please feel free to contribute

Improvements

If you look carefully at the github repo from which the above code was taken, you will see the link refers to a specific commit, and the project has progressed. The newer code ensures that each validator is executed a minimal number of times per request cycle. This adds in another level of complexity to the code, none of which has anything to do with the Cloudscribe.Web.Navigation API. As such, the explanation of the simpler but less efficient implementation remains on this Wiki. If you are struggling to follow the newer implementation, or think it needs improvement please let me know.