Featured image of post Implementing fine-grained access control with ASP.NET Core custom endpoint metadata

Implementing fine-grained access control with ASP.NET Core custom endpoint metadata

Create your own ASP.NET Core authorization policies using custom endpoint metadata to enable fine-grained access control in your web apps.

Endpoint metadata are pieces of information associated with each endpoint in an ASP.NET Core application. An endpoint is essentially an entry point into your web application, such as an MVC controller action or a route in a minimal API, which can process HTTP requests. Endpoint metadata allow for the description of these endpoints’ characteristics and behaviors, such as authorization policies, CORS (Cross-Origin Resource Sharing) restrictions, filters, and more.

In the context of MVC controllers or API controllers, endpoint metadata are often defined using attributes, such as [Authorize], or [Produces]. With the introduction of minimal APIs in ASP.NET Core 6 and later versions, the concept of endpoint metadata has also been extended to these lighter and more flexible models. Endpoint metadata are typically added using fluent methods, such as RequireAuthorization() or RequireRateLimiting().

In this article, we will explore how to use your own endpoint metadata and consume them in an authorization policy to implement fine-grained access control in your ASP.NET Core application.

# Creating your own endpoint metadata

We will implement an authorization policy based on a system of granular permissions. The code you will see in the following sections is inspired by the Microsoft.Identity.Web library, which uses these same principles to implement authorization based on OAuth 2.0 scopes.

Let’s start with our fine-grained permission system. We’ll define a simple enum to represent the different permissions that can be granted:

public enum Permission
{
    Read,
    Write,
    // ...
}

Next, we will create an attribute that represents our endpoint metadata, allowing an endpoint to specify one or more required permissions:

public interface IRequiredPermissionMetadata
{
    HashSet<Permission> RequiredPermissions { get; }
}

[AttributeUsage(AttributeTargets.Class | AttributeTargets.Method)]
public class RequiredPermissionAttribute(Permission requiredPermission, params Permission[] additionalRequiredPermissions)
    : Attribute, IRequiredPermissionMetadata
{
    public HashSet<Permission> RequiredPermissions { get; } = [requiredPermission, ..additionalRequiredPermissions];
}

The [RequiredPermission] attribute can be used on your controller methods and classes like this:

[ApiController]
[Route("[controller]")]
public class HelloController : ControllerBase
{
    [HttpGet]
    [RequiredPermission(Permission.Read, Permission.Write)]
    public IActionResult Index()
    {
        return this.Ok("Hello world");
    }
}

If you want to add it as endpoint metadata on minimal APIs, you can create the following extension method:

public static TBuilder RequirePermission<TBuilder>(
    this TBuilder endpointConventionBuilder, Permission requiredPermission, params Permission[] additionalRequiredPermissions)
    where TBuilder : IEndpointConventionBuilder
{
    return endpointConventionBuilder.WithMetadata(new RequiredPermissionAttribute(requiredPermission, additionalRequiredPermissions));
}

Thus, you can use it in the following way:

app.MapGet("/", () => "Hello World!")
    .RequirePermission(Permission.Read);

# Consuming endpoint metadata in a custom authorization requirement

Now that our endpoints are decorated with our metadata, we can create an authorization requirement. In ASP.NET Core, authorization policies are collections of authorization requirements that allow you to compose granular rules for controlling access to your resources.

Let’s create a requirement for our permission system:

public class PermissionAuthorizationRequirement : IAuthorizationRequirement;

Our authorization requirement doesn’t need to contain data, as we will use an authorization handler to retrieve the permissions declared on the endpoints that users are attempting to access.

public sealed class RequiredPermissionAuthorizationHandler : AuthorizationHandler<PermissionAuthorizationRequirement>
{
    protected override Task HandleRequirementAsync(AuthorizationHandlerContext context, PermissionAuthorizationRequirement requirement)
    {
        var endpoint = context.Resource switch
        {
            HttpContext httpContext => httpContext.GetEndpoint(),
            Endpoint ep => ep,
            _ => null,
        };

        var requiredPermissions = endpoint?.Metadata.GetMetadata<IRequiredPermissionMetadata>()?.RequiredPermissions;
        if (requiredPermissions == null)
        {
            // The endpoint is not decorated with the required permission metadata
            return Task.CompletedTask;
        }

        // TODO: Implement your custom logic to check if the user has the required permissions
        var hasRequiredPermissions = true;

        if (hasRequiredPermissions)
        {
            context.Succeed(requirement);
        }

        return Task.CompletedTask;
    }
}

Note that we used the EndpointMetadataCollection.GetMetadata<T> method by providing our endpoint metadata interface. This method ensures to retrieve the metadata that implement the IRequiredPermissionMetadata interface and is optimized with an internal cache.

Now, all that remains is to declare our authorization policy within our application’s services.

# Registering the authorization policy

Let’s create a fictional authorization policy that requires the endpoints protected by this policy to be decorated with our RequiredPermissionAttribute metadata, and for the user to be authenticated with the Cookies authentication scheme. Our policy will be named RequiredPermissions.

public static class RequiredPermissionDefaults
{
    public const string PolicyName = "RequiredPermission";
}

public static class RequiredPermissionAuthorizationExtensions
{
    public static AuthorizationPolicyBuilder RequireRequiredPermissions(this AuthorizationPolicyBuilder builder)
    {
        return builder.AddRequirements(new PermissionAuthorizationRequirement());
    }

    public static AuthorizationBuilder AddRequiredPermissionPolicy(this AuthorizationBuilder builder)
    {
        builder.AddPolicy(RequiredPermissionDefaults.PolicyName, policy =>
        {
            policy.RequireAuthenticatedUser();
            policy.AddAuthenticationSchemes(CookieAuthenticationDefaults.AuthenticationScheme);
            
            policy.RequireRequiredPermissions();
        });

        builder.Services.TryAddEnumerable(ServiceDescriptor.Singleton<IAuthorizationHandler, RequiredPermissionAuthorizationHandler>());

        return builder;
    }
}

In the code above, we took the opportunity to register our RequiredPermissionAuthorizationHandler implementation with our application’s services. This will allow our handler to be resolved by ASP.NET Core’s dependency injection system.

Finally, we can register our policy within our services:

var builder = WebApplication.CreateBuilder(args);

// TODO: Register your authentication scheme!
builder.Services.AddAuthorization();

builder.Services.AddAuthorizationBuilder()
    .AddRequiredPermissionPolicy();

builder.Services.AddAuthorization(options =>
{
    options.FallbackPolicy = options.GetPolicy(RequiredPermissionDefaults.PolicyName);
});

var app = builder.Build();

app.UseAuthentication();
app.UseAuthorization();

app.MapGet("/", () => "Hello World!")
    .RequirePermission(Permission.Read);

app.Run();

This setup ensures that our application utilizes the RequiredPermissions policy as a fallback policy, applying our fine-grained access control mechanism across all endpoints that are not already protected by another policy or are anonymous.

# Conclusion

In this article, we explored how to use endpoint metadata to implement a system of granular permissions in an ASP.NET Core application. We created our own RequiredPermissionAttribute metadata and consumed it in an authorization policy to control access to our resources. We also saw how to use an authorization handler to retrieve the required permissions from endpoint metadata.

It’s your responsibility to implement the logic for verifying permissions in your authorization handler. The same goes for the authorization policy and the authentication scheme you wish to use.

# References