ODataAuthorization is a library for implementing authorization on OData-enabled endpoints. It was originally written by Microsoft and I have ported it to ASP.NET Core OData a few years ago:
The Problem
When I've initially ported the library I wanted to change as little as possible. So the library is a mix of IAuthorizationHandler, AuthorizationFilter, IFilterProvider, IAuthorizationRequirement and so on...
It works somehow, but I don't understand the code. That's a problem, because I am the maintainer. 🫣
This needs to be fixed.
The Solution
There must be a simpler way to integrate the OData authorization into ASP.NET Core. So I've read through
Microsofts documentation and saw a RequireAssertion
method on an AuthorizationPolicyBuilder
, which is
described as:
There may be situations in which fulfilling a policy is simple to express in code. It's possible to supply a
Func<AuthorizationHandlerContext, bool>
when configuring a policy with theRequireAssertion
policy builder.
Now that... sounds about right?
Implementing it
Thinking about the Extension Methods
So what should the new ODataAuthorization API surface look like?
I think of something along the lines of:
var builder = WebApplication.CreateBuilder(args);
// ...
builder.Services.AddAuthorization(options =>
{
options.AddODataAuthorizationPolicy();
});
// ...
var app = builder.Build();
// ...
app
.MapControllers()
.RequireODataAuthorization();
This means we will add an extension method on the AuthorizationOptions
to add the policy, and an extension
method on the IEndpointConventionBuilder
(which MapControllers
returns) to require authorization on all
OData-enabled endpoints.
Implementing the Extension Methods
We start with a static
class ODataAuthorizationPolicies
, which is going to hold the RequireAssertion
policy and the related extension methods. We start by adding two constants for the default policy name and
the default scope name.
public static class ODataAuthorizationPolicies
{
public static class Constants
{
/// <summary>
/// Gets the Default Policy Name.
/// </summary>
public const string DefaultPolicyName = "OData";
/// <summary>
/// Gets the Default Scope Claim Type.
/// </summary>
public const string DefaultScopeClaimType = "Scope";
}
}
Next we add the RequireODataAuthorization
extension to require endpoints authorizing OData requests.
public static class ODataAuthorizationPolicies
{
// ...
/// <summary>
/// Require OData Authorization for all OData-enabled Endpoints.
/// </summary>
/// <typeparam name="TBuilder">Type of the <see cref="IEndpointConventionBuilder"/></typeparam>
/// <param name="builder">The <see cref="IEndpointConventionBuilder"/></param>
/// <param name="policyName">The Policy name</param>
/// <returns>A <typeparamref name="TBuilder"/> with OData authorization enabled</returns>
public static TBuilder RequireODataAuthorization<TBuilder>(this TBuilder builder, string policyName = Constants.DefaultPolicyName)
where TBuilder : IEndpointConventionBuilder
{
builder.RequireAuthorization(policyName);
return builder;
}
// ...
}
And finally we'll add the AuthorizationOptions#AddODataAuthorizationPolicy
extension method to add the Policy. I was
surprised to see how simple it is. I've left the ODataModelPermissionsExtractor
out, because it's only partially interesting
to the article.
/// <summary>
/// Adds the OData Authorization Policy applied to all OData-enabled Endpoints.
/// </summary>
/// <param name="options"><see cref="AuthorizationOptions"> to be configured</param>
/// <param name="policyName">The Policy Name, which defaults to <see cref="Constants.DefaultPolicyName"/></param>
/// <param name="getUserScopes">Resolver for the User Scopes, uses <see cref="Constants.DefaultScopeClaimType"/>, if <see cref="null"/> is passed</param>
public static void AddODataAuthorizationPolicy(this AuthorizationOptions options, string policyName = Constants.DefaultPolicyName, Func<ClaimsPrincipal, IEnumerable<string>>? getUserScopes = null)
{
// Set the Resolver for Permissions, if none was given
if (getUserScopes == null)
{
getUserScopes = (user) => user
.FindAll(Constants.DefaultScopeClaimType)
.Select(claim => claim.Value);
}
options.AddPolicy(policyName, policyBuilder =>
{
policyBuilder.RequireAssertion((ctx) =>
{
var resource = ctx.Resource;
// We can only work on a HttpContext or we are out
if (resource is not HttpContext httpContext)
{
return false;
}
// Get all Scopes for the User
var scopes = getUserScopes(httpContext.User);
// Check Users Scopes against the OData Route
bool isAccessAllowed = IsAccessAllowed(httpContext, scopes);
return isAccessAllowed;
});
});
}
/// <summary>
/// Checks if the Access to the requested Resource is allowed based on the Scopes.
/// </summary>
/// <param name="httpContext">The <see cref="HttpContext"/> for the OData Route</param>
/// <param name="scopes">List of Scopes to check against the Model Permissions</param>
/// <returns></returns>
public static bool IsAccessAllowed(HttpContext httpContext, IEnumerable<string> scopes)
{
// Get the OData Feature to access the parsed OData components
var odataFeature = httpContext.ODataFeature();
// We should ignore Non-OData Routes
if (odataFeature == null || odataFeature.Path == null)
{
return true;
}
// Get the EDM Model associated with the Request
IEdmModel model = httpContext.Request.GetModel();
if (model == null)
{
return false;
}
// At this point in the Middleware the SelectExpandClause hasn't been evaluated yet (https://github.com/OData/WebApiAuthorization/issues/4),
// but it's needed to provide securing the $expand-statements, so that you can't request expanded data without the required Scope Permissions.
ParseSelectExpandClause(httpContext, model, odataFeature);
// Extract the Required Permissions for the Request using the ODataModelPermissionsExtractor
var permissions = ODataModelPermissionsExtractor.ExtractPermissionsForRequest(model, httpContext.Request.Method, odataFeature.Path, odataFeature.SelectExpandClause);
// Finally evaluate the Scopes
bool allowsScopes = permissions.AllowsScopes(scopes);
return allowsScopes;
}
Conclusion
And that's it! It's working great and I finally understand the code.