State driven security in LightSwitch (part 5): let the state dictate what you can update

Introduction

We have so far a decent way to protect state transitions and a comfortable client side implementation. Now, we will cover another important facet of state driven security: the ability to update entities based on the current state.

Why do we need some new base infrastructure for this?

It is tempting to think that we can use for this the standard LightSwitch Can_<CRUD> methods like the one here for update:

 partial void HolidayRequests_CanUpdate(ref bool result)
        {

        }

What’s the problem? Well, … these set of methods is only suitable for course grained security. It can provide a go/no go for updating an entity as a whole, without referring to a particular row. Indeed, there is no current entity parameter in the above method.

Of course, we can still use these set of security methods, but not when it is about security related to the current state of an entity.

What do we need exactly?

We need a way to enforce that given a certain state (e.g. Approved) an entity (or any entity inside the object graph related to the entity monitored by the state property) can not be updated. Let’s extend our Validate method, with another one from our StateManagement class: the ValidateCanUpdateEntityGivenCurrentState method:

 

 partial void HolidayRequests_Validate(HolidayRequest entity, 
            EntitySetValidationResultsBuilder results)
        {
            StateManagement.ValidateCanUpdateEntityGivenCurrentState(
                entity,
                entity,
                s => s.HolidayStateCode,
                results);

            StateManagement.ValidateStateTransition(
                entity,
                s => s.HolidayStateCode, 
                this.DataWorkspace.ApplicationData.StateCodes, 
                results);
        }

The ValidateCanUpdateEntityGivenCurrentState is flexible enough to cope both with the situation where the state is included in the entity to validate or not.

 

public static void ValidateCanUpdateEntityGivenCurrentState<TEntityContainingState, TStateProperty, TEntityToValidate>(
            TEntityToValidate entityToValidate,
            TEntityContainingState entityContainingStateProperty,
            Expression<Func<TEntityContainingState, TStateProperty>> statePropertyLambda,
            EntitySetValidationResultsBuilder results, params IEntityProperty[] excludeProperties)
            where TEntityContainingState : IEntityObject
            where TEntityToValidate : IEntityObject
        {
            var stateProperty = entityContainingStateProperty.GetEntityTrackedProperty(statePropertyLambda);

            if (stateProperty.Value == null || entityToValidate.Details.EntityState == EntityState.Added) 
            {
                return;
            }
            var originalStateCode = stateProperty.OriginalValue as IStateCode;

            if (!entityToValidate.CanUpdateEntityInState(originalStateCode.StateValue))
            {
                if (entityToValidate.Equals(entityContainingStateProperty))
                {
                    // when the state field is part of the entity to validate, 
                    // the statefield and rowversion must be excluded from validation

                    var statePropToExclude
                        = entityContainingStateProperty.GetEntityTrackedProperty(statePropertyLambda);
                    var rowVersionPropToExclude
                        = entityContainingStateProperty.Details.Properties["RowVersion"];

                    IEntityProperty[] updatedExcludeProperties = new IEntityProperty[excludeProperties.Count() + 2];
                    for (int i = 0; i < excludeProperties.Count(); i++)
                    {
                        updatedExcludeProperties[i] = excludeProperties[i];
                    }
                    updatedExcludeProperties[excludeProperties.Count()] = statePropToExclude;
                    updatedExcludeProperties[excludeProperties.Count() + 1] = rowVersionPropToExclude;
                    entityToValidate.ValidateFieldsAreUnmodified(results, updatedExcludeProperties);
                }
                else
                {
                    entityToValidate.ValidateFieldsAreUnmodified(results, excludeProperties);

                }
            }
        }

 

 

We’ll again use the permission table to check if we have CanUpdate permission given the current state:

public static bool CanUpdateEntityInState(this IEntityObject entity, string currentState)
        {
            if (entity == null)
            {
                throw new ArgumentException("entity cannot be null");
            }
            string entityName = entity.Details.Name;
            string requiredPermission
                = string.Format(CanUpdateEntityPermissionFormat, entityName, currentState);

            return Application.Current.User.HasPermission("LightSwitchApplication:" + requiredPermission);
        }

Note, that the above method makes use of the CanUpdateEntityPermissionFormat:

        private static readonly string CanUpdateEntityPermissionFormat = "CanUpdate{0}InState{1}";

The Format allows to specify update conditions for any entity type involved in the state logic.

Eventually, we’ll call this:

