Creating Overridable Hierarchical Attribute Filters for ASP.net 6

Control which instance of the filter executes

You have worked with them and probably haven't given them a second thought: Attribute filters that can be placed at the controller level as well as the action level, and it is expected that the action-level one "overrides" whatever setting/functionality is set at the controller level.

Recently I answered a question in Stack Overflow about how to implement this behavior. The main problem the asker had was that he/she believed that just because the attribute filter was the same, ASP.net would automagically run just the innermost one, merely because it was repeated. This is not how it works.

First of all, let's remind ourselves about filters in ASP.net by reading this Microsoft resource.

In a nutshell, there are three important pieces of information that must stand out in the context of this article:

  1. Authorization, resource, action, exception and result filters run after the routing mechanism has selected the action to run. The technique demonstrated in this article can be used for any of these filter types.
  2. The different types of filters have a specific order of execution.
  3. Authorization and exception filters are run only once. The other filter types execute twice.

So let's get started.

The Goal

Create a filter attribute that, when placed at the action level, overrides itself at the controller level, if present.

In a code example:

[ApiController]
[Route("[controller]")]
[MyAuth("At Controller")]
public class SomeController : ControllerBase
{

    private readonly ILogger<OrderController> _logger;

    public OrderController(ILogger<OrderController> logger)
    {
        _logger = logger;
    }

    [HttpPost]
    [MyAuth("At Action")]
    public Task<IActionResult> AddOrder(Order order)
    {
        return Task.FromResult<IActionResult>(Ok());
    }
}

The above example shows the MyAuthAttribute attribute specified in two places: At the controller level, and at the action level, in the AddOrder action specifically. The idea here is to prevent the controller-level MyAuth attribute to execute whenever the AddOrder action is called because we only want the filter attribute version that was applied at the action level to execute.

There are probably several ways to accomplish this, but the most elegant way (which is the object of this write-up) is probably with the use of the FilterContext.FindEffectivePolicy method.

FilterContext.FindEffectivePolicy

At the time of writing, the only information in the provided link says only this:

Returns the most effective (most specific) policy of type TMetadata applied to the action associated with the FilterContext.

Yes, indeed Microsoft documentation has gone down the drain in the recent years. But I digress. Back to topic.

This means that for given a type TMetadata, the method will search instances of said type through the controller hierarchy, and will return the instance that is most specific, being the most specific position the action position.

In light of this and accounting for our goal, we now have a way to determine exactly which version of the attribute is supposed to run.

The Solution

As an (incomplete) example, I will start creating a custom authorization attribute. We want only the most specific version of our authorization attribute to be enforced, and we'll use the base class TypeFilterAttribute so our implementation can obtain services from the IoC container if needed.

using Microsoft.AspNetCore.Mvc;

namespace my.app;

// [AttributeUsage(AttributeTargets.Class | AttributeTargets.Method)] <-- This already comes with TypeFilterAttribute.
public class MyAuthAttribute : TypeFilterAttribute
{
    public MyAuthAttribute(string name)
        : base(typeof(MyAuth))
    {
        Arguments = new object[] { name };
    }
}

Hopefully the above is not weird. It is just how to use TypeFilterAttribute. Our actual authorization code will reside inside the MyAuth class. In this example, we have added a Name property so as to be able to track what's happening in the debug console.

using Microsoft.AspNetCore.Mvc.Filters;

namespace my.app;

public class MyAuth : IAsyncAuthorizationFilter
{
    #region Properties
    public string Name { get; }
    #endregion

    #region Constructors
    public MyAuth(string name)
    {
        Name = name;
    }
    #endregion

    #region IAsyncAuthorizationFilter
    public Task OnAuthorizationAsync(AuthorizationFilterContext context)
    {
        System.Diagnostics.Debug.Print($"My name: {Name}");
        var effectiveAtt = context.FindEffectivePolicy<MyAuth>();
        System.Diagnostics.Debug.Print($"Effective filter's name: {effectiveAtt?.Name}");
        System.Diagnostics.Debug.Print($"Am I the effective attribute? {this == effectiveAtt}");
        if (this == effectiveAtt)
        {
            // Do stuff since this is the effective attribute (policy).
        }
        else
        {
            // ELSE part probably not needed.  We just want the IF to make sure the code runs only once.
        }
        return Task.CompletedTask;
    }
    #endregion
}

This is it. One call to FindEffectivePolicy plus a simple if statement is all you need to implement a filter attribute that can override itself whenever it is applied at the controller and action levels simultaneously.

If you use the above code plus the example code in the Goal section, you'll see the following output in the debug console whenever you attempt to call the AddOrder action:

My name: At Controller
Effective filter's name: At Action
Am I the effective attribute? False
My name: At Action
Effective filter's name: At Action
Am I the effective attribute? True

Caveat with Filters that Run Twice

As stated by Microsoft, some filter types run before and after certain step. If you implement the asynchronous version of the method (which I think you should), then you're probably OK. However, if you implement the synchronous versions, you'll have the before and after methods, and you'll have to do the check in both.

Feel free to drop any comments, and happy coding.