49

Background:
I have a very large OData model that is currently using WCF Data Services (OData) to expose it. However, Microsoft has stated that WCF Data Services is dead and that Web API OData is the way they will be going.

So I am researching ways to get Web API OData to work as well as WCF Data Services.

Problem Setup:
Some parts of the model do not need to be secured but some do. For example, the Customers list needs security to restrict who can read it, but I have other lists, like the list of Products, that any one can view.

The Customers entity has many many associations that can reach it. If you count 2+ level associations, the are many hundreds of ways that Customers can be reached (via associations). For example Prodcuts.First().Orders.First().Customer. Since Customers are the core of my system, you can start with most any entity and eventually associate your way to the Customers list.

WCF Data Services has a way for me to put security on a specific entity via a method like this:

[QueryInterceptor("Customers")]
public Expression<Func<Customer, bool>> CheckCustomerAccess()
{
     return DoesCurrentUserHaveAccessToCustomers();
}

As I look at Web API OData, I am not seeing anything like this. Plus I am very concerned because the controllers I am making don't seem to get called when an association is followed. (Meaning I can't put security in the CustomersController.)

I am worried that I will have to try to somehow enumerate all the ways that associations can some how get to customers and put security on each one.

Question:
Is there a way to put security on a specific entity in Web API OData? (Without having to enumerate all the associations that could somehow expand down to that entity?)

Vaccano
  • 70,257
  • 127
  • 405
  • 747

7 Answers7

46

UPDATE: At this point in time I would recommend that you follow the solution posted by vaccano, which is based on input from the OData team.

What you need to do is to create a new Attribute inheriting from EnableQueryAttribute for OData 4 (or QuerableAttribute depending on which version of Web API\OData you are talking with) and override the ValidateQuery (its the same method as when inheriting from QuerableAttribute) to check for the existence of a suitable SelectExpand attribute.

To setup a new fresh project to test this do the following:

  1. Create a new ASP.Net project with Web API 2
  2. Create your entity framework data context.
  3. Add a new "Web API 2 OData Controller ..." controller.
  4. In the WebApiConfigRegister(...) method add the below:

Code:

ODataConventionModelBuilder builder = new ODataConventionModelBuilder();

builder.EntitySet<Customer>("Customers");
builder.EntitySet<Order>("Orders");
builder.EntitySet<OrderDetail>("OrderDetails");

config.Routes.MapODataServiceRoute("odata", "odata", builder.GetEdmModel());

//config.AddODataQueryFilter();
config.AddODataQueryFilter(new SecureAccessAttribute());

In the above, Customer, Order and OrderDetail are my entity framework entities. The config.AddODataQueryFilter(new SecureAccessAttribute()) registers my SecureAccessAttribute for use.

  1. SecureAccessAttribute is implemented as below:

Code:

public class SecureAccessAttribute : EnableQueryAttribute
{
    public override void ValidateQuery(HttpRequestMessage request, ODataQueryOptions queryOptions)
    {
        if(queryOptions.SelectExpand != null
            && queryOptions.SelectExpand.RawExpand != null
            && queryOptions.SelectExpand.RawExpand.Contains("Orders"))
        {
            //Check here if user is allowed to view orders.
            throw new InvalidOperationException();
        }

        base.ValidateQuery(request, queryOptions);
    }
}

Please note that I allow access to the Customers controller, but I limit access to Orders. The only Controller I have implemented is the one below:

public class CustomersController : ODataController
{
    private Entities db = new Entities();

    [SecureAccess(MaxExpansionDepth=2)]
    public IQueryable<Customer> GetCustomers()
    {
        return db.Customers;
    }

    // GET: odata/Customers(5)
    [EnableQuery]
    public SingleResult<Customer> GetCustomer([FromODataUri] int key)
    {
        return SingleResult.Create(db.Customers.Where(customer => customer.Id == key));
    }
}
  1. Apply the attribute in ALL actions that you want to secure. It works exactly as the EnableQueryAttribute. A complete sample (including Nuget packages end everything, making this a 50Mb download) can be found here: http://1drv.ms/1zRmmVj

I just want to also comment a bit on some other solutions:

  1. Leyenda's solution does not work simply because it is the other way around, but otherwise was super close! The truth is that the builder will look in the entity framework to expand properties and will not hit the Customers controller at all! I do not even have one, and if you remove the security attribute, it will still retrieve the orders just fine if you add the expand command to your query.
  2. Setting the model builder will prohibit access to the entities you removed globally and from everyone, so it is not a good solution.
  3. Feng Zhao's solution could work, but you would have to manually remove the items you wanted to secure in every query, everywhere, which is not a good solution.