public static void ValidateFieldsAreUnmodified(this IEntityObject entity,
           EntitySetValidationResultsBuilder results, params IEntityProperty[] excludeProperties)
        {
            foreach (var item in entity.Details.Properties.All().OfType<IEntityTrackedProperty>())
            {
                if (item.IsChanged && !excludeProperties.Contains(item))
                {
                    results.AddPropertyError(string.Format("Field {0} may not be updated", item.Name), item);
                }
            }
        }

Basically, all props are entity are checked if there are changes. We have also the ability to excluded certain properties. Why do we need this? Well, in case you state field is present inside the entity you are checking for modifications, it should be possible to exclude this field from the modification check. Obviously, the “RowVersion” field should be excluded also.

For this reason it could make sense (and the ValidateCanUpdateEntityGivenCurrentState is ready for this) to store the state field in a dedicated entity which is bound as a 1 to 0..1 relationship to the entity on which the state is applied. By doing so, you can avoid the avoid the usage of the above ExcludeProperties.

The nice thing about the exclude properties is that they can be specified in a strongly typed manner (by means of a lambda expression).

We can also apply the same validation method the the entity attached to the HolidayRequest entity:

partial void HolidayRequestManagementFeedbacks_Validate(HolidayRequestManagementFeedback entity, EntitySetValidationResultsBuilder results)
        {
            StateManagement.ValidateCanUpdateEntityGivenCurrentState(
                entity, entity.HolidayRequest,
                s => s.HolidayStateCode,
                results);
        }

We simply tell the method that the state can be found in entity.HolidayRequest and that the field is s=>s.HolidayStateCode.

the whole infrastructure class so far

using Microsoft.LightSwitch;
using Microsoft.LightSwitch.Details;
using System;
using System.Collections.Generic;
using System.Linq;
using System.Linq.Expressions;
using System.Reflection;

namespace LightSwitchApplication
{
    public interface IStateCode : IEntityObject
    {
        string StateValue { get; }
        bool IsInitialState { get; }
    }
    public static class PropertyExtensions
    {
        public static IEntityTrackedProperty GetEntityTrackedProperty<TSource, TProperty>(
           this TSource entity,
           Expression<Func<TSource, TProperty>> propertyLambda) where TSource : IEntityObject
        {
            string fieldName = entity.GetEntityObjectPropertyName(propertyLambda);
            return entity.Details.Properties[fieldName] as IEntityTrackedProperty;
        }

        public static string GetEntityObjectPropertyName<TSource, TProperty>
          (this TSource source,
          Expression<Func<TSource, TProperty>> propertyLambda) where TSource : IEntityObject
        {
            MemberExpression member = propertyLambda.Body as MemberExpression;
            if (member == null)
                throw new ArgumentException(string.Format(
                    "Expression '{0}' refers to a method, not a property.",
                    propertyLambda.ToString()));

            PropertyInfo propInfo = member.Member as PropertyInfo;
            if (propInfo == null)
                throw new ArgumentException(string.Format(
                    "Expression '{0}' refers to a field, not a property.",
                    propertyLambda.ToString()));

            return propInfo.Name;
        }
    }
    public static class StateManagement
    {
        private static readonly string StateTransitionPermissionFormat = "CanDoTransitionFrom{0}To{1}";
        private static readonly string CanUpdateEntityPermissionFormat = "CanUpdate{0}InState{1}";

        public static void ValidateCanUpdateEntityGivenCurrentState<TEntityContainingState, TStateProperty, TEntityToValidate>(
            TEntityToValidate entityToValidate,
            TEntityContainingState entityContainingStateProperty,
            Expression<Func<TEntityContainingState, TStateProperty>> statePropertyLambda,
            EntitySetValidationResultsBuilder results, params IEntityProperty[] excludeProperties)
            where TEntityContainingState : IEntityObject
            where TEntityToValidate : IEntityObject
        {
            var stateProperty = entityContainingStateProperty.GetEntityTrackedProperty(statePropertyLambda);

            if (stateProperty.Value == null || entityToValidate.Details.EntityState == EntityState.Added) 
            {
                return;
            }
            var originalStateCode = stateProperty.OriginalValue as IStateCode;

            if (!entityToValidate.CanUpdateEntityInState(originalStateCode.StateValue))
            {
                if (entityToValidate.Equals(entityContainingStateProperty))
                {
                    // when the state field is part of the entity to validate, 
                    // the statefield and rowversion must be excluded from validation

                    var statePropToExclude
                        = entityContainingStateProperty.GetEntityTrackedProperty(statePropertyLambda);
                    var rowVersionPropToExclude
                        = entityContainingStateProperty.Details.Properties["RowVersion"];

                    IEntityProperty[] updatedExcludeProperties = new IEntityProperty[excludeProperties.Count() + 2];
                    for (int i = 0; i < excludeProperties.Count(); i++)
                    {
                        updatedExcludeProperties[i] = excludeProperties[i];
                    }
                    updatedExcludeProperties[excludeProperties.Count()] = statePropToExclude;
                    updatedExcludeProperties[excludeProperties.Count() + 1] = rowVersionPropToExclude;
                    entityToValidate.ValidateFieldsAreUnmodified(results, updatedExcludeProperties);
                }
                else
                {
                    entityToValidate.ValidateFieldsAreUnmodified(results, excludeProperties);

                }
            }
        }

