Dave Mateer
Dave Mateer

Reputation: 17946

Creating ASP.Net Core IAuthorizationRequirement that is based on route parameter

I have groups of pages that share common authorization requirements. Consider the following structure:

https://example.com/Company/{companyId}/Billing/Index
https://example.com/Company/{companyId}/Billing/Edit
https://example.com/Company/{companyId}/Billing/(other pages)
https://example.com/Company/{companyId}/Profile/Index
https://example.com/Company/{companyId}/Profile/Edit
https://example.com/Company/{companyId}/Profile/(other pages)

I need to ensure that users getting to any pages under /Company/{companyId}/Billing have authorization to manage billing within the company with id = {companyId}. Likewise, users requesting pages underneath /Company/{companyId}/Profile must have authorization to manage profile information for the company included in the route. It is not a simple role ... it is a role as applied to a company, which configuration is in our database.

I have a solution that is working, but it just seems ... wrong. I'm having to sniff for the {companyId} route id within the AuthorizationHandler, and that just doesn't seem right. Surely this must be a common enough scenario (protecting resources not just based on role, but based on the details of the actual resource) that there's a smoother way.

I am including my current solution as an answer below, but welcome feedback and recommendations for a better implementation.

Upvotes: 2

Views: 2075

Answers (1)

Dave Mateer
Dave Mateer

Reputation: 17946

Here's my current solution, but it just doesn't seem like this is "the right way". In particular, it seems to me like the company id should somehow be part of the CompanyAuthorizationRequirement, and not something being sniffed in the authorization handler.

Create an IAuthorizationRequirement with which to mark the resources that need to be protected:

public class CompanyAuthorizationRequirement : IAuthorizationRequirement
{
    public CompanyAuthorizationRequirement(string permission)
    {
        this.Permission = permission;
    }

    public string Permission { get; private set; }
}

Create an AuthorizationHandler to handle the requirement:

public class CompanyAuthorizationHandler : AuthorizationHandler<CompanyAuthorizationRequirement>
{
    private readonly IActionContextAccessor actionContextAccessor;
    private readonly IAuthorizationRepository authorizationRepository;

    public CompanyAuthorizationHandler(IActionContextAccessor actionContextAccessor, IAuthorizationRepository authorizationRepository)
    {
        this.actionContextAccessor = actionContextAccessor;
        this.authorizationRepository = authorizationRepository;
    }

    protected override async Task HandleRequirementAsync(AuthorizationHandlerContext context, CompanyAuthorizationRequirement requirement)
    {
        if (context.User.IsInRole(RoleNames.SystemAdministrator))
        {
            context.Succeed(requirement);
            return;
        }

        // This is the part that smells wrong to me...
        object companyIdRouteData = this.actionContextAccessor.ActionContext.RouteData.Values["companyId"];
        if (companyIdRouteData == null)
        {
            companyIdRouteData = this.actionContextAccessor.ActionContext.HttpContext.Request.Query["companyId"];
        }

        if (companyIdRouteData == null || !int.TryParse(companyIdRouteData.ToString(), out int companyId))
        {
            context.Fail();
            return;
        }

        string userId = context.User.Claims.First(c => c.Type == "sub").Value;
        bool authorized = false;

        switch (requirement.Permission)
        {
            case "billing":
                authorized = await this.authorizationRepository.CanManageCompanyBilling(userId, companyId);
                break;

            case "profile":
                authorized = await this.authorizationRepository.CanManageCompanyProfile(userId, companyId);
                break;
        }

        if (authorized)
        {
            context.Succeed(requirement);
        }
        else
        {
            context.Fail();
        }
    }
}

Add the folder authorization conventions and policies in Startup.cs:

public void ConfigureServices(IServiceCollection services)
{
    services.AddMvc()
        .AddRazorPagesOptions(options =>
        {
            options.Conventions.AuthorizeFolder("/");
            options.Conventions.AuthorizeFolder("/Company/Billing", "CanManageCompanyBilling");
            options.Conventions.AuthorizeFolder("/Company/Profile", "CanManageCompanyProfile");

            // Change the routing on all pages underneath the Company folder to include the company id in the route.
            options.Conventions.AddFolderRouteModelConvention("/Company", model =>
            {
                Regex templatePattern = new Regex("^Company(/|$)");
                foreach (var selector in model.Selectors)
                {
                    selector.AttributeRouteModel.Template = templatePattern.Replace(selector.AttributeRouteModel.Template, "Company/{company:int}$1");
                }
            });
        });

    services.AddAuthorization(options =>
    {
        options.AddPolicy("CanManageCompanyBilling", builder => builder.AddRequirements(new CompanyAuthorizationRequirement("billing")));
        options.AddPolicy("CanManageCompanyProfile", builder => builder.AddRequirements(new CompanyAuthorizationRequirement("profile")));
    });
}

Upvotes: 2

Related Questions