QueryInterceptor similar thing in Web API

1.1k Views Asked by At

I'm actually migrating some parts of my previous WCF services to Web API.

I had used QueryInterceptor on my Machine entity which checks whether the current user has access to the desired data and returns all the data or a filtered set that they are allowed to see.

[QueryInterceptor("Machines")]
public Expression<Func<Machine, bool>> FilterMachines()
{
     return CheckMachineAccess<Machine>(m => m.MachineRole==xyz && m.userHasPermission);
}

I'm finding it difficult to implement the same in Web API. I'm using odata v4, OWIN hosted web API.

Anyone has any suggestions regarding this? Thanks in advance :)

Edit: I have followed this approach. Don't know if this is the right way to follow.

[HttpGet]
[ODataRoute("Machines")]
[EnableQuery]
public IQueryable<Machine> FilterMachines(ODataQueryOptions opts)
{
     var expression = CheckMachineAccess<Machine>(m => m.MachineRole==xyz && m.userHasPermission);

     var result = db.Machines.Where(expression);

     return (IQueryable<Machine>)result;
}
2

There are 2 best solutions below

1
Chris Schaller On BEST ANSWER

OP you are on the right track, if that is working for you then I totally support it!

I'll address the Title of your question directly first.

While using middleware is a good way to intercept incoming requests for Authentication and Access control, it is not a great way to implement row level security or to manipulate the query used in your controller.

Why? To manipulate the query for the controller, before the request is passed to the controller your middleware code will need to know so much about the controller and the data context that a lot of code will be duplicated.

In OData services, a good replacement for the many QueryInterceptor implementations is to Inherit from the EnableQuery Attribute.

[AttributeUsage(validOn: AttributeTargets.Method, Inherited = true, AllowMultiple = false)]
public class EnableQueryAttribute : System.Web.OData.EnableQueryAttribute
{
    public EnableQueryAttribute()
    {
        // TODO: Reset default values
    }

    /// <summary>
    /// Intercept before the query, here we can safely manipulate the URL before the WebAPI request has been processed so before the OData context has been resolved.
    /// </summary>
    /// <remarks>Simple implementation of common url replacement tasks in OData</remarks>
    /// <param name="actionContext"></param>
    public override void OnActionExecuting(HttpActionContext actionContext)
    {
        var tokens = HttpUtility.ParseQueryString(actionContext.Request.RequestUri.AbsoluteUri);
        // If the caller requested oDataV2 $inlinecount then remove it!
        if (tokens.AllKeys.Contains("$inlinecount"))
        {
            // CS: we don't care what value they requested, OData v4 will only return the allPages count
            tokens["$count"] = "true";
            tokens.Remove("$inlinecount");
        }
        // if caller forgot to ask for count and we are top'ing but paging hasn't been configured lets add the overall count for good measure
        else if (String.IsNullOrEmpty(tokens["$count"])
            && !String.IsNullOrEmpty(tokens["$top"])
            && this.PageSize <= 0
        )
        {
            // we want to add $count if it is not there
            tokens["$count"] = "true";
        }

        var modifiedUrl = ParseUri(tokens);

        // if we modified the url, reset it. Leaving this in a logic block to make an obvious point to extend the process, say to perform other clean up when we know we have modified the url
        if (modifiedUrl != actionContext.Request.RequestUri.AbsoluteUri)
            actionContext.Request.RequestUri = new Uri(modifiedUrl);

        base.OnActionExecuting(actionContext);
    }

    /// <summary>
    /// Simple validator that can fix common issues when converting NameValueCollection back to Uri when the collection has been modified.
    /// </summary>
    /// <param name="tokens"></param>
    /// <returns></returns>
    private static string ParseUri(System.Collections.Specialized.NameValueCollection tokens)
    {
        var query = tokens.ToHttpQuery().TrimStart('=');
        if (!query.Contains('?')) query = query.Insert(query.IndexOf('&'), "?");
        return query.Replace("?&", "?");
    }

    /// <summary>
    /// Here we can intercept the IQueryable result AFTER the controller has processed the request and created the intial query.
    /// </summary>
    /// <remarks>
    /// So you could append filter conditions to the query, but, like middleware you may need to know a lot about the controller 
    /// or you have to make a lot of assumptions to make effective use of this override. Stick to operations that modify the queryOptions 
    /// or that conditionally modify the properties on this EnableQuery attribute
    /// </remarks>
    /// <param name="queryable">The original queryable instance from the controller</param>
    /// <param name="queryOptions">The System.Web.OData.Query.ODataQueryOptions instance constructed based on the incomming request</param>
    public override IQueryable ApplyQuery(IQueryable queryable, ODataQueryOptions queryOptions)
    {
        // I do not offer common examples of this override, because they would be specific to your business logic, but know that it is an available option
        return base.ApplyQuery(queryable, queryOptions);
    }
}

But how do we solve your issue of what is effectively an implementation of Row Level Security? What you have implemented already is very similar to what I would have done. You are right, in your controller method you have enough information about the context to be able to apply a filter to your query.

I had a similar idea in my projects and have a common base class for all my controllers that has a single method that all inheriting controllers must use to get the initial filtered query for their respective entity type: The following are a cut down version of my base class methods for applying security style rules to a query

    /// <summary>
    /// Get the base table query for this entity, with user policy applied
    /// </summary>
    /// <returns>Default IQueryable reference to use in this controller</returns>
    protected Task<IQueryable<TEntity>> GetQuery()
    {
        var dbQuery = this.GetEntityQuery();
        return this.ApplyUserPolicy(dbQuery);
    }

    /// <summary>
    /// Inheriting classes MUST override this method to include standard related tables to the DB query
    /// </summary>
    /// <returns></returns>
    protected abstract DbQuery<TEntity> GetEntityQuery();

    /// <summary>
    /// Apply default user policy to the DBQuery that will be used by actions on this controller.
    /// </summary>
    /// <remarks>
    /// Allow inheriting classes to implement or override the DBQuery before it is parsed to an IQueryable, note that you cannot easily add include statements once it is IQueryable
    /// </remarks>
    /// <param name="dataTable">DbQuery to parse</param>
    /// <param name="tokenParameters">Security and Context Token variables that you can apply if you want to</param>
    /// <returns></returns>
    protected virtual IQueryable<TEntity> ApplyUserPolicy(DbQuery<TEntity> dataTable, System.Collections.Specialized.NameValueCollection tokenParameters)
    {
        // TODO: Implement default user policy filtering - like filter by tenant or customer.

        return dataTable;
    }

So now in your controller you would override the ApplyUserPolicy method to evaluate your security rules in the specific context of the Machine data, which would result in the following changes to your endpoint.

Note that I have also included additional endpoints to show how with this pattern ALL endpoints in your controller should use GetQuery() to ensure they have the correct security rules applied. The implication of this pattern though is that A single item Get will return not found instead of access denied if the item is not found because it is out of scope for that user. I prefer this limitation because my user should not have any knowledge that the other data that they are not allowed to access exists.

    /// <summary>
    /// Check that User has permission to view the rows and the required role level
    /// </summary>
    /// <remarks>This applies to all queries on this controller</remarks>
    /// <param name="dataTable">Base DbQuery to parse</param>
    /// <returns></returns>
    protected override IQueryable<Machine> ApplyUserPolicy(DbQuery<Machine> dataTable)
    {
        // Apply base level policies, we only want to add further filtering conditions, we are not trying to circumvent base level security
        var query = base.ApplyUserPolicy(dataTable, tokenParameters);

        // I am faking your CheckMachineAccess code, as I don't know what your logic is
        var role = GetUserRole();
        query = query.Where(m => m.MachineRole == role);

        // additional rule... prehaps user is associated to a specific plant or site and con only access machines at that plant
        var plant = GetUserPlant();
        if (plant != null) // Maybe plant is optional, so admin users might not return a plant, as they can access all machines
        {
            query = query.Where(m => m.PlantId == plant.PlantId);
        }

        return query;
    }

    [HttpGet]
    [ODataRoute("Machines")]
    [EnableQuery]
    public IQueryable<Machine> FilterMachines(ODataQueryOptions opts)
    {
        // Get the default query with security applied
        var expression = GetQuery();

        // TODO: apply any additional queries specific to this endpoint, if there are any

        return expression;
    }

    [HttpGet]
    [ODataRoute("Machine")]
    [EnableQuery] // so we can still apply $select and $expand
    [HttpGet]
    public SingleResult<Machine> GetMachine([FromODataUri] int key)
    { 
        // Get the default query with security applied
        var query = GetQuery();
        // Now filter for just this item by id
        query = query.Where(m => m.Id == key);

        return SingleResult.Create(query);
    }


    [HttpGet]
    [ODataRoute("MachinesThatNeedService")]
    [EnableQuery]
    internal IQueryable<Machine> GetMachinesServiceDue(ODataQueryOptions opts)
    {
        // Get the default query with security applied
        var query = GetQuery();
        // apply the specific filter for this endpoint
        var lastValidServiceDate = DateTimeOffset.Now.Add(-TimeSpan.FromDays(60));
        query = query.Where(m => m.LastService < lastValidServiceDate);

        return query;
    }
5
Ygalbel On

You can use OWIN middelware to enter in the pipe of the request.

You will have a function with HTTP request and you can decide to accept or reject the request.

Function to implement is like this:

public async override Task Invoke(IOwinContext context)
    {
        // here do your check!!

        if(isValid)
        {
            await Next.Invoke(context);
        }

        Console.WriteLine("End Request");
    }