Savvas Kleanthous
  • 2,629
  • 15
  • 18
  • 1
    That's it. It should be noted that `EnableQueryAttribute` is available starting with Web API OData 4, so in a typical Web API solution you need to `Install-Package Microsoft.AspNet.Odata`. – Marcel N. Aug 03 '14 at 15:51
  • @MarcelN. I updated my answer to reflect this. Thank you for noticing and commenting. – Savvas Kleanthous Aug 05 '14 at 06:44
  • Great, thanks. This needs more upvotes, since is the correct & complete solution. – Marcel N. Aug 05 '14 at 06:45
  • When I tried this, adding the `SecureAccessAttribute` to the config caused all expand calls to fail with a "The query specified in the URI is not valid. Could not find a property named 'MyExpanedPropertyHere' on type 'System.Web.Http.OData.Query.Expressions.SelectAllAndExpand_1OfMyTypeThatIWasQueryingHere'" Even if I comment out the Validate Query it still fails. Once I remove the `config.AddODataQueryFilter(new SecureAccessAttribute())` the expand works fine. – Vaccano Aug 05 '14 at 15:01
  • @Vaccano Please mention the version of OData that you are working with and also the version of Web API. Also with no more code from your side, it is a bit difficult to help you debug this. Please follow my instructions to create a simple solution and use that to compare it with the one you have. What I mentioned definitely works. Also, I will post my complete solution in a while for your convenience. – Savvas Kleanthous Aug 05 '14 at 16:28
  • I think you are right. I think I have nuget version crossing issues. I will try make a new project. Either way thank you for the solution. It is clearly the best one. I will accept it now so I don't forget before the time runs out. – Vaccano Aug 05 '14 at 16:38
  • I added an answer I got from the Web API OData team. If you have a min, take a look and see if it seems as good as what you posted. – Vaccano Aug 07 '14 at 17:52
  • A different method would be along these lines, but instead override the `EnableQueryAttribute.GetModel(...)` method to optionally return a model ignoring the Orders navigation property. – jt000 Dec 29 '16 at 21:46
  • @SKleanthous any solution for ASP.NET Core ? – tchelidze Sep 25 '18 at 21:01
18

I got this answer when I asked the Web API OData team. It seems very similar to the answer I accepted, but it uses an IAuthorizationFilter.

In interest of completeness I thought I would post it here:


For entity set or navigation property appears in the path, we could define a message handler or an authorization filter, and in that check the target entity set requested by the user. E.g., some code snippet:

public class CustomAuthorizationFilter : IAuthorizationFilter
{
    public bool AllowMultiple { get { return false; } }

    public Task<HttpResponseMessage> ExecuteAuthorizationFilterAsync(
        HttpActionContext actionContext,
        CancellationToken cancellationToken,
        Func<Task<HttpResponseMessage>> continuation)
    {
        // check the auth
        var request = actionContext.Request;
        var odataPath = request.ODataProperties().Path;
        if (odataPath != null && odataPath.NavigationSource != null &&
            odataPath.NavigationSource.Name == "Products")
        {
            // only allow admin access
            IEnumerable<string> users;
            request.Headers.TryGetValues("user", out users);
            if (users == null || users.FirstOrDefault() != "admin")
            {
                throw new HttpResponseException(HttpStatusCode.Unauthorized);
            }
        }

        return continuation();
    }
}

public static class WebApiConfig
{
    public static void Register(HttpConfiguration config)
    {
        config.Filters.Add(new CustomAuthorizationFilter());

For $expand authorization in query option, a sample.

Or create per user or per group edm model. A sample.

Vaccano
  • 70,257
  • 127
  • 405
  • 747
  • 2
    Vaccano, this seems to be a good way of doing this. I will investigate further and update my answer to include an example if indeed it is. Unfortunately, this will happen in a week or so due to holidays :) – Savvas Kleanthous Aug 08 '14 at 06:16
  • @SKleanthous I have a very similar problem and found both your and Vaccano's answers interesting. Did you ever have a chance to test Vaccano's solution, and if so, do you recommend this one or the one you suggested? – Jonathan Quinth Sep 05 '17 at 06:36
  • 1
    @JonathanQuinth I am sorry for not updating my answer (I really need to do so since this is still getting views). Today I would recommend that you follow this solution unless you needed to secure access to resources based on what you're expanding (in which case I would use my suggestion with a better exception). – Savvas Kleanthous Sep 05 '17 at 14:27
  • @Vaccano any solution for ASP.NET Core ? – tchelidze Sep 25 '18 at 21:01
  • @tchelidze No clue. Sorry. – Vaccano Sep 26 '18 at 06:29
5

While I think that the solution provided by @SKleanthous is very good. However, we can do better. It has some issues which aren't going to be an issue in a majority of cases, I feel like it they were sufficient enough of a problem that I didn't want to leave it to chance.

  1. The logic checks the RawExpand property, which can have a lot of stuff in it based on nested $selects and $expands. This means that the only reasonable way you can grab information out is with Contains(), which is flawed.
  2. Being forced into using Contains causes other matching problems, say you $select a property that contains that restricted property as a substring, Ex: Orders and 'OrdersTitle' or 'TotalOrders'
  3. Nothing is gaurenteeing that a property named Orders is of an "OrderType" that you are trying to restrict. Navigation property names are not set in stone, and could get changed without the magic string being changed in this attribute. Potential maintenance nightmare.

TL;DR: We want to protect ourselves from specific Entities, but more specifically, their types without false positives.

Here's an extension method to grab all the types (technically IEdmTypes) out of a ODataQueryOptions class:

public static class ODataQueryOptionsExtensions
{
    public static List<IEdmType> GetAllExpandedEdmTypes(this ODataQueryOptions self)
    {
        //Define a recursive function here.
        //I chose to do it this way as I didn't want a utility method for this functionality. Break it out at your discretion.
        Action<SelectExpandClause, List<IEdmType>> fillTypesRecursive = null;
        fillTypesRecursive = (selectExpandClause, typeList) =>
        {
            //No clause? Skip.
            if (selectExpandClause == null)
            {
                return;
            }

            foreach (var selectedItem in selectExpandClause.SelectedItems)
            {
                //We're only looking for the expanded navigation items, as we are restricting authorization based on the entity as a whole, not it's parts. 
                var expandItem = (selectedItem as ExpandedNavigationSelectItem);
                if (expandItem != null)
                {
                    //https://msdn.microsoft.com/en-us/library/microsoft.data.odata.query.semanticast.expandednavigationselectitem.pathtonavigationproperty(v=vs.113).aspx
                    //The documentation states: "Gets the Path for this expand level. This path includes zero or more type segments followed by exactly one Navigation Property."
                    //Assuming the documentation is correct, we can assume there will always be one NavigationPropertySegment at the end that we can use. 
                    typeList.Add(expandItem.PathToNavigationProperty.OfType<NavigationPropertySegment>().Last().EdmType);

                    //Fill child expansions. If it's null, it will be skipped.
                    fillTypesRecursive(expandItem.SelectAndExpand, typeList);
                }
            }
        };

        //Fill a list and send it out.
        List<IEdmType> types = new List<IEdmType>();
        fillTypesRecursive(self.SelectExpand?.SelectExpandClause, types);
        return types;
    }
}

Great, we can get a list of all expanded properties in a single line of code! That's pretty cool! Let's use it in an attribute:

public class SecureEnableQueryAttribute : EnableQueryAttribute
{
    public List<Type> RestrictedTypes => new List<Type>() { typeof(MyLib.Entities.Order) }; 

