3

I'm looking to have all OkObjectResult responses coming out of my api get run through a custom JSON resolver that I have. The resolver relies on some request-specific data - namely, the user's roles. It's effectively like the Authorize attribute on a controller, but for data transfer objects passed from the API to the UI.

I can add the resolver in Configure Services via AddJsonOptions, but it doesn't have access to that user info there.

How can I pass values that are based on the request to this resolver? Am I looking at some sort of custom middleware, or something else?

As a sample, if I have an object with some custom attribute decorators, like so:

public class TestObject
{
    public String Field1 => "NoRestrictions";
    [RequireRoleView("Admin")]
    public String Field2 => "ViewRequiresAdmin";
}

And call my custom serializer with different roles, like so:

var test = new TestObject();
var userRoles = GetRoles(); // "User" for the sake of this example
var outputJson = JsonConvert.SerializeObject(test, 
                    new JsonSerializerSettings { 
                        ContractResolver = new MyCustomResolver(userRoles) 
                    });

Then the output JSON will skip anything the user can't access, like so:

{
    "Field1":"NoRestrictions",
    // Note the absence of Field2, since it has [RequireRoleView("Admin")]
}
user1874135
  • 419
  • 5
  • 17
  • 2
    Can you pass the `userRoles` into `TestObject` when it is constructed? If all your objects that have `RequireRoleViewAttribute` applied could have `userRoles` as an internal property, you could make a custom contract resolver that does the necessary checks in a custom [`JsonProperty.ShouldSerialize`](https://www.newtonsoft.com/json/help/html/P_Newtonsoft_Json_Serialization_JsonProperty_ShouldSerialize.htm) predicate. – dbc Nov 13 '18 at 20:28
  • 1
    Yeah, my hesitance on ShouldSerialize is in potential for name changes and stuff like that in the future - changing a property meaning we need to remember to change ShouldSerialize, etc. I can probably update `MyCustomResolver` to check for that property on the object being serialized though, instead of needing the roles passed to it when called. I'll poke around with that and see how it goes. Thanks! – user1874135 Nov 13 '18 at 20:39
  • 2
    Just to be clear, I meant that the custom contract resolver itself could add a synthetic `ShouldSerialize` predicate based on the current user roles and value for `RequireRoleView`. I.e. the logic would be similar to the contract resolver from [this answer](https://stackoverflow.com/a/29721849/3744182) but rather than doing the checks directly in `CreateProperty`, `CreateProperty()` would add a `ShouldSerialize` predicate that would check against object's roles. – dbc Nov 13 '18 at 20:46

2 Answers2

5

Suppose you have an custom RequireRoleViewAttribute:

[AttributeUsageAttribute(AttributeTargets.All, Inherited = true, AllowMultiple = true)]
public class RequireRoleViewAttribute : Attribute
{
    
    public string Role;

    public RequireRoleViewAttribute(string role){
        this.Role = role;
    }
}

How can I pass values that are based on the request to this resolver?

You can have a IServiceProvider injected in your custom resolver :

public class RoleBasedContractResolver : DefaultContractResolver
{
    public IServiceProvider ServiceProvider { get; }
    public RoleBasedContractResolver( IServiceProvider sp)
    {
        this.ServiceProvider = sp;
    }
    
    protected override JsonProperty CreateProperty(MemberInfo member, MemberSerialization memberSerialization)
    {
        var contextAccessor = this.ServiceProvider.GetRequiredService<IHttpContextAccessor>() ;
        var context = contextAccessor.HttpContext;
        var user = context.User;
        
       // if you're using the Identity, you can get the userManager :
       var userManager = context.RequestServices.GetRequiredService<UserManager<IdentityUser>>();

       // ...
    }
}

thus we can get the HttpContext and User as we like. If you're using the Identity, you can also get the UserManager service and roles.

and now we can follow @dbc's advice to control the ShouldSerialize:

    protected override JsonProperty CreateProperty(MemberInfo member, MemberSerialization memberSerialization)
    {
        var contextAccessor = this.ServiceProvider.GetRequiredService<IHttpContextAccessor>() ;
        var context = contextAccessor.HttpContext;
        var user = context.User;

        // if you use the Identitiy, you can get the usermanager
        //UserManager<IdentityUser> 
        var userManager = context.RequestServices.GetRequiredService<UserManager<IdentityUser>>();

        JsonProperty property = base.CreateProperty(member, memberSerialization);

        // get the attributes
        var attrs=member.GetCustomAttributes<RequireRoleViewAttribute>();
        
        // if no [RequireResoveView] decorated, always serialize it
        if(attrs.Count()==0) {
            property.ShouldDeserialize = instance => true;
            return property;
        }

        // custom your logic to dertermine wether should serialize the property
        // I just use check if it can statisify any the condition :
        var roles = this.GetIdentityUserRolesAsync(context,userManager).Result;
        property.ShouldSerialize = instance => {
            var resource = new { /* any you need  */ };
            return attrs.Any(attr => {
                var rolename = attr.Role;
                return roles.Any(r => r == rolename ) ;
            }) ? true : false;
        };
        return property;
    }

The function GetIdentityUserRolesAsync here is helper method to retrieve roles using the current HttpContext and the UserManger service :

private async Task<IList<string>> GetIdentityUserRolesAsync(HttpContext context, UserManager<IdentityUser> userManager)
{
    var rolesCached= context.Items["__userRoles__"];
    if( rolesCached != null){
        return (IList<string>) rolesCached;
    }
    var identityUser = await userManager.GetUserAsync(context.User);
    var roles = await userManager.GetRolesAsync(identityUser);
    context.Items["__userRoles__"] = roles;
    return roles;
}

How to inject the IServiceProvider in details :

The trick is all about how to configure the default MvcJwtOptions with an IServiceProvider.

Don't configure the JsonOptions by :

services.AddMvc().
    .AddJsonOptions(o =>{
        // o. 
    });

as it doesn't allow us add a IServiceProvider parameter.

We can custom a subclass of MvcJsonOptions:

// in .NET 3.1 and above, change this from MvcJsonOptions to MvcNewtonsoftJsonOptions
public class MyMvcJsonOptionsWrapper : IConfigureOptions<MvcJsonOptions>
{
    IServiceProvider ServiceProvider;
    public MyMvcJsonOptionsWrapper(IServiceProvider serviceProvider)
    {
        this.ServiceProvider = serviceProvider;
    }
    public void Configure(MvcJsonOptions options)
    {
        options.SerializerSettings.ContractResolver =new RoleBasedContractResolver(ServiceProvider);
    }
}

and register the services by :

services.TryAddSingleton<IHttpContextAccessor, HttpContextAccessor>();

// don't forget to add the IHttpContextAccessor
// in .NET 3.1 and above, change this from MvcJsonOptions to MvcNewtonsoftJsonOptions
services.AddTransient<IConfigureOptions<MvcJsonOptions>,MyMvcJsonOptionsWrapper>();
    

Test Case :

Let's say you have a custom POCO :

public class TestObject
{
    public string Field1 => "NoRestrictions";

    [RequireRoleView("Admin")]
    public string Field2 => "ViewRequiresAdmin";

    [RequireRoleView("HR"),RequireRoleView("OP")]
    public string Field3 => "ViewRequiresHROrOP";

    [RequireRoleView("IT"), RequireRoleView("HR")]
    public string Field4 => "ViewRequiresITOrHR";

    [RequireRoleView("IT"), RequireRoleView("OP")]
    public string Field5 => "ViewRequiresITOrOP";
}

And the Current User has roles : Admin and HR:

The result will be :

{"Field1":"NoRestrictions","Field2":"ViewRequiresAdmin","Field3":"ViewRequiresHROrOP","Field4":"ViewRequiresITOrHR"}

A screenshot of testing with an action method :

enter image description here

ProgrammingLlama
  • 36,677
  • 7
  • 67
  • 86
itminus
  • 23,772
  • 2
  • 53
  • 88
  • 1
    Yaaaay, it works! I was having some issues with using UserManager.GetRolesAsync (service not registered, etc.), so I wound up falling back to getting user roles by parsing the claims, but otherwise it's pretty slick. I also set up some extension methods, so using it is just a matter of adding the `RequireRoleView` attribute and calling `services.AddRoleBasedContractResolver()` in `Startup.ConfigureServices`. Thanks to both of you :) I've accepted yours as the answer and I'll post my own with my tweaks in case anyone in the future finds it useful. – user1874135 Nov 14 '18 at 15:53
3

Itminus's answer covers everything that's needed, but for anyone interested, I've extended it a little for easy reuse.

First, in a class library

My RequireRoleViewAttribute, which allows multiple roles (OR, not AND):

[AttributeUsage(AttributeTargets.Property)]
public class RequireRoleViewAttribute : Attribute
{
    public List<String> AllowedRoles { get; set; }

    public RequireRoleViewAttribute(params String[] AllowedRoles) =>
        this.AllowedRoles = AllowedRoles.Select(ar => ar.ToLower()).ToList();
}

My resolver is almost identical to Itminus's, but CreateProperty is adjusted to

IEnumerable<String> userRoles = this.GetIdentityUserRoles();

property.ShouldSerialize = instance =>
{
    // Check if every attribute instance has at least one role listed in the user's roles.
    return attrs.All(attr =>
                userRoles.Any(ur =>
                    attr.AllowedRoles.Any(ar => 
                        String.Equals(ar, ur, StringComparison.OrdinalIgnoreCase)))
    );
};

And GetIdentityUserRoles doesn't use UserManager

private IEnumerable<String> GetIdentityUserRoles()
{
    IHttpContextAccessor contextAccessor = this.ServiceProvider.GetRequiredService<IHttpContextAccessor>();
    HttpContext context = contextAccessor.HttpContext;
    ClaimsPrincipal user = context.User;
    Object rolesCached = context.Items["__userRoles__"];
    if (rolesCached != null)
    {
        return (List<String>)rolesCached;
    }
    var roles = ((ClaimsIdentity)user.Identity).Claims.Where(c => c.Type == ClaimTypes.Role).Select(c => c.Value).ToList();
    context.Items["__userRoles__"] = roles;
    return roles;
}

And I have an extensions class which contains:

public static IServiceCollection AddRoleBasedContractResolver(this IServiceCollection services)
{
    services.TryAddSingleton<IHttpContextAccessor, HttpContextAccessor>();
    services.AddTransient<IConfigureOptions<MvcJsonOptions>, RoleBasedContractResolverOptions>();
    return services;
}

Then in my API

I reference that class library. In Startup.cs -> ConfigureServices, I call:

public void ConfigureServices(IServiceCollection services)
{
    ...
    services.AddRoleBasedContractResolver();
    ...
}

And my DTOs are tagged with the attribute:

public class Diagnostics
{
    public String VersionNumber { get; set; }

    [RequireRoleView("admin")]
    public Boolean ViewIfAdmin => true;

    [RequireRoleView("hr")]
    public Boolean ViewIfHr => true;

    [RequireRoleView("hr", "admin")]
    public Boolean ViewIfHrOrAdmin => true;
}

And the return value as an admin is:

{
    "VersionNumber": "Debug",
    "ViewIfAdmin": true,
    "ViewIfHrOrAdmin": true
}
user1874135
  • 419
  • 5
  • 17