Skip to content

Claim-Based Authorization For MVC Actions #49431

Open
@amiru3f

Description

@amiru3f

Note: the original suggestion is moot - there's a new proposal further down.

Background and Motivation

Hey guys.

As you know, there are several ways of Authorization in Aspnetcore. Policy, Role, and Claim based authorization.
Consider a situation that I want to protect a specific action (either inMinimalApi or Controller based style) depending on some specific user Permissions with the following considerations:

  • Permissions are simply located in the user claims with ClaimType=permission
  • Claims are available in either JWT, SAML token or even intercepted through IClaimsTransformation before entering the authorization process

As an example, the user claims look like sth like this:

{
 "sub": "user_id",
 "permission": ["P1", "P2"]
}

There is an easy way to have this in MinimalApis:

app.MapGet("/", () => "Granted").RequireAuthorization(policy => policy.RequireClaim("permission", "P1"));

But what about Controller Actions? The developer must hack all of the following steps!

builder.Services.AddAuthorization(options =>
{
    options.AddPolicy("RequiresPermission", policy => policy.AddRequirements(new MustHavePermissionRequirement()));
});
[RequiresPermission("P1")]
public async Task<string> Index()
{
    return "Granted";
}
[AttributeUsage(AttributeTargets.Class | AttributeTargets.Method)]
internal sealed class RequiresPermissionAttribute : Attribute, IAuthorizeData
{
    public string Permissions { get; }
    public RequiresPermissionAttribute(string permissions)
    {
        Permissions = permissions;
        Policy = "RequiresPermission";
    }

    //
    // Summary:
    //     Gets or sets the policy name that determines access to the resource.
    public string? Policy { get; set; }

    //
    // Summary:
    //     Gets or sets a comma delimited list of roles that are allowed to access the resource.
    public string? Roles { get; set; }

    //
    // Summary:
    //     Gets or sets a comma delimited list of schemes from which user information is
    //     constructed.
    public string? AuthenticationSchemes { get; set; }
}

The self-handling (Please see this) MustHavePermissionRequirement should look like sth like this:

public Task HandleAsync(AuthorizationHandlerContext context)
{
    if (context.Resource is HttpContext httpContext)
    {
        var endpoint = httpContext.GetEndpoint();
        var authDatum = endpoint?.Metadata.GetOrderedMetadata<RequiresPermissionAttribute>() ?? Array.Empty<RequiresPermissionAttribute>();
        var permissionsString = authDatum.Select(x => x.Permissions).FirstOrDefault();
        var requiredPermissions = permissionsString?.Split(",").Select(x => x.Trim()) ?? Array.Empty<string>();
        var userPermissions = context.User.Claims.Where(x => x.Type == "permission").ToList();

        //To check if the user has the specific permission or not

        context.Succeed(this);
    }

    return Task.CompletedTask;
}

But why?
The reason is if you take a look at AuthorizationMiddlware, there is a CombineAsync method which only cares about RoleBased authorization and also the IAuthorizeData interface does not support AllowedClaimType and AllowedClaimValues out-of-the-box.
There is a piece of CombineAsync method code that tries to extract the allowedRoles from [Authorize] attribute which is inherited from IAuthorizeData:

var rolesSplit = authorizeDatum.Roles?.Split(',');
if (rolesSplit?.Length > 0)
{
    var trimmedRolesSplit = rolesSplit.Where(r => !string.IsNullOrWhiteSpace(r)).Select(r => r.Trim());
    policyBuilder.RequireRole(trimmedRolesSplit);
    useDefaultPolicy = false;
}

But what I guess that can be done is:

Proposed API

The IAuthorizeData and consequently AuthorizeAttribute can support AllowedClaimType and AllowedClaimValues in order to be used in CombineAsync method like this:

namespace Microsoft.AspNetCore.Authorization;

public interface IAuthorizeData
{
    /// <summary>
    /// Gets or sets the policy name that determines access to the resource.
    /// </summary>
    string? Policy { get; set; }
    /// <summary>
    /// Gets or sets a comma delimited list of roles that are allowed to access the resource.
    /// </summary>
    string? Roles { get; set; }

+  /// <summary>
+  /// Gets or sets the claim type name that is allowed to access the resource
+  /// </summary>
+  string? AllowedClaim { get; set; }
+ 
+  /// <summary>
+  /// Gets or sets a comma delimited list of claims that are allowed to access the resource.
+  /// </summary>
+  string? AllowedClaimValues { get; set; }
+ 
    /// <summary>
    /// Gets or sets a comma delimited list of schemes from which user information is constructed.
    /// </summary>
    string? AuthenticationSchemes { get; set; }
}

So that we can explicitly combine allowed claims with the other policies in CombineAsync method like this:

var allowedClaim = authorizeDatum.AllowedClaim;
var claimSplit = authorizeDatum.AllowedClaimValues?.Split(',');

if (string.IsNullOrWhiteSpace(allowedClaim) && claimSplit?.Length > 0)
{
    var trimmedClaimSplit = claimSplit.Where(r => !string.IsNullOrWhiteSpace(r)).Select(r => r.Trim());
    policyBuilder.RequireClaim(allowedClaim!, trimmedClaimSplit);
    useDefaultPolicy = false;
}

And all of the code hacks can be removed!

Usage Examples

[Authorize(AllowedClaimType="permission", AllowedClaimValues="p1, p2")]
public async Task<string> Index()
{
    return "Granted";
}

Risks

  • Razor pages and components' authorization integration (AuthorizeRouteView, AuthorizeView, AuthorizeDataAdapter)
  • This is a public API change that is widely used, so any kinda breaking change can be also a risk
  • For the sake of multiple Claim support, can have multiple [Authorize] on the same action

P.S. I've already implemented this in my own GitHub repo. So fill free to check if it's feasible enough to submit a pull request: link-to-the-changes

Metadata

Metadata

Assignees

No one assigned

    Labels

    api-needs-workAPI needs work before it is approved, it is NOT ready for implementationarea-authIncludes: Authn, Authz, OAuth, OIDC, BearerenhancementThis issue represents an ask for new feature or an enhancement to an existing one

    Type

    No type

    Projects

    No projects

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions