ASP.NET MVC: Dynamic View Locations Based On Roles

A co-worker was working on a new MVC project using role based authorization when he ran into a concern where a view was nearly identical between two different roles. The only difference between the views was one role had the ability to “Delete” and included a delete button, the other role didn’t have the delete capability.

One solution to the problem would have been to add the following code into the view:

if (User.IsInRole("Administrator")) {
  <button>delete</button>
}

Using the above wouldn’t be the end of the word, since the “Delete” action on the controller would be limited to only the “Administrator” role. However, in the future as the abilities of the two roles diverge, we’re likely to continue sprinkling more authorization code into our view creating a bundle of mud. I recommended the developer to create a dedicated view for each role, using partials where able to minimize code duplication.

By doing so, we can have the controller determine the correct view to render.

public class HomeController: Controller
{
  [AcceptVerbs(HttpVerbs.Get)]
  public virtual ActionResult Index()
  {
    if (User.IsInRole("Administrator")) {
        return (View("AdministratorIndex"));
    }
    return (View());
  }
}

That will work and is better than having the the authorization logic inside of the view. We could even make it better by moving the custom views into a subfolder based on the role to help keep our files organized.

- Views
  - Home
    - Administrator
      index.cshtml
    index.cshtml

That’ll help keep our role custom views organized.

But something still “smells” here. Our controller / action is now dealing with authorization concerns. Can we somehow move that authorization logic outside of our controller? Essentially our authorization logic is merely selecting what view to render. Let’s see if we can override the view selection logic…

ViewEngine (RazorViewEngine) To The Rescue

Looking at the ASP.NET MVC stack, we can see that there are ViewEngine classes, (RazorViewEngine, WebFormViewEngine) which will apply a set of rules to locating the appropriate view.

To implement this, we’ll create an RoleBasedRazorViewEngine class with the following contents:

/// <summary>
/// A razor based view engine that locates views based on their role.
/// </summary>
public class RoleBasedRazorViewEngine: RazorViewEngine
{
  private readonly IEnumerable<string> _roles;

  /// <summary>
  /// Creates an instance of the RoleBasedRazorViewEngine class.
  /// </summary>
  /// <param name="roles">The list of roles in priority order supported by the application.</param>
  public RoleBasedRazorViewEngine(IEnumerable<string> roles): this(roles, null)
  {
  }

  /// <summary>
  /// Creates an instance of the RoleBasedRazorViewEngine class.
  /// </summary>
  /// <param name="roles">The list of roles in priority order supported by the application.</param>
  /// <param name="viewPageActivator">The ViewPageActivator to use for page dependency resolution.</param>
  public RoleBasedRazorViewEngine(IEnumerable<string> roles, IViewPageActivator viewPageActivator): base(viewPageActivator)
  {
    _roles = roles ?? new String[0];

    AreaViewLocationFormats = new [] {
      "~/Areas/{2}/Views/{1}/{{0}}/{0}.cshtml",
      "~/Areas/{2}/Views/{1}/{{0}}/{0}.vbhtml",
      "~/Areas/{2}/Views/Shared/{{0}}/{0}.cshtml",
      "~/Areas/{2}/Views/Shared/{{0}}/{0}.vbhtml"
    }.Concat(base.AreaViewLocationFormats).ToArray();
    AreaMasterLocationFormats = new[] {
      "~/Areas/{2}/Views/{1}/{{0}}/{0}.cshtml",
      "~/Areas/{2}/Views/{1}/{{0}}/{0}.vbhtml",
      "~/Areas/{2}/Views/Shared/{{0}}/{0}.cshtml",
      "~/Areas/{2}/Views/Shared/{{0}}/{0}.vbhtml"
    }.Concat(base.AreaMasterLocationFormats).ToArray();
    AreaPartialViewLocationFormats = new[] {
      "~/Areas/{2}/Views/{1}/{{0}}/{0}.cshtml",
      "~/Areas/{2}/Views/{1}/{{0}}/{0}.vbhtml",
      "~/Areas/{2}/Views/Shared/{{0}}/{0}.cshtml",
      "~/Areas/{2}/Views/Shared/{{0}}/{0}.vbhtml"
    }.Concat(base.AreaPartialViewLocationFormats).ToArray();

    ViewLocationFormats = new[] {
      "~/Views/{1}/{{0}}/{0}.cshtml",
      "~/Views/{1}/{{0}}/{0}.vbhtml",
      "~/Views/Shared/{{0}}/{0}.cshtml",
      "~/Views/Shared/{{0}}/{0}.vbhtml"
    }.Concat(base.ViewLocationFormats).ToArray();
    MasterLocationFormats = new[] {
      "~/Views/{1}/{{0}}/{0}.cshtml",
      "~/Views/{1}/{{0}}/{0}.vbhtml",
      "~/Views/Shared/{{0}}/{0}.cshtml",
      "~/Views/Shared/{{0}}/{0}.vbhtml"
    }.Concat(base.MasterLocationFormats).ToArray();
    PartialViewLocationFormats = new[] {
      "~/Views/{1}/{{0}}/{0}.cshtml",
      "~/Views/{1}/{{0}}/{0}.vbhtml",
      "~/Views/Shared/{{0}}/{0}.cshtml",
      "~/Views/Shared/{{0}}/{0}.vbhtml"
    }.Concat(base.PartialViewLocationFormats).ToArray();
  }

  /// <summary>
  /// Creates a partial view using the specified controller context and partial path.
  /// </summary>
  /// <returns>
  /// The partial view.
  /// </returns>
  /// <param name="controllerContext">The controller context.</param><param name="partialPath">The path to the partial view.</param>
  protected override IView CreatePartialView(ControllerContext controllerContext, string partialPath)
  {
    return base.CreatePartialView(controllerContext, GetRoleBasedPath(controllerContext, partialPath));
  }

  /// <summary>
  /// Creates a view by using the specified controller context and the paths of the view and master view.
  /// </summary>
  /// <returns>
  /// The view.
  /// </returns>
  /// <param name="controllerContext">The controller context.</param>
  /// <param name="viewPath">The path to the view.</param>
  /// <param name="masterPath">The path to the master view.</param>
  protected override IView CreateView(ControllerContext controllerContext, string viewPath, string masterPath)
  {
    return base.CreateView(controllerContext, 
                           GetRoleBasedPath(controllerContext, viewPath), 
                           GetRoleBasedPath(controllerContext, masterPath));
  }

  /// <summary>
  /// Resolves the path based on the role information.
  /// </summary>
  /// <param name="controllerContext">The controller context.</param>
  /// <param name="viewPath">The path to the view.</param>
  /// <returns>The resolved view path.</returns>
  private string GetRoleBasedPath(ControllerContext controllerContext, string viewPath)
  {
    if ((! String.IsNullOrEmpty(viewPath)) && 
        (controllerContext.HttpContext.User != null)) {
      IPrincipal principal = controllerContext.HttpContext.User;
      foreach (string role in _roles.Where(role => principal.IsInRole(role))) {
        string resolvedViewPath = String.Format(CultureInfo.InvariantCulture, viewPath, role);
        if (base.FileExists(controllerContext, resolvedViewPath)) {
          return (resolvedViewPath);
        }
      }
    }
    return (viewPath);
  }

  /// <summary>
  /// Gets a value that indicates whether a file exists in the specified virtual file system (path).
  /// </summary>
  /// <returns>
  /// true if the file exists in the virtual file system; otherwise, false.
  /// </returns>
  /// <param name="controllerContext">The controller context.</param><param name="virtualPath">The virtual path.</param>
  protected override bool FileExists(ControllerContext controllerContext, string virtualPath)
  {
    if (controllerContext.HttpContext.User != null) {
      IPrincipal principal = controllerContext.HttpContext.User;
      if (_roles.Where(role => principal.IsInRole(role))
                .Any(role => base.FileExists(controllerContext, String.Format(CultureInfo.InvariantCulture, virtualPath, role)))) {
        return (true);
      }
    }
    return(base.FileExists(controllerContext, virtualPath));
  }

  /// <summary>
  /// Finds the specified partial view by using the specified controller context.
  /// </summary>
  /// <returns>
  /// The partial view.
  /// </returns>
  /// <param name="controllerContext">The controller context.</param>
  /// <param name="partialViewName">The name of the partial view.</param>
  /// <param name="useCache">true to use the cached partial view.</param>
  /// <exception cref="T:System.ArgumentNullException">The <paramref name="controllerContext"/> parameter is null (Nothing in Visual Basic).</exception>
  /// <exception cref="T:System.ArgumentException">The <paramref name="partialViewName"/> parameter is null or empty.</exception>
  public override ViewEngineResult FindPartialView(ControllerContext controllerContext, string partialViewName, bool useCache)
  {
    return(base.FindPartialView(controllerContext, partialViewName, false));
  }

  /// <summary>
  /// Finds the specified view by using the specified controller context and master view name.
  /// </summary>
  /// <returns>
  /// The page view.
  /// </returns>
  /// <param name="controllerContext">The controller context.</param>
  /// <param name="viewName">The name of the view.</param>
  /// <param name="masterName">The name of the master view.</param>
  /// <param name="useCache">true to use the cached view.</param>
  /// <exception cref="T:System.ArgumentNullException">The <paramref name="controllerContext"/> parameter is null (Nothing in Visual Basic).</exception>
  /// <exception cref="T:System.ArgumentException">The <paramref name="viewName"/> parameter is null or empty.</exception>
  public override ViewEngineResult FindView(ControllerContext controllerContext, string viewName, string masterName, bool useCache)
  {
    return (base.FindView(controllerContext, viewName, masterName, false));
  }
}