        public static void ValidateStateTransition<T, TSource, TProperty>(
            TSource entityContainingTheState,
            Expression<Func<TSource, TProperty>> statePropertyLambda,
            T stateCodes,
            EntitySetValidationResultsBuilder results, bool ignoreReflexiveTransitions = true)
            where T : IEntitySet
            where TSource : IEntityObject
        {
            var stateProperty = entityContainingTheState.GetEntityTrackedProperty(statePropertyLambda);
            var requestedStateCode = stateProperty.Value as IStateCode;

            if (requestedStateCode == null || entityContainingTheState.Details.EntityState == EntityState.Added)
            {
                string initialStateValue = StateManagement.GetInitialStateValue(stateCodes);
                if (requestedStateCode == null || requestedStateCode.StateValue != initialStateValue)
                {
                    results.AddEntityError(string.Format("The initial state must be {0}", initialStateValue));
                }
            }
            else
            {
                var originalStateCode = stateProperty.OriginalValue as IStateCode;

                bool stateChanged = !(originalStateCode.StateValue == requestedStateCode.StateValue);
                if (stateChanged || ignoreReflexiveTransitions == false)
                {
                    bool transitionIsAllowed
                        = StateManagement.IsStateTransitionAllowed(originalStateCode.StateValue, requestedStateCode.StateValue);
                    if (!transitionIsAllowed)
                    {
                        results.AddEntityError(string.Format("transition not allowed from {0} to {1}",
                            originalStateCode.StateValue, requestedStateCode.StateValue));
                    }
                }
            }
        }

        public static bool CanUpdateEntityInState(this IEntityObject entity, string currentState)
        {
            if (entity == null)
            {
                throw new ArgumentException("entity cannot be null");
            }
            string entityName = entity.Details.Name;
            string requiredPermission
                = string.Format(CanUpdateEntityPermissionFormat, entityName, currentState);

            return Application.Current.User.HasPermission("LightSwitchApplication:" + requiredPermission);
        }

        public static void ValidateFieldsAreUnmodified(this IEntityObject entity,
           EntitySetValidationResultsBuilder results, params IEntityProperty[] excludeProperties)
        {
            foreach (var item in entity.Details.Properties.All().OfType<IEntityTrackedProperty>())
            {
                if (item.IsChanged && !excludeProperties.Contains(item))
                {
                    results.AddPropertyError(string.Format("Field {0} may not be updated", item.Name), item);
                }
            }
        }

        public static List<string> GetAllowedStatesFor<T>(string currentState, T stateCodes)
            where T : IEntitySet
        {
            List<string> allowedStates = new List<string>();
            if (string.IsNullOrEmpty(currentState))
            {
                string initialStateValue = GetInitialStateValue<T>(stateCodes);
                allowedStates.Add(initialStateValue);
            }
            else
            {
                allowedStates.Add(currentState);

                foreach (IStateCode item in stateCodes)
                {
                    string requestedState = item.StateValue;
                    if (IsStateTransitionAllowed(currentState, requestedState))
                    {
                        allowedStates.Add(requestedState);
                    }
                }
            }
            return allowedStates;
        }

        public static string GetInitialStateValue<T>(T stateCodes) where T : IEntitySet
        {
            var stateList = new List<IStateCode>();
            //no better way currently than using a temp collection

            foreach (IStateCode item in stateCodes)
            {
                stateList.Add(item);
            }

            var initialStateRecord =
               stateList.Where(s => s.IsInitialState == true).Single(); // there must be one IsInitialState=true

            return initialStateRecord.StateValue;
        }

        public static bool IsStateTransitionAllowed(string currentState, string requestedState)
        {
            string requiredPermission
                = string.Format(StateTransitionPermissionFormat, currentState, requestedState);

            bool transitionIsAllowed
                = Application.Current.User.HasPermission("LightSwitchApplication:" + requiredPermission);
            return transitionIsAllowed;
        }
    }
}

How do we define the permissions for the can do behavior?

candopermissions

What’s next?

A client side implementation for both the html client and the silverlight client for State driven CanUpdate behavior.