Skip to content

Using Authorize Attributes to Implement Menu Filtering

Brent McSharry edited this page Jul 13, 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. It should also improve performance if we build a dictionary of which Authorization Filters apply to which actions only once.

To access this dicctionary, 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 action found and has no Authorization, or action not found

	}

	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 (string.IsNullOrEmpty(menuNode.Value.ViewRoles)) {
			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 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.
  • 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 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

Clone this wiki locally