A Walkthrough

The logic is pretty simple.

All of the available application roles are injected into the RoleBasedRazorViewEngine in priority order. Basically if someone is a member of multiple roles, the first role with a matching view will be returned.

We prepend custom views paths to include a file path based on roles. The defaults paths use the .NET string formatting place holders. The default .NET placeholders for MVC paths are:

  • {0} – The name of the action.
  • {1} – The name of the controller.
  • {2} – The name of the area.

When dealing with .NET place holder values, if you actually want to output the value {0} in a string that is being formatted, you wrap it with double braces like {{0}}. In our custom paths, we want the default .NET MVC string substitutions to occur. After that parsing has occurred, we’ll do a second string format / replacement specifying each of the user’s roles.

  ViewLocationFormats = new[] {
    "~/Views/{1}/{{0}}/{0}.cshtml",
    "~/Views/{1}/{{0}}/{0}.vbhtml",
    "~/Views/Shared/{{0}}/{0}.cshtml",
    "~/Views/Shared/{{0}}/{0}.vbhtml"
  }.Concat(base.ViewLocationFormats).ToArray();

Given the previous example, for the Index action on the Home controller, our custom search path of view locations would be:

  "~/Views/Home/{0}/index.cshtml",
  "~/Views/Home/{0}/index.vbhtml",
  "~/Views/Shared/{0}/index.cshtml",
  "~/Views/Shared/{0}/index.vbhtml",
  "~/Views/Home/index.cshtml",
  "~/Views/Home/index.vbhtml",
  "~/Views/Shared/index.cshtml",
  "~/Views/Shared/index.vbhtml"

Or our second pass, we’ll dynamically fill in what {0} should be with one of the user’s roles.

In the FileExists method, we check to see if the user belongs to any of the predefined roles. At this point, we don’t actually return the name of the file, we just indicate if we can resolve the requested path.

  protected override bool FileExists(ControllerContext controllerContext, string virtualPath)
  {
    if (controllerContext.HttpContext.User != null) {
      IPrincipal principal = controllerContext.HttpContext.User;
      if (_roles.Where(role => principal.IsInRole(role))
                .Any(role => base.FileExists(controllerContext, String.Format(CultureInfo.InvariantCulture, virtualPath, role)))) {
        return (true);
      }
    }
    return(base.FileExists(controllerContext, virtualPath));
  }

If we had the following defined roles for our application: “Administrator”, “Operator”, “User”, the following file names would be searched in order.

  "~/Views/Home/Administrator/index.cshtml",
  "~/Views/Home/Administrator/index.vbhtml",
  "~/Views/Shared/Administrator/index.cshtml",
  "~/Views/Shared/Administrator/index.vbhtml",
  "~/Views/Home/Operator/index.cshtml",
  "~/Views/Home/Operator/index.vbhtml",
  "~/Views/Shared/Operator/index.cshtml",
  "~/Views/Shared/Operator/index.vbhtml",
  "~/Views/Home/User/index.cshtml",
  "~/Views/Home/User/index.vbhtml",
  "~/Views/Shared/User/index.cshtml",
  "~/Views/Shared/User/index.vbhtml",
  "~/Views/Home/index.cshtml",
  "~/Views/Home/index.vbhtml",
  "~/Views/Shared/index.cshtml",
  "~/Views/Shared/index.vbhtml"

If one of those paths match, the CreateView or CreatePartialView methods will dynamically expand the path using a helper function to locate the correct view based on the role.

  private string GetRoleBasedPath(ControllerContext controllerContext, string viewPath)
  {
    if ((! String.IsNullOrEmpty(viewPath)) && 
        (controllerContext.HttpContext.User != null)) {
      IPrincipal principal = controllerContext.HttpContext.User;
      foreach (string role in _roles.Where(role => principal.IsInRole(role))) {
        string resolvedViewPath = String.Format(CultureInfo.InvariantCulture, viewPath, role);
        if (base.FileExists(controllerContext, resolvedViewPath)) {
          return (resolvedViewPath);
        }
      }
    }
    return (viewPath);
  }

To use this new custom ViewEngine, we simply need to register it in our Global.asax.cs file.

    /// <summary>
    /// Occurs when the first resource is requested from the web server and the web application starts.
    /// </summary>
    protected void Application_Start()
    {
      ViewEngines.Engines.Clear();
      ViewEngines.Engines.Add(new RoleBasedRazorViewEngine(new[] { "Administrator", "Operator", "User" }));
    }

With the new view engine registered, we can go back and remove our authorization or view selection logic from our controller.

public class HomeController: Controller
{
  [AcceptVerbs(HttpVerbs.Get)]
  public virtual ActionResult Index()
  {
    return (View());
  }
}

Nice, simple, clean code with a separation of concerns! We let the view engine do it’s job and pick the right view for our controller action.

1 comment

Leave a Reply

Your email address will not be published. Required fields are marked *