Click here to Skip to main content
16,016,263 members
Articles / Web Development / CSS

MVC .NET Core Dynamic Role Based Authorization

Rate me:
Please Sign up or sign in to vote.
5.00/5 (28 votes)
15 Apr 2022CPOL8 min read 104.5K   5K   65   49
MVC .NET Core 3.1 Dynamic Role based authorization using Authorization Handler & Custom Authorize Policy
MVC application with dynamic roles and dynamic permissions with Custom Authorize Policy to have the ability to change authorization of different features in the system.

Table of Contents

Goal

Usually for small organisations, there are no predefined fixed roles/users. They learn processes while they grow and prosper over time. In such situations, we usually get requirements to create role and assign permissions dynamically without compromising security because mostly, those people giving requirements also not sure about the roles or policies. So here, we'll try to learn dynamic Role based Authorization.

Introduction

In this article, we'll try to learn how to create dynamic roles and assign permissions dynamically to those roles. It's a continuation of a previous article, MVC 6 Dynamic Navigation Menu from Database.

Previously, we learnt how to generate the menu dynamically from database. Now according to that menu, we need to validate permissions for user roles. We'll learn to:

  • create a new role
  • assign/remove role permissions dynamically
  • assign/remove new role to User

Components Used

Here are the components that you'll need to build and test the demo code provided.

We'll be using .NET Core Framework version 3.1 with C# & MVC project template, so let's start.

From the previous article, I've added some extra fields like ExternalUrl & DisplayOrder to give an option to add external links in the menu and set the order of menu items as per user's choice.

Create New Project

Open Visual Studio 2019 and click on Create a new project to start with a new project.

Image 1

It'll show you the below screen for more selections, so select C#, All platforms, Web and then ASP.NET Core Web Application and click Next.

Image 2

Here, we need to provide the project name and click on Create.

Image 3

Select .NET Core, ASP.NET Core 3.1, Model-View-Controller as the template and Individual User Accounts as Authentication, then click on Create, Visual Studio will create a new project with all these settings for you.

Image 4

After setting up the project, let's create database based on our model, make sure to setup the connection string in appsettings.json file. I'll be using the localhost as my server with Windows authentication, following is my connection string.

JavaScript
"DefaultConnection": "Server=localhost;Database=DynamicPermissions;
Trusted_Connection=True;MultipleActiveResultSets=true"

I've created NavigationMenu to store menu names & RoleMenuPermission entity to store role permissions.

C#
[Table(name: "AspNetNavigationMenu")]
public class NavigationMenu
{
    [Key]
    [DatabaseGenerated(DatabaseGeneratedOption.Identity)]
    public Guid Id { get; set; }

    public string Name { get; set; }

    [ForeignKey("ParentNavigationMenu")]
    public Guid? ParentMenuId { get; set; }

    public virtual NavigationMenu ParentNavigationMenu { get; set; }

    public string Area { get; set; }

    public string ControllerName { get; set; }

    public string ActionName { get; set; }
        
    public bool IsExternal { get; set; }

    public string ExternalUrl { get; set; }

    public int DisplayOrder { get; set; }

    [NotMapped]
    public bool Permitted { get; set; }

    public bool Visible { get; set; }
}

[Table(name: "AspNetRoleMenuPermission")]
public class RoleMenuPermission
{
    public string RoleId { get; set; }

    public Guid NavigationMenuId { get; set; }

    public NavigationMenu NavigationMenu { get; set; }
}

Here is my Db Context, we're overriding OnModelCreating to define RoleId & NavigationMenuId as keys because we don't need an identity key for this table.

C#
public class ApplicationDbContext : IdentityDbContext
{
      public ApplicationDbContext(DbContextOptions<ApplicationDbContext> options)
      : base(options)
      {

      }

      public DbSet<RoleMenuPermission> RoleMenuPermission { get; set; }

      public DbSet<NavigationMenu> NavigationMenu { get; set; }

      protected override void OnModelCreating(ModelBuilder builder)
      {
           builder.Entity<RoleMenuPermission>()
           .HasKey(c => new { c.RoleId, c.NavigationMenuId});
           

           base.OnModelCreating(builder);
      }
}

Migrations

Now we need to run the migrations and then update the database, Enable-Migrations command has been obsolete, so we need to delete everything from Migrations folder and then run add migration command.

add-migration InitialVersion

Here are my database tables like shown below:

Image 5

For more details on Seeding data, you can check out the following article:

New version of EF .NET Core has HasData on ModelBuilder object in OnModelCreating function but for now, we'll stick to the above approach for this demonstration.

Modification in DbInitializer, added new Permissions and allocated to Admin Role, we need these to be available in the database so we can allocate and validate for User roles later.

For our limited scope we'll add data through seed there's no CRUD screen as part of this scope but I hope you talented folks can do it in no time.

C#
new NavigationMenu()
{
    Id = new Guid("F704BDFD-D3EA-4A6F-9463-DA47ED3657AB"),
    Name = "External Google Link",
    ControllerName = "",
    ActionName = "",
    IsExternal = true,
    ExternalUrl = "https://www.google.com/",
    ParentMenuId = new Guid("13e2f21a-4283-4ff8-bb7a-096e7b89e0f0"),
    DisplayOrder=2,
    Visible = true,
},
new NavigationMenu()
{
    Id = new Guid("913BF559-DB46-4072-BD01-F73F3C92E5D5"),
    Name = "Create Role",
    ControllerName = "Admin",
    ActionName = "CreateRole",
    ParentMenuId = new Guid("13e2f21a-4283-4ff8-bb7a-096e7b89e0f0"),
    DisplayOrder=3,
    Visible = true,
},
new NavigationMenu()
{
    Id = new Guid("3C1702C5-C34F-4468-B807-3A1D5545F734"),
    Name = "Edit User",
    ControllerName = "Admin",
    ActionName = "EditUser",
    ParentMenuId = new Guid("13e2f21a-4283-4ff8-bb7a-096e7b89e0f0"),
    DisplayOrder=3,
    Visible = false,
},
new NavigationMenu()
{
    Id = new Guid("94C22F11-6DD2-4B9C-95F7-9DD4EA1002E6"),
    Name = "Edit Role Permission",
    ControllerName = "Admin",
    ActionName = "EditRolePermission",
    ParentMenuId = new Guid("13e2f21a-4283-4ff8-bb7a-096e7b89e0f0"),
    DisplayOrder=3,
    Visible = false,
},

I have added two new functions in the data service from our previous implementation.

We'll get all defined permissions from NavigationMenu joining with allocated to role having Permitted = true so based on that, we can render the checkboxes Checked/Unchecked.

C#
public async Task<List<NavigationMenuViewModel>> GetPermissionsByRoleIdAsync(string id)
{
    var items = await (from m in _context.NavigationMenu
                      join rm in _context.RoleMenuPermission
                      on new { X1 = m.Id, X2 = id } equals 
                         new { X1 = rm.NavigationMenuId, X2 = rm.RoleId }
                      into rmp
                      from rm in rmp.DefaultIfEmpty()
                      select new NavigationMenuViewModel()
                      {
                          Id = m.Id,
                          Name = m.Name,
                          Area = m.Area,
                          ActionName = m.ActionName,
                          ControllerName = m.ControllerName,
                          IsExternal = m.IsExternal,
                          ExternalUrl = m.ExternalUrl,
                          DisplayOrder = m.DisplayOrder,
                          ParentMenuId = m.ParentMenuId,
                          Visible = m.Visible,
                          Permitted = rm.RoleId == id
                       })
                         .AsNoTracking()
                         .ToListAsync();

   return items;
}

//Remove old permissions for that role id and assign changed permissions

public async Task<bool> SetPermissionsByRoleIdAsync(string id, IEnumerable<Guid> permissionIds)
{
    var existing = await _context.RoleMenuPermission.Where(x => x.RoleId == id).ToListAsync();
     _context.RemoveRange(existing);

     foreach (var item in permissionIds)
     {
        await _context.RoleMenuPermission.AddAsync(new RoleMenuPermission()
        {
            RoleId = id,
            NavigationMenuId = item,
        });
     }

     var result = await _context.SaveChangesAsync();

     // Remove existing permissions to roles from Cache so it can re evaluate and take effect
     _cache.Remove("RolePermissions");

     return result > 0;
}

Here is my Admin Controller, for detailed implementation for actions, we can see code in the zip. Simple implementation, no magic code :). We just need to put [Authorize("Authorization")] on any Action we want to tell the application to validate authorization or it can be used on Controller level if all the actions are protected under same policy.

C#
[Authorize]
public class AdminController : Controller
{
    private readonly UserManager<IdentityUser> _userManager;
    private readonly RoleManager<IdentityRole> _roleManager;
    private readonly IDataAccessService _dataAccessService;
    private readonly ILogger<AdminController> _logger;

