I'm creating a workflow tool that will be used on our company intranet. Users are authenticated using Windows Authentication and I've set up a custom RoleProvider that maps each user to a pair of roles.
One role indicates their seniority (Guest, User, Senior User, Manager etc.) and the other indicates their role/department (Analytics, Development, Testing etc.). Users in Analytics are able to create a request that then flows up the chain to Development and so on:
Models
public class Request
{
public int ID { get; set; }
...
public virtual ICollection<History> History { get; set; }
...
}
public class History
{
public int ID { get; set; }
...
public virtual Request Request { get; set; }
public Status Status { get; set; }
...
}
In the controller I have a Create() method that will create the Request header record and the first History item:
Request Controller
public class RequestController : BaseController
{
[HttpPost]
[ValidateAntiForgeryToken]
public ActionResult Create (RequestViewModel rvm)
{
Request request = rvm.Request
if(ModelState.IsValid)
{
...
History history = new History { Request = request, Status = Status.RequestCreated, ... };
db.RequestHistories.Add(history);
db.Requests.Add(request);
...
}
}
}
Each further stage of the request will need to be handled by different users in the chain. A small subset of the process is:
Currently I have a single CreateHistory() method that handles each stage of the process. The status of the new History item is pulled up from the View:
// GET: Requests/CreateHistory
public ActionResult CreateHistory(Status status)
{
History history = new History();
history.Status = status;
return View(history);
}
// POST: Requests/CreateHistory
[HttpPost]
[ValidateAntiForgeryToken]
public ActionResult CreateHistory(int id, History history)
{
if(ModelState.IsValid)
{
history.Request = db.Requests.Find(id);
...
db.RequestHistories.Add(history);
}
}
The CreateHistory View itself will render a different partial form depending on the Status. My intention was that I could use a single generic CreateHistory method for each of the stages in the process, using the Status as a reference to determine which partial View to render.
Now, the problem comes in rendering and restricting available actions in the View. My CreateHistory View is becoming bloated with If statements to determine the availability of actions depending on the Request's current Status:
@* Available user actions *@
<ul class="dropdown-menu" role="menu">
@* Analyst has option to withdraw a request *@
<li>@Html.ActionLink("Withdraw", "CreateHistory", new { id = Model.Change.ID, status = Status.Withdrawn }, null)</li>
@* Request manager approval if not already received *@
<li>...</li>
@* If user is in Development and the Request is authorised by Analytics Manager *@
<li>...</li>
...
</ul>
Making the right actions appear at the right time is the easy part, but it feels like a clumsy approach and I'm not sure how I would manage permissions in this way. So my question is:
Should I create a separate method for every stage of the process in the RequestController, even if this results in a lot of very similar methods?
An example would be:
public ActionResult RequestApproval(int id)
{
...
}
[MyAuthoriseAttribute(Roles = "Analytics, User")]
[HttpPost]
[ValidateAntiForgeryToken]
public ActionResult RequestApproval(int id, History history)
{
...
}
public ActionResult Approve (int id)
{
...
}
[MyAuthoriseAttribute(Roles = "Analytics, Manager")]
[HttpPost]
[ValidateAntiForgeryToken]
public ActionResult Approve (int id, History history)
{
...
}
If so, how do I handle rendering the appropriate buttons in the View? I only want a set of valid actions appear as controls.
Sorry for the long post, any help would be greatly appreciated.
First of all if you have a lot of logic encapsulated in boolean based operations I highly recommend using the Specifications Pattern this and this should start you off well. It is highly reusable and allows great maintainability when existing logic changes or you need to add new logic. Look into making composite specifications that specify exactly what can be satisfied e.g. If the user is a manager and the request is unapproved.
Now with regards to your problem in your view - although when I faced the same issue in the past I had followed a similar approach to ChrisDixon. It was simple and easy to work with but looking back at the app now I find it tedious since it is burried in if statements. The approach I would take now is to create custom action links or custom controls that take the authorization into context when possible. I started writing some code to do this but in the end realized that this must be a common issue and hence found something a lot better than I myself intended to write for this answer. Although aimed at MVC3 the logic and purpose should still hold up.
Below are the snippets in case the article ever gets removed. :)
The is the extension method that checks the controller for the Authorized Attribute. In the foreach
loop you can check for the existence of your own custom attribute and authorize against it.
public static class ActionExtensions
{
public static bool ActionAuthorized(this HtmlHelper htmlHelper, string actionName, string controllerName)
{
ControllerBase controllerBase = string.IsNullOrEmpty(controllerName) ? htmlHelper.ViewContext.Controller : htmlHelper.GetControllerByName(controllerName);
ControllerContext controllerContext = new ControllerContext(htmlHelper.ViewContext.RequestContext, controllerBase);
ControllerDescriptor controllerDescriptor = new ReflectedControllerDescriptor(controllerContext.Controller.GetType());
ActionDescriptor actionDescriptor = controllerDescriptor.FindAction(controllerContext, actionName);
if (actionDescriptor == null)
return false;
FilterInfo filters = new FilterInfo(FilterProviders.Providers.GetFilters(controllerContext, actionDescriptor));
AuthorizationContext authorizationContext = new AuthorizationContext(controllerContext, actionDescriptor);
foreach (IAuthorizationFilter authorizationFilter in filters.AuthorizationFilters)
{
authorizationFilter.OnAuthorization(authorizationContext);
if (authorizationContext.Result != null)
return false;
}
return true;
}
}
This is a helper method to get the ControllerBase object which is used in the above snippet to interrogate the action filters.
internal static class Helpers
{
public static ControllerBase GetControllerByName(this HtmlHelper htmlHelper, string controllerName)
{
IControllerFactory factory = ControllerBuilder.Current.GetControllerFactory();
IController controller = factory.CreateController(htmlHelper.ViewContext.RequestContext, controllerName);
if (controller == null)
{
throw new InvalidOperationException(String.Format(CultureInfo.CurrentCulture, "The IControllerFactory '{0}' did not return a controller for the name '{1}'.", factory.GetType(), controllerName));
}
return (ControllerBase)controller;
}
}
This is the custom Html Helper that generates the action link if authorization passes. I have tweaked it from the original article to remove the link if not authorized.
public static MvcHtmlString ActionLinkAuthorized(this HtmlHelper htmlHelper, string linkText, string actionName, string controllerName, RouteValueDictionary routeValues, IDictionary<string, object> htmlAttributes)
{
if (htmlHelper.ActionAuthorized(actionName, controllerName))
{
return htmlHelper.ActionLink(linkText, actionName, controllerName, routeValues, htmlAttributes);
}
else
{
return MvcHtmlString.Empty;
}
}
Call it as you would normally call an ActionLink
@Html.ActionLinkAuthorized("Withdraw", "CreateHistory", new { id = Model.Change.ID, status = Status.Withdrawn }, null)
When coding in MVC (or, well, any language) I try and keep all, or most of, my logical statements away from my Views.
I'd keep your logic processing in your ViewModels, so:
public bool IsAccessibleToManager { get; set; }
Then, in your view, it's simple to use this variable like @if(Model.IsAccessibleToManager) {}
.
This is then populated in your Controller, and can be set however you see fit, potentially in a role logic class that keeps all this in one place.
As for the methods in your Controller, keep these the same method and do the logical processing inside the method itself. It's all entirely dependant on your structure and data repositories, but I'd keep as much of the logical processing itself at the Repository level so it's the same in every place you get/set that data.
Normally you'd have attribute tags to not allow these methods for certain Roles, but with your scenario you could do it this way...
[HttpPost]
[ValidateAntiForgeryToken]
public ActionResult Approve (int id, History history)
{
try {
// The logic processing will be done inside ApproveRecord and match up against Analytics or Manager roles.
_historyRepository.ApproveRecord(history, Roles.GetRolesForUser(yourUser));
}
catch(Exception ex) {
// Could make your own Exceptions here for the user not being authorised for the action.
}
}
If you love us? You can donate to us via Paypal or buy me a coffee so we can maintain and grow! Thank you!
Donate Us With