    public override void ValidateQuery(HttpRequestMessage request, ODataQueryOptions queryOptions)
    {
        List<IEdmType> expandedTypes = queryOptions.GetAllExpandedEdmTypes();

        List<string> expandedTypeNames = new List<string>();
        //For single navigation properties
        expandedTypeNames.AddRange(expandedTypes.OfType<EdmEntityType>().Select(entityType => entityType.FullTypeName()));
        //For collection navigation properties
        expandedTypeNames.AddRange(expandedTypes.OfType<EdmCollectionType>().Select(collectionType => collectionType.ElementType.Definition.FullTypeName())); 

        //Simply a blanket "If it exists" statement. Feel free to be as granular as you like with how you restrict the types. 
        bool restrictedTypeExists =  RestrictedTypes.Select(rt => rt.FullName).Any(rtName => expandedTypeNames.Contains(rtName));

        if (restrictedTypeExists)
        {
            throw new InvalidOperationException();
        }

        base.ValidateQuery(request, queryOptions);
    }

}

From what I can tell, the only navigation properties are EdmEntityType (Single Property) and EdmCollectionType (Collection Property). Getting the type name of the collection is a little different just because it will call it a "Collection(MyLib.MyType)" instead of just a "MyLib.MyType". We don't really care if it's a collection or not, so we get the Type of the Inner Elements.

I've been using this in production code for a while now with great success. Hopefully you will find an equal amount with this solution.

Zachary Dow
  • 1,639
  • 18
  • 34
1

You could remove certain properties from the EDM programmatically:

var employees = modelBuilder.EntitySet<Employee>("Employees");
employees.EntityType.Ignore(emp => emp.Salary);

from http://www.asp.net/web-api/overview/odata-support-in-aspnet-web-api/odata-security-guidance

phish_bulb
  • 77
  • 7
  • 2
    Alas, just removing it is not really the security I am looking for. I need some to have access and others to not. – Vaccano Aug 02 '14 at 16:32
0

Would it be feasible to move this to your database? Assuming you're using SQL server, set up users which match the profiles you need for each client profile. Keeping it simple, one account with customer access and one without.

If you then map the user making a data request to one of these profiles and modify your connection string to include the related credentials. Then if they make a request to an entity they are not permitted to, they will get an exception.

Firstly, sorry if this is a misunderstanding of the problem. Even though I'm suggesting it, I can see a number of pitfalls most immediate being the extra data access control and maintenance within your db.

Also, I'm wondering if something can be done within the T4 template which generates your entity model. Where the association is defined, it might be possible to inject some permission control there. Again this would put the control in a different layer - I'm just putting it out there in case someone who knows T4s better than me can see a way to make this work.

kidshaw
  • 3,189
  • 2
  • 13
  • 25
0

The ValidateQuery override will help with detecting when a user explicitly expands or selects a navigable property, however it won't help you when a user uses a wildcard. For example, /Customers?$expand=*. Instead, what you likely want to do is change the model for certain users. This can be done using the EnableQueryAttribute's GetModel override.

For example, first create a method to generate your OData Model

public IEdmModel GetModel(bool includeCustomerOrders)
{
    ODataConventionModelBuilder builder = new ODataConventionModelBuilder();

    var customerType = builder.EntitySet<Customer>("Customers").EntityType;
    if (!includeCustomerOrders)
    {
        customerType.Ignore(c => c.Orders);
    }
    builder.EntitySet<Order>("Orders");
    builder.EntitySet<OrderDetail>("OrderDetails");

    return build.GetModel();
}

... then in a class that inherits from EnableQueryAttribute, override GetModel:

public class SecureAccessAttribute : EnableQueryAttribute
{
    public override IEdmModel GetModel(Type elementClrType, HttpRequestMessage request, HttpActionDescriptor actionDescriptor)
    {
        bool includeOrders = /* Check if user can access orders */;
        return GetModel(includeOrders);
    }
}

Note that this will create a bunch of the same models on multiple calls. Consider caching various versions of your IEdmModel to increase performance of each call.

jt000
  • 3,007
  • 1
  • 14
  • 33
-2

You can put your own Queryable attribute on Customers.Get() or whichever method is used to access the Customers entity (either directly or through a navigation property). In the implementation of your attribute, you can override the ValidateQuery method to check the access rights, like this:

public class MyQueryableAttribute : QueryableAttribute
{
    public override void ValidateQuery(HttpRequestMessage request, 
    ODataQueryOptions queryOptions)
    {
        if (!DoesCurrentUserHaveAccessToCustomers)
        {
            throw new ODataException("User cannot access Customer data");
        }

        base.ValidateQuery(request, queryOptions);
    }
}

I don't know why your controller isn't called on navigation properties. It should be...

Leyenda
  • 41
  • 2
  • 3
    Should the controller normally be called? If the OP is using EF, then maybe the WebAPI backend just uses the EF navigation properties to get the relations. – Marcel N. Aug 03 '14 at 00:05
  • 4
    This will not work since the OData model builder can still build the data using the relationships provided from the entity context. Actually if you read my answer you will notice that I only have a Customers controller, but I am still fully able to expand Orders (for example). – Savvas Kleanthous Aug 05 '14 at 06:10
  • 7
    @Leyenda: This post is discussed on meta: http://meta.stackoverflow.com/questions/267772/answers-which-are-wrong. – Patrick Hofman Aug 05 '14 at 14:36