    public AdminController(
        UserManager<IdentityUser> userManager,
        RoleManager<IdentityRole> roleManager,
        IDataAccessService dataAccessService,
        ILogger<AdminController> logger)
    {
        _userManager = userManager;
        _roleManager = roleManager;
        _dataAccessService = dataAccessService;
        _logger = logger;
    }

    [Authorize("Authorization")]
    public async Task<IActionResult> Roles() {}

    [HttpPost]
    [Authorize("Roles")]
    public async Task<IActionResult> CreateRole(RoleViewModel viewModel) {}

    [Authorize("Authorization")]
    public async Task<IActionResult> Users() {}

    [Authorize("Users")]
    public async Task<IActionResult> EditUser(string id){}

    [HttpPost]
    [Authorize("Users")]
    public async Task<IActionResult> EditUser(UserViewModel viewModel){}        

    [Authorize("Authorization")]
    public async Task<IActionResult> EditRolePermission(string id){}

    [HttpPost]
    [Authorize("Authorization")]
    public async Task<IActionResult> EditRolePermission
                     (string id, List<NavigationMenuViewModel> viewModel){}
}

Here is how we render the checkboxes list.

HTML
<form asp-action="EditRolePermission">
    <div class="form-group">
        <ul style="list-style-type: none;">
            @for (var i = 0; i < Model.Count; i++)
            {
                <li>
                    <input type="checkbox" asp-for="@Model[i].Permitted" />
                    <label style="margin-left:10px;" 
                    asp-for="@Model[i].Permitted">@Model[i].Name</label>
                    <input type="hidden" asp-for="@Model[i].Id" />
                    <input type="hidden" asp-for="@Model[i].Name" />
                </li>
            }
        </ul>
    </div>
    <div class="form-group">
        <input type="submit" value="Save" class="btn btn-primary" />
        <a asp-action="Roles">Back to List</a>
    </div>
</form>

So now we can run & test the system with Admin User by logging in with:

  • Username: admin@test.com
  • Password: P@ssw0rd

Roles, Create Role

Here is the list of roles which were created as part of migrations:

Image 6

From Create Role screen, a new role can be added in the system.

Image 7

Assign Role Permissions

In Roles Listing, if we click on Edit Permissions button, it'll take us to Permissions screen listing all permissions with allocated permissions checked.

Image 8

Now we can change these permissions and save to make it effective for users under that role. So let's try to change it.

Image 9

We'll uncheck External Google Link & Create Role.

Image 10

Now when I'll save these changes and after that again Edit permissions for that same role.

Image 11

As you can see, now those two permissions are unchecked and not present in the menu as well.

Now I can try to access Create Role page by pasting the URL, so it should validate me according to my updated permissions and throw me an Access Denied.

Same can be verified if we copy URL for some page with user having access, then login with some other user without access to that page and paste the copied URL, it should give the same error.

Image 12

Assign Role to User

We can see the user listing with Edit button.

Image 13

By Editing, we'll be able to assign/remove roles to User, after we click on Edit button, we can see the below screen with list of all roles as check box list available in the system.

Image 14

So now we have screens to Create new role, Roles listing, Edit User, Edit permissions for role, based on these interfaces, we need to validate the authorization.

Access Restriction with Cache

We'll use Authorization handler for that purpose but instead of many policies or roles already defined at the time of development, in real world systems, roles can be changed and reassigned to different users or one user can have multiple roles for some specific period of time, etc. Keeping that in mind, we'll give the liberty to the end user to give permissions to their defined roles so their customer/employees with those roles could perform their duties according to their roles and permissions.

We'll generalize AuthorizationHandler to make it work dynamically with permissions from the database. We need to Create an Authorization requirement and inherit from IAuthorizationRequirement interface. Now we can create an AuthorizationHandler and pass our requirement using generics, then we can override the HandleRequirementAsync function. To get Controller and Action from end point and check for permission from database. With this approach, Authorization will be coupled to MVC but that's ok because that handler has been written for this particular purpose & use.

We can provide a parent action name as well when we have lots of ajax child actions that we want to protect as well e.g. I have an Action Role Listing so I say if a Role has access to Role Listing it should be allowed to create new role as well. So in this case we are protecting two actions based on one Permission.

C#
public class AuthorizationRequirement : IAuthorizationRequirement
{
    public AuthorizationRequirement(string permissionName)
    {
        PermissionName = permissionName;
    }

    public string PermissionName { get; }
}

public class PermissionHandler : AuthorizationHandler<AuthorizationRequirement>
{
    private readonly IDataAccessService _dataAccessService;

    public PermissionHandler(IDataAccessService dataAccessService)
    {
        _dataAccessService = dataAccessService;
    }

    protected async override Task HandleRequirementAsync(AuthorizationHandlerContext context, AuthorizationRequirement requirement)
    {
        if (context.Resource is RouteEndpoint endpoint)
        {
            endpoint.RoutePattern.RequiredValues.TryGetValue("controller", out var _controller);
            endpoint.RoutePattern.RequiredValues.TryGetValue("action", out var _action);
                
            endpoint.RoutePattern.RequiredValues.TryGetValue("page", out var _page);
            endpoint.RoutePattern.RequiredValues.TryGetValue("area", out var _area);

            // Check if a parent action is permitted then it'll allow child without checking for child permissions
            if (!string.IsNullOrWhiteSpace(requirement?.PermissionName) && !requirement.PermissionName.Equals("Authorization"))
            {
                _action = requirement.PermissionName;
            }

            if (context.User.Identity.IsAuthenticated && _controller != null && _action != null &&
                   await _dataAccessService.GetMenuItemsAsync(context.User, _controller.ToString(), _action.ToString()))
            {
                context.Succeed(requirement);
            }
         }

         await Task.CompletedTask;
     }
}

We can use Cache to hold permissions to reduce database calls for authorization checks on each resource access. Role Permissions can be added to user claims and permissions in Cache to give it a performance boost.

So in the Data Service we are going to make some changes, we'll use MemoryCache, injecting through DI then use GetOrCreateAsync function.

C#
public class DataAccessService : IDataAccessService
{
    private readonly IMemoryCache _cache;
    private readonly ApplicationDbContext _context;

    public DataAccessService(ApplicationDbContext context, IMemoryCache cache)
    {
        _cache = cache;
        _context = context;
    }

    public async Task<List<NavigationMenuViewModel>> GetMenuItemsAsync(ClaimsPrincipal principal)
    {
        var isAuthenticated = principal.Identity.IsAuthenticated;
        if (!isAuthenticated)
        {
            return new List<NavigationMenuViewModel>();
        }

        var roleIds = await GetUserRoleIds(principal);

        var permissions = await _cache.GetOrCreateAsync("Permissions",
             async x => await (from menu in _context.NavigationMenu select menu).ToListAsync());

        var rolePermissions = await _cache.GetOrCreateAsync("RolePermissions",
             async x => await (from menu in _context.RoleMenuPermission select menu).Include(x => x.NavigationMenu).ToListAsync());

        var data = (from menu in rolePermissions
                    join p in permissions on menu.NavigationMenuId equals p.Id
                    where roleIds.Contains(menu.RoleId)
                    select p)
                           .Select(m => new NavigationMenuViewModel()
                           {
                              Id = m.Id,
                              Name = m.Name,
                              Area = m.Area,
                              Visible = m.Visible,
                              IsExternal = m.IsExternal,
                              ActionName = m.ActionName,
                              ExternalUrl = m.ExternalUrl,
                              DisplayOrder = m.DisplayOrder,
                              ParentMenuId = m.ParentMenuId,
                              ControllerName = m.ControllerName,
                          }).Distinct().ToList();

        return data;
    }

}

Custom Authorization Policy

Usually when we have large number of policies or dynamic polices so in such cases we can't add each individual policy with AuthorizationOptions.AddPolicy. Same can be used if we are reading these policies from database or external data source some api etc. Creating policies at runtime based on information make sense.

Using policy-based authorization, policies are registered by calling AuthorizationOptions. AddPolicy as part of authorization service configuration. We can use a custom IAuthorizationPolicyProvider to control how authorization policies are supplied. So here's our implementation class and some changes in Startup.cs are required as well

C#
public class AuthorizationPolicyProvider : DefaultAuthorizationPolicyProvider
{
    private readonly AuthorizationOptions _options;

    public AuthorizationPolicyProvider(IOptions<AuthorizationOptions> options) : base(options)
    {
        _options = options.Value;
    }

    public override async Task<AuthorizationPolicy> GetPolicyAsync(string policyName)
    {
        return await base.GetPolicyAsync(policyName)
                 ?? new AuthorizationPolicyBuilder()
                     .AddRequirements(new AuthorizationRequirement(policyName))
                     .Build();
    }
}

in Startup.cs need to register it with the handler.

C#
services.AddScoped<IAuthorizationHandler, PermissionHandler>();
services.AddSingleton<IAuthorizationPolicyProvider, AuthorizationPolicyProvider>();

Now we don't need to use the single policy registration in the startup class which was like 

C#
services.AddAuthorization(options =>
{
     options.AddPolicy("Authorization", policyCorrectUser =>
     {
          policyCorrectUser.Requirements.Add(new AuthorizationRequirement());
     });
});

Conclusion

We have created our database through migrations and started our project under Development environment. Logged in User can see Menu Items and pages according to dynamically defined role permissions. The source code is attached. I encourage you to download the sample code, run and see. All of you are most welcome to post comments if you have any questions/suggestions.

Thanks for reading...

Interesting Reads

Between developer community and .NET Core security team, some discussions are still going on.

History

License

This article, along with any associated source code and files, is licensed under The Code Project Open License (CPOL)


Written By
Software Developer
Pakistan Pakistan
This member has not yet provided a Biography. Assume it's interesting and varied, and probably something to do with programming.

Comments and Discussions

 
QuestionPages Identity Pin
Armando Solares9-Nov-23 3:54
Armando Solares9-Nov-23 3:54 
Questionspecific role Pin
Preethi S 20235-Oct-23 4:22
Preethi S 20235-Oct-23 4:22 
AnswerRe: specific role Pin
dnxit6-Oct-23 10:54
dnxit6-Oct-23 10:54 
GeneralRe: specific role Pin
Preethi S 20236-Oct-23 20:46
Preethi S 20236-Oct-23 20:46 
GeneralRe: specific role Pin
dnxit15-Oct-23 2:47
dnxit15-Oct-23 2:47 
QuestionCan we autologin user with windows authentication? Pin
Member 1340043724-Jan-23 9:31
Member 1340043724-Jan-23 9:31 
AnswerRe: Can we autologin user with windows authentication? Pin
dnxit25-Jan-23 8:51
dnxit25-Jan-23 8:51 
QuestionDynamic Role Pin
halubabuba16-Oct-22 1:46
halubabuba16-Oct-22 1:46 
AnswerRe: Dynamic Role Pin
dnxit25-Jan-23 8:53
dnxit25-Jan-23 8:53 
QuestionRequest for the .net 6 version of this sample Pin
Sadegh Dehghani15-Apr-22 3:39
Sadegh Dehghani15-Apr-22 3:39 
AnswerRe: Request for the .net 6 version of this sample Pin
dnxit15-Apr-22 11:35
dnxit15-Apr-22 11:35 
GeneralRe: Request for the .net 6 version of this sample Pin
Sadegh Dehghani9-Jun-22 19:45
Sadegh Dehghani9-Jun-22 19:45 
GeneralRe: Request for the .net 6 version of this sample Pin
dnxit11-Jun-22 8:52
dnxit11-Jun-22 8:52 
Questionthank you about this article Pin
Sadegh Dehghani6-Mar-22 18:48
Sadegh Dehghani6-Mar-22 18:48 
AnswerRe: thank you about this article Pin
dnxit7-Mar-22 5:31
dnxit7-Mar-22 5:31 
GeneralRe: thank you about this article Pin
Sadegh Dehghani8-Mar-22 3:46
Sadegh Dehghani8-Mar-22 3:46 
QuestionPostman Pin
XaspMe31-Aug-21 23:29
XaspMe31-Aug-21 23:29 
AnswerRe: Postman Pin
dnxit1-Sep-21 5:05
dnxit1-Sep-21 5:05 
GeneralRe: Postman Pin
XaspMe1-Sep-21 23:05
XaspMe1-Sep-21 23:05 
GeneralRe: Postman Pin
dnxit6-Sep-21 9:15
dnxit6-Sep-21 9:15 
GeneralRe: Postman Pin
XaspMe6-Sep-21 1:07
XaspMe6-Sep-21 1:07 
QuestionLove the article but missing in code is showing how a edit page or a create page is handled Pin
Member 1263846212-May-21 13:47
Member 1263846212-May-21 13:47 
AnswerRe: Love the article but missing in code is showing how a edit page or a create page is handled Pin
dnxit13-May-21 5:33
dnxit13-May-21 5:33 
QuestionIs we used this code in web api core? Pin
RohitNegi8910-May-21 17:16
RohitNegi8910-May-21 17:16 
AnswerRe: Is we used this code in web api core? Pin
dnxit11-May-21 8:54
dnxit11-May-21 8:54 

General General    News News    Suggestion Suggestion    Question Question    Bug Bug    Answer Answer    Joke Joke    Praise Praise    Rant Rant    Admin Admin   

Use Ctrl+Left/Right to switch messages, Ctrl+Up/Down to switch threads, Ctrl+Shift+Left/Right to switch pages.