Home > Software engineering >  Restrict JsonPatchDocument patch operations on a per member basis
Restrict JsonPatchDocument patch operations on a per member basis

Time:02-22

Are there any recommendations for cases where you need some members to be immutable/read-only as far as patch operations are concerned?

Normally we would pass a context entity to the JsonPatchDocument, we expose the entire object to modification bar any entity validation rules you may have.

I can imagine a case were one member of an entity should not be editable for security reasons or may be a calculated value.

I don's see much online regarding this, one way I could think of was to map my entity to an UpdateRequest/DTO class that you could pass to initialize the JsonPatchDocument object. Then run the patch operation against that, finally mapping the updateRequest back to the db entity and persisting.

I think this would work, but feels a little messy, would be great to have a decorator that restricted patch operations on a per member basis e.g [NoPatch] or the inverse.

Here is a nice standard implementation of Dotnet API patch operation that matches my current understanding of the topic How to use JSONPatch in .net core

CodePudding user response:

After more research I came across this project which allowed you to pass in a list of immutable members Tingle.AspNetCore.JsonPatch.NewtonsoftJson

I wanted to take that further and decorate my entities with attributes that could also handle role based permissions and have the ability to disable patch operations completely, so this is what I came up with.

ApplyPatchWrapper

public static class JsonPatchDocumentExtensions
{
    public static void CheckAttributesThenApply<T>(this JsonPatchDocument<T> patchDoc,
                                        T objectToApplyTo,
                                        Action<JsonPatchError> logErrorAction,
                                        List<string>? currentUserRoles)
        where T : class
    {
        if (patchDoc == null) throw new ArgumentNullException(nameof(patchDoc));
        if (objectToApplyTo == null) throw new ArgumentNullException(nameof(objectToApplyTo));
        
        foreach (var op in patchDoc.Operations)
        {
            if (!string.IsNullOrWhiteSpace(op.path))
            {
                var pathToPatch = op.path.Trim('/').ToLowerInvariant();
                var objectToPatch = objectToApplyTo.GetType().Name;
                var attributesFilter = BindingFlags.Public | BindingFlags.FlattenHierarchy | BindingFlags.Instance;
                var propertyToPatch = typeof(T).GetProperties(attributesFilter).FirstOrDefault(p => p.Name.Equals(pathToPatch, StringComparison.InvariantCultureIgnoreCase));
                
                var patchRestrictedToRolesAttribute = propertyToPatch?
                    .GetCustomAttributes(typeof(PatchRestrictedToRolesAttribute),false)
                    .Cast<PatchRestrictedToRolesAttribute>()
                    .SingleOrDefault();
                if (patchRestrictedToRolesAttribute != null)
                {
                    var userCanUpdateProperty = patchRestrictedToRolesAttribute.GetUserRoles().Any(r =>
                        currentUserRoles != null && currentUserRoles.Any(c => c.Equals(r, StringComparison.InvariantCultureIgnoreCase)));
                    if(!userCanUpdateProperty) logErrorAction(new JsonPatchError(objectToApplyTo, op, 
                        $"Current user role is not permitted to patch {objectToPatch}.{propertyToPatch!.Name}"));
                }
                
                var patchDisabledForProperty = propertyToPatch?
                    .GetCustomAttributes(typeof(PatchDisabledAttribute),false)
                    .SingleOrDefault();
                if (patchDisabledForProperty != null)
                {
                    logErrorAction(new JsonPatchError(objectToApplyTo, op, 
                        $"Patch operations on {objectToPatch}.{propertyToPatch!.Name} have been disabled"));
                }
            }
        }
        patchDoc.ApplyTo(objectToApplyTo: objectToApplyTo, logErrorAction: logErrorAction);
    }
    
}

Entity with custom attribute

public class AquisitionChannel
{
    [PatchDisabled]
    public Guid Id { get; set; } = Guid.Empty;
    
    [PatchRestrictedToRoles(new [] { UserRoleConstants.SalesManager, UserRoleConstants.Administrator })]
    public string Description { get; set; } = string.Empty;
}

Usage

var currentUserRoles = _contextAccessor.HttpContext.User.Claims.Where(c => c.Type == ClaimTypes.Role)
                .Select(c => c.Value).ToList();

patchDocument.CheckAttributesThenApply(acquisitionChannelToUpdate, 
                error => throw new JsonPatchException(error),currentUserRoles);
  • Related