Read Only User in MVC3 with minimal changes

I had a task of creating a read-only user role in our MVC application. And given the limited time for the task I had to do that with minimal changes to the domain/architecture.

I have started with creating “Readonly” user role. Hopefully I don’t need to explain how to do that.

This is not the most elegant solution to the problem, but it kinda does the job. It block read-only user from using the POST actions, also if available substitutes Edit views with Details views (presuming this is CRUD application). Also we add javascript to Edit pages where we don’t have Details page.

Then I created a global filter:


    public class ReadOnlyFilter : IActionFilter 
    {
        public void OnActionExecuting(ActionExecutingContext filterContext)
        {
            var currentUser = HttpContext.Current.User;
            if (currentUser == null || !currentUser.Identity.IsAuthenticated)
                return; // user is not logged in yet. Give them a break, please! Maybe they just trying to login. Not even touching your precious controllers yet!


            if (!currentUser.IsInRole("Readonly")) 
                return; // user is not read-only. Nothing to see here, move on!


            // Presume User is read-only from now on************************************

            var readOnlyAttribute = GetForReadOnlyUserAttribute(filterContext);

            // If DisableUserInput applied to the action, turn on JS for disabling the input fields on the view
            if (readOnlyAttribute != null && readOnlyAttribute.DisableInputFields)
                DisableInputOnForm(filterContext);

            // if AllowPost Attribute applied to the action, ignore the rest of this filter and get on with this
            if (readOnlyAttribute != null && readOnlyAttribute.AllowPost == true)
                return;

            // if action is of type post - deny
            if (filterContext.HttpContext.Request.HttpMethod.ToLower() == "post" )
            {
                ReadOnlyPage(filterContext);
                return;
            }

            // if action is "Create" - deny access
            if (filterContext.ActionDescriptor.ActionName.ToLower() == "create")
            {
                ReadOnlyPage(filterContext);
                return;
            }

            // if action is edit - check if Details action exits -> redirect to it.
            if (filterContext.ActionDescriptor.ActionName.ToLower() == "edit")
            {
                // find a method named Details that has exactly the same set of parameters as the request.
                var detailsAction = GetActionMethod(filterContext, "Details");

                // if "Details" action exist, we redirect the user into that action.
                if (detailsAction != null)
                {
                    // (in)sanity check. Also Resharper told me this could be null.
                    if (filterContext.HttpContext.Request.Url == null) return;

                    // simply replace the "Edit" with "Details" in a url. This is DIRTY, just the way I like my women: http://goo.gl/wrKuI
                    var currentUrl = filterContext.HttpContext.Request.Url.ToString();
                    var newUrl = currentUrl.Replace("Edit", "Details");

                    // and redirect to the new url.
                    filterContext.Result = new RedirectResult(newUrl);
                }
                else
                {
                    DisableInputOnForm(filterContext);
                }
            }
        }



        /// 
        /// Redirect user to read-only message page
        /// 
        /// 
        private void ReadOnlyPage(ActionExecutingContext filterContext)
        {
            filterContext.Result = new RedirectResult("~/ReadOnlyAccess");
        }


        /// 
        /// Activate javascript to disable all input fields.
        /// 
        private void DisableInputOnForm(ActionExecutingContext filterContext)
        {
            filterContext.Controller.ViewBag.DisableAllInputElements = true;
        }


        /// 
        /// If ForReadOnlyUserAttribute is applied for a method, return that attribute object.
        /// If actionName provided, work with that action in the controller. Otherwise work with currently executed action.
        /// 
        private ForReadOnlyUserAttribute GetForReadOnlyUserAttribute(ActionExecutingContext filterContext, String actionName = null)
        {
            MethodInfo method = GetActionMethod(filterContext, actionName);
            if (method == null) return null;

            var readOnlyAttribute = (ForReadOnlyUserAttribute)Attribute.GetCustomAttribute(method, typeof(ForReadOnlyUserAttribute));

            return readOnlyAttribute;
        }


        /// 
        /// Return MethodInfo for the executed action on the controller.
        /// If actionNameString is provided we return MethodName for the provided name.
        /// If no actionNameString is provided, we return the currently executed Action.
        /// 
        private MethodInfo GetActionMethod(ActionExecutingContext filterContext, String actionNameString = null)
        {
            var controllerType = filterContext.Controller.GetType();

            // get all the parameters from the original request. Copy Types of parameters in an array. 
            var paramTypes = filterContext.ActionParameters
                .Values
                .Where(p => p != null)
                .Select(o => o.GetType())
                .ToArray();

            var actionName = actionNameString ?? filterContext.ActionDescriptor.ActionName;

            // find a method that has exactly the same set of parameters as the request.
            var detailsAction = controllerType.GetMethod(actionName, paramTypes);

            return detailsAction;
        }


        public void OnActionExecuted(ActionExecutedContext filterContext)
        {
            // 1960: "I have a great idea! lets have every person in the country carry a radio tracking beacon!" 
            //  "That'll never fly!"  
            //
            // 2012: "I can has TWO iphones??"
        }
    }

That class is registered in Global.asax.cs:

        public static void RegisterGlobalFilters(GlobalFilterCollection filters)
        {
            filters.Add(new ReadOnlyFilter());
        }

And you need ForReadOnlyUserAttribute.cs:

    [AttributeUsage(AttributeTargets.Method, AllowMultiple = false) ]
    public class ForReadOnlyUserAttribute : Attribute
    {
        public bool DisableInputFields { get; set; }
        public bool AllowPost { get; set; }
    }

And in your global Layout page you need to add following block of code for javascript. Taking you already have jQuery in your project:

    @{  // if we are working with read-only user and Edit page does not have Details alternative, we show Edit page, but with all the controls disabled.
        var disable = (bool?) ViewBag.DisableAllInputElements;  // this is set in ReadOnlyFilter
        if (disable.HasValue && disable.Value == true)
        {
            //script from asawyer: http://stackoverflow.com/a/12477283/809357
            
        }
    }

To disable input forms on the pages, add [ForReadOnlyUser(DisableInputFields = true)] attribute on the controller action:

        [ForReadOnlyUser(DisableInputFields = true)]
        public virtual ViewResult Details(int id)
        {
            var model = _personRepository.Find(id);
            return View(model);
        }

To allow POST actions add [ForReadOnlyUser(AllowPost = true)] to actions where you take POST submit.
Beware – this allows unauthorised users to do POST actions. That was fine in my case, but might not be acceptable for you, so change the filter code a bit.

And that seems to be it.