ASP.NET MVC 5 Identity: Implementing Group-Based Permissions Management Part I

Posted on February 19 2014 09:01 PM by jatten in ASP.NET MVC, ASP.Net, C#, CodeProject   ||   Comments (0)

leeds-castle-portcullis-500Over the course of several recent articles, we're examined various ways and means of working with and extending the ASP.NET Identity System. We've covered the basics of configuring the database connections and working with  the EF Code-First approach used by the Identity System, extending the core IdentityUser class to add our own custom properties and behaviors, such as email addresses, First/Last names, and such. While we did that, we also looked at utilizing the basic Role-based account management which comes with ASP.NET Identity out of the box.

In the last post, we figured out how to extend the IdentityRole class, which took a little more doing than was required with IdentityUser.

Image by Shaun Dunmall | Some Rights Reserved

Here, we are going one step further, and building out a more advanced, "permissions management" model on top of the basic Users/Roles paradigm represented by the core ASP.NET Identity System out of the box.

Update: 2/27/2014: According to reader feedback, there will be some modifications required to the code in this article if you are using the newly-released ASP.NET Identity Preview. If you are checking out the Identity preview, be ready to make some adjustments. If you are using the current, stable 1.0 version, this should work quite well. I will post an article detailing the differences in the near future. 

Before we go too much further, it bears mentioning that implementing a complex permissions management system is not a small undertaking. While the model we are about to look at is not overly difficult, managing a large number of granular permissions in the context of a web application could be. You will want to think hard and plan well before you implement something like this in a production site.

With careful up-front planning, and a well-designed permission structure, you should be able to find a middle ground for your site between bloated, complex, and painful enterprise-type solutions such as Active Directory or Windows Authentication and the overly simple Identity management as it comes out of the box.

More on this later. First, some background.

Granular Management of Authorization Permissions - The Principle of Least Privilege

Good security is designed around (among other things) the Principle of Least Privilege. That is, "in a particular abstraction layer of a computing environment, every module (such as a process, a user or a program depending on the subject) must be able to access only the information and resources that are necessary for its legitimate purpose"

As we are well aware by now, the primary way we manage access to different functionality within our ASP.NET MVC application is through the [Authorize] attribute. We decorate specific controller methods with [Authorize] and define which roles can execute the method. For example, we may be building out a site for a business. Among other things, the site will likely contain any number of operational or business domains, such as Site Administration, Human Resources, Sales, Order Processing, and so on.

A hypothetical PayrollController might contain, among others, the following methods:

Methods from a hypothetical Payroll Controller:
[Authorize(Roles = "HrAdmin, CanEnterPayroll")]
[HttpPost]
public ActionResult EnterPayroll(string id)
{
    //  . . . Enter some payroll . . . 
}
  
  
[Authorize(Roles = "HrAdmin, CanEditPayroll, CanProcessPayroll")]
[HttpPost]
public ActionResult EditPayroll(string id)
{
    //  . . . Edit existing payroll entries . . . 
}
  
  
[Authorize(Roles = "HrAdmin, CanProcessPayroll")]
[HttpPost]
public ActionResult ProcessPayroll(string id)
{
    //  . . . Process payroll and cut checks . . . 
}

 

We infer from the above that the grunts who simply enter the payroll information have no business editing work already in the system. On the other hand, there are those in the company who may need to be able to edit existing payroll, which might include the managers of particular employees departments, the HR Manager themselves, and those whose job it is to process the payroll.

The action of actually processing payroll and creating checks for payment is very restricted. Only the HR manager, and those members of the "ProcessPayroll" role are able to do this, and we can assume their number is few.

Lastly, we see that the HrAdmin role has extensive privileges, including all of these functions, and also  presumable is able to act as the administrator within the Human Resources application Domain, assigning these and other domain permissions to the various users within the domain. 

Limitations of Application Authorization Under Identity

Under the current Identity system's out-of-the-box implementation (even with the ways in which we have extended it over these last few articles), We have Users, and Roles. Users are assigned to one or more roles as part of our security setup, and Admins are able to add or remove users from various roles.

Role access to various application functionality is hard-coded into our application via [Authorize], so creating and modifying roles in production is of little value, unless we have implemented some other business reason for it.

Also under the current system, each time we add a new user to the system, we need to assign individual roles specific to the user. This is not a big deal if our site includes (for example) "Admins", "Authors" and "Users." However, for a more complex site, with multiple business domains, and multiple users serving in multiple roles, this could become painful.

When security administration becomes painful, we tend to default to time-saving behavior, such as ignoring the Principle of Least Privilege, and instead granting users broad permissions so we don't have to bother (at least, if we don't have a diligent system admin!).

A Middle of the Road Solution

In this article, we examine one possible manner of extending the Identity model to form a middle-of-the road solution. For applications of moderate complexity, which require a little more granularity in authorization permissions, but which may not warrant moving to a heavy-weight solutions such as Active Directory.

I am proposing the addition of what appear to be authorization Groups to the identity mix. Groups are assigned various combinations of permissions, and Users are assigned to one or more groups.

To do this, we will be creating a slight illusion. We will simply be treating what we currently recognize as Roles as, instead, Permissions. We will then create groups of these "Role-Permissions" and assign users to one or more groups. Behind the scenes, of course, we are still constrained by the essential elements of Identity; Users and Roles. We are also still limited by having to hard-code our "Permissions" into [Authorize] attributes. However, we can define these "Role-Permissions" at a fairly granular level now, because managing assignment of Role Permissions to users will be done by assigning Users to Groups, at which point such a user will assume all of the specific permissions of each particular Group.

Building on Previous Work

I started with the foundation we have built so far, by cloning the project from the last article where we extended our Roles by inheriting from IdentityRole. We didn't do anything earth-shaking in that, but we did get a closer look at how we might override the OnModelCreating() method of ApplicationDbContext and bend EF and the Identity framework to our will, without compromising the underlying security mechanisms created by the ASP.NET team.

You can either do the same, and follow along as we walk through building this out, or you can clone the finished source from this article.

Get the Original Source from Github:
Get the Completed Source for this Article:

As in previous articles, once I have cloned the initial source project, I renamed the solution files, namespaces, directory, and project files, since in my case, I will be pushing this up as a new project, not as new changes to the old.

Next, delete the existing Migrations files (but not the Migrations folder, and not the Configuration.cs file). We will be adding to our Code-First model before we build the database, so we don't need these files anymore.

Now, we're ready to get started.

Adding the Group and ApplicationRoleGroup Models

First, of course, we need our Group class. The Group class will represent a named group of roles, and therefore we consider that the Group class has a collection of roles.  However, since each Group can include zero or many roles, and each role can also belong to zero or many Groups, this will be a many-to-many mapping in our database. Therefore, we first need an intermediate object, ApplicationRoleGroup which maps the foreign keys in the many-to-many relationship.

Add the following classes to the Models folder:

The Application Role Group Model Class:
using Microsoft.AspNet.Identity.EntityFramework;
using Microsoft.AspNet.Identity;
using System.ComponentModel.DataAnnotations;
using System.Collections.Generic;
  
  
namespace AspNetGroupBasedPermissions.Models
{
    public class ApplicationRoleGroup
    {
        public virtual string RoleId { get; set; }
        public virtual int GroupId { get; set; }
  
        public virtual ApplicationRole Role { get; set; }
        public virtual Group Group { get; set; }
    }
}

 

Then add the Group class as another new class in Models:

The Group Class:
using Microsoft.AspNet.Identity.EntityFramework;
using Microsoft.AspNet.Identity;
using System.ComponentModel.DataAnnotations;
using System.Collections.Generic;
  
namespace AspNetGroupBasedPermissions.Models
{
    public class Group
    {
        public Group() {}
  
  
        public Group(string name) : this()
        {
            this.Roles = new List<ApplicationRoleGroup>();
            this.Name = name;
        }
  
  
        [Key]
        [Required]
        public virtual int Id { get; set; }
  
        public virtual string Name { get; set; }
        public virtual ICollection<ApplicationRoleGroup> Roles { get; set; }
    }
}

 

Next, we need to create a similar many-to-many mapping model for ApplicationUser and Group. Once again, each user can have zero or many groups, and each group can have zero or many users. We already have our ApplicationUser class (although we need to modify it a little), but we need an ApplicationUserGroup class to complete the mapping.

Add the ApplicationUserGroup Model

Add the ApplicationUserGroup class to the Models folder:

using Microsoft.AspNet.Identity.EntityFramework;
using Microsoft.AspNet.Identity;
using System.ComponentModel.DataAnnotations;
using System.Collections.Generic;
  
namespace AspNetGroupBasedPermissions.Models
{
    public class ApplicationUserGroup
    {
        [Required]
        public virtual string UserId { get; set; }
        [Required]
        public virtual int GroupId { get; set; }
  
        public virtual ApplicationUser User { get; set; }
        public virtual Group Group { get; set; }
    }
}

 

Next, we need to add a Groups Property to ApplicationUser, in such a manner that Entity Framework will understand and be able to use it to populate the groups when the property is accessed. This means we need to add a virtual property which returns a instance of ICollection<ApplicationUserGroup> when the property is accessed.

Modify the existing ApplicationUser class as follows:

Modified ApplicationUser Class:
using Microsoft.AspNet.Identity.EntityFramework;
using Microsoft.AspNet.Identity;
using System.ComponentModel.DataAnnotations;
using System.Collections.Generic;
  
namespace AspNetGroupBasedPermissions.Models
{
    public class ApplicationUser : IdentityUser
    {
        public ApplicationUser()
            : base()
        {
            this.Groups = new HashSet<ApplicationUserGroup>();
        }
  
        [Required]
        public string FirstName { get; set; }
  
        [Required]
        public string LastName { get; set; }
  
        [Required]
        public string Email { get; set; }
  
        public virtual ICollection<ApplicationUserGroup> Groups { get; set; }
    }
}

 

Update ApplicationDbContext to Reflect the New Model

Now that we have extended our model somewhat, we need to update the OnModelCreating method of ApplicationDbContext so that EF can properly model our database, and work with our objects.

** This whole method becomes a little messy and cluttered, but a discerning read of the code reveals the gist of what is happening here. Don't worry too much about understanding the details of this code - just try to get a general picture of how it is mapping model entities to database tables. **

Update the OnModelCreating() method of ApplicationDbContext as follows:

Modified OnModelCreating Method for ApplicationDbContext:
protected override void OnModelCreating(DbModelBuilder modelBuilder)
{
    if (modelBuilder == null)
    {
        throw new ArgumentNullException("modelBuilder");
    }
    // Keep this:
    modelBuilder.Entity<IdentityUser>().ToTable("AspNetUsers");
    // Change TUser to ApplicationUser everywhere else - IdentityUser 
    // and ApplicationUser essentially 'share' the AspNetUsers Table in the database:
    EntityTypeConfiguration<ApplicationUser> table = 
        modelBuilder.Entity<ApplicationUser>().ToTable("AspNetUsers");
    table.Property((ApplicationUser u) => u.UserName).IsRequired();
    // EF won't let us swap out IdentityUserRole for ApplicationUserRole here:
    modelBuilder.Entity<ApplicationUser>().HasMany<IdentityUserRole>((ApplicationUser u) => u.Roles);
    modelBuilder.Entity<IdentityUserRole>().HasKey((IdentityUserRole r) => 
        new { UserId = r.UserId, RoleId = r.RoleId }).ToTable("AspNetUserRoles");
    // Add the group stuff here:
    modelBuilder.Entity<ApplicationUser>().HasMany<ApplicationUserGroup>((ApplicationUser u) => u.Groups);
    modelBuilder.Entity<ApplicationUserGroup>().HasKey((ApplicationUserGroup r) => 
        new { UserId = r.UserId, GroupId = r.GroupId }).ToTable("ApplicationUserGroups");
    // And here:
    modelBuilder.Entity<Group>().HasMany<ApplicationRoleGroup>((Group g) => g.Roles);
    modelBuilder.Entity<ApplicationRoleGroup>().HasKey((ApplicationRoleGroup gr) => 
        new { RoleId = gr.RoleId, GroupId = gr.GroupId }).ToTable("ApplicationRoleGroups");
    // And Here:
    EntityTypeConfiguration<Group> groupsConfig = modelBuilder.Entity<Group>().ToTable("Groups");
    groupsConfig.Property((Group r) => r.Name).IsRequired();
    // Leave this alone:
    EntityTypeConfiguration<IdentityUserLogin> entityTypeConfiguration = 
        modelBuilder.Entity<IdentityUserLogin>().HasKey((IdentityUserLogin l) => 
            new { UserId = l.UserId, LoginProvider = l.LoginProvider, ProviderKey = 
                l.ProviderKey }).ToTable("AspNetUserLogins");
    entityTypeConfiguration.HasRequired<IdentityUser>((IdentityUserLogin u) => u.User);
    EntityTypeConfiguration<IdentityUserClaim> table1 = 
        modelBuilder.Entity<IdentityUserClaim>().ToTable("AspNetUserClaims");
    table1.HasRequired<IdentityUser>((IdentityUserClaim u) => u.User);
    // Add this, so that IdentityRole can share a table with ApplicationRole:
    modelBuilder.Entity<IdentityRole>().ToTable("AspNetRoles");
    // Change these from IdentityRole to ApplicationRole:
    EntityTypeConfiguration<ApplicationRole> entityTypeConfiguration1 = 
        modelBuilder.Entity<ApplicationRole>().ToTable("AspNetRoles");
    entityTypeConfiguration1.Property((ApplicationRole r) => r.Name).IsRequired();
}

 

Next, we need to explicitly add a Groups property on ApplicationDbContext. Once again, this needs to be a virtual property, but in this case the return type is ICollection<Group>:

Add the Groups Property to ApplicationDbcontext:
public class ApplicationDbContext : IdentityDbContext<ApplicationUser>
{
    // Add an instance IDbSet using the 'new' keyword:
    new public virtual IDbSet<ApplicationRole> Roles { get; set; }
  
    // ADD THIS:
    public virtual IDbSet<Group> Groups { get; set; }
  
    public ApplicationDbContext()
        : base("DefaultConnection")
    {
    }
  
  
    protected override void OnModelCreating(DbModelBuilder modelBuilder)
    {
        // Code we just added above is here . .  .
    }
  
    // Etc . . .
}

 

Add Group Management Items to Identity Manager

Now, let's add some code to help us manage the various functionality we need related to Groups, and management of users and Roles ("permissions") related to Groups. Look over the methods below carefully to understand just what is going on most of the time, as several of the actions one might take upon a group have potential consequences across the security spectrum.

For example, when we delete a group, we need to also:

  • Remove all the users from the group. Remember, there is a foreign key relationship here with an intermediate or "relations table" - related records need to be removed first, or we will generally get a key constraint error.
  • Remove all the roles from that group. Remember, there is a foreign key relationship here with an intermediate or "relations table" - related records need to be removed first, or we will generally get a key constraint error.
  • Remove the roles from each user, except when that user has the same role resulting from membership in another group (this was a pain to think through!).

Likewise, when we add a Role ("Permission") to a group, we need to update all of the users in that group to reflect the added permission.

Add the following methods to the bottom of the existing IdentityManager class:

Add Group Methods to Identity Manager Class:
public void CreateGroup(string groupName)
{
    if (this.GroupNameExists(groupName))
    {
        throw new System.Exception("A group by that name already exists in the database. Please choose another name.");
    }
  
    var newGroup = new Group(groupName);
    _db.Groups.Add(newGroup);
    _db.SaveChanges();
}
  
  
public bool GroupNameExists(string groupName)
{
    var g = _db.Groups.Where(gr => gr.Name == groupName);
    if (g.Count() > 0)
    {
        return true;
    }
    return false;
}
  
  
public void ClearUserGroups(string userId)
{
    this.ClearUserRoles(userId);
    var user = _db.Users.Find(userId);
    user.Groups.Clear();
    _db.SaveChanges();
}
  
  
public void AddUserToGroup(string userId, int GroupId)
{
    var group = _db.Groups.Find(GroupId);
    var user = _db.Users.Find(userId);
  
    var userGroup = new ApplicationUserGroup()
    {
        Group = group,
        GroupId = group.Id,
        User = user,
        UserId = user.Id
    };
  
    foreach (var role in group.Roles)
    {
        _userManager.AddToRole(userId, role.Role.Name);
    }
    user.Groups.Add(userGroup);
    _db.SaveChanges();
}
  
  
public void ClearGroupRoles(int groupId)
{
    var group = _db.Groups.Find(groupId);
    var groupUsers = _db.Users.Where(u => u.Groups.Any(g => g.GroupId == group.Id));
  
    foreach (var role in group.Roles)
    {
        var currentRoleId = role.RoleId;
        foreach (var user in groupUsers)
        {
            // Is the user a member of any other groups with this role?
            var groupsWithRole = user.Groups
                .Where(g => g.Group.Roles
                    .Any(r => r.RoleId == currentRoleId)).Count();
            // This will be 1 if the current group is the only one:
            if (groupsWithRole == 1)
            {
                this.RemoveFromRole(user.Id, role.Role.Name);
            }
        }
    }
    group.Roles.Clear();
    _db.SaveChanges();
}
  
  
public void AddRoleToGroup(int groupId, string roleName)
{
    var group = _db.Groups.Find(groupId);
    var role = _db.Roles.First(r => r.Name == roleName);
    var newgroupRole = new ApplicationRoleGroup()
    {
        GroupId = group.Id,
        Group = group,
        RoleId = role.Id,
        Role = (ApplicationRole)role
    };
  
    group.Roles.Add(newgroupRole);
    _db.SaveChanges();
  
    // Add all of the users in this group to the new role:
    var groupUsers = _db.Users.Where(u => u.Groups.Any(g => g.GroupId == group.Id));
    foreach (var user in groupUsers)
    {
        if(!(_userManager.IsInRole(user.Id, roleName)))
        {
            this.AddUserToRole(user.Id, role.Name);
        }
    }
}
  
  
public void DeleteGroup(int groupId)
{
    var group = _db.Groups.Find(groupId);
  
    // Clear the roles from the group:
    this.ClearGroupRoles(groupId);
    _db.Groups.Remove(group);
    _db.SaveChanges();
}

 

We now have the core code needed to manage the relationships between Users, Groups, and Roles ("Permissions") in the back end. Now we need to set up our Migrations Configuration file to properly seed our database when we run EF Migrations.

Update the Migrations Configuration File to Seed the Database

Most of the basic model stuff is now in place such that we can run EF Migrations and build out our modified database. Before we do that, though, we want to update our Migrations Configuration class so that we seed our database with the minimal required data to function. Remember, our site is closed to "public" registration. Therefore, at the very least we need to seed it with an initial admin-level user, just like before.

What is NOT like before is that we have changed the manner in which roles are assigned and managed. Going forward, we need to seed our initial user, along with one or more initial Groups, and seed at least one of those groups with sufficient admin permissions that our initial user can take it from there.

There are many ways this code could be written. Further, depending upon your application requirements, how the database is seeded may become an extensive exercise in planning (remember that bit about how a more complex authorization model requires more and more up-front planning?).

Here, we are going to update our Configuration class with a few new methods. We will add an initial user, a handful of potentially useful Groups, and some roles relevant to managing security and authorization.

Updated Migrations Configuration File:
internal sealed class Configuration 
    : DbMigrationsConfiguration<ApplicationDbContext>
{
    IdentityManager _idManager = new IdentityManager();
    ApplicationDbContext _db = new ApplicationDbContext();
    public Configuration()
    {
        AutomaticMigrationsEnabled = true;
    }
  
  
    protected override void Seed(ApplicationDbContext context)
    {
        this.AddGroups();
        this.AddRoles();
        this.AddUsers();
        this.AddRolesToGroups();
        this.AddUsersToGroups();
    }
   
    string[] _initialGroupNames = 
        new string[] { "SuperAdmins", "GroupAdmins", "UserAdmins", "Users" };
    public void AddGroups()
    {
        foreach (var groupName in _initialGroupNames)
        {
            _idManager.CreateGroup(groupName);
        }
    }
  
  
    void AddRoles()
    {
        // Some example initial roles. These COULD BE much more granular:
        _idManager.CreateRole("Admin", "Global Access");
        _idManager.CreateRole("CanEditUser", "Add, modify, and delete Users");
        _idManager.CreateRole("CanEditGroup", "Add, modify, and delete Groups");
        _idManager.CreateRole("CanEditRole", "Add, modify, and delete roles");
        _idManager.CreateRole("User", "Restricted to business domain activity");
    }
  
  
    string[] _superAdminRoleNames = 
        new string[] { "Admin", "CanEditUser", "CanEditGroup", "CanEditRole", "User" };
    string[] _groupAdminRoleNames =
        new string[] { "CanEditUser", "CanEditGroup", "User" };
    string[] _userAdminRoleNames =
        new string[] { "CanEditUser", "User" };
    string[] _userRoleNames =
        new string[] { "User" };
    void AddRolesToGroups()
    {
        // Add the Super-Admin Roles to the Super-Admin Group:
        var allGroups = _db.Groups;
        var superAdmins = allGroups.First(g => g.Name == "SuperAdmins");
        foreach (string name in _superAdminRoleNames)
        {
            _idManager.AddRoleToGroup(superAdmins.Id, name);
        }
  
        // Add the Group-Admin Roles to the Group-Admin Group:
        var groupAdmins = _db.Groups.First(g => g.Name == "GroupAdmins");
        foreach (string name in _groupAdminRoleNames)
        {
            _idManager.AddRoleToGroup(groupAdmins.Id, name);
        }
  
        // Add the User-Admin Roles to the User-Admin Group:
        var userAdmins = _db.Groups.First(g => g.Name == "UserAdmins");
        foreach (string name in _userAdminRoleNames)
        {
            _idManager.AddRoleToGroup(userAdmins.Id, name);
        }
  
        // Add the User Roles to the Users Group:
        var users = _db.Groups.First(g => g.Name == "Users");
        foreach (string name in _userRoleNames)
        {
            _idManager.AddRoleToGroup(users.Id, name);
        }
    }
  
  
    // Change these to your own:
    string _initialUserName = "jatten";
    string _InitialUserFirstName = "John";
    string _initialUserLastName = "Atten";
    string _initialUserEmail = "jatten@typecastexception.com";
    void AddUsers()
    {
        var newUser = new ApplicationUser()
        {
            UserName = _initialUserName,
            FirstName = _InitialUserFirstName,
            LastName = _initialUserLastName,
            Email = _initialUserEmail
        };
  
        // Be careful here - you  will need to use a password which will 
        // be valid under the password rules for the application, 
        // or the process will abort:
        _idManager.CreateUser(newUser, "Password1");
    }
  
  
    // Configure the initial Super-Admin user:
    void AddUsersToGroups()
    {
        var user = _db.Users.First(u => u.UserName == _initialUserName);
        var allGroups = _db.Groups;
        foreach (var group in allGroups)
        {
            _idManager.AddUserToGroup(user.Id, group.Id);
        }
    }
}

 

As you can see in the above, I have (rather arbitrarily) decided to set up some initial groups and roles related to the Users/Groups/Roles domain. If we already knew the domain structure of the rest of our application, we might want to include additional roles ("Permissions") as part of our Configuration, since roles need to be hard-coded into our controllers using the [Authorize] attribute. The earlier we can determine the role structure for our application security model, the better. You will want to strike a balance between granularity and manageability here, though.

For the moment, we have a sufficient starting point, and we are ready to run EF Migrations and see if our database is built successfully.

Run Migrations and Build Out the Database

As mentioned previously, as I did this, I deleted the previous Migration files, but left the Migrations folder intact, with the (now modified Configuration.cs file). Therefore, in order to perform the migration, I simply type the following into the Package Manager Console:

Add New Migration:
PM> Add-Migration init

 

This scaffolds up a new migration. Next:

Build Out the Database:
PM> Update-Database

 

If everything went well, we should be able to open our database in the Visual Studio Server Explorer and see how we did. You should see something like this:

The Database in VS Server Explorer:

vs-server-explorer-database-view

Looks like everything went ok!

Next: Controllers, ViewModels, and Views

This article became long enough that I decided to break it into two parts. In this post, we figured out how to model our Users, Groups, and Roles ("Permissions") in our application, and by extension, in our database via EF Code-First and Migrations.

Next, we will start pulling all this together into the business end of our application

Next:  Part II - Controllers, ViewModels, and Views --->

 

Additional Resources and Items of Interest

 

Posted on February 19 2014 09:01 PM by jatten     

Comments (0)

Pingbacks and trackbacks (2)+

Comments are closed

About the author

My name is John Atten, and my username on many of my online accounts is xivSolutions. I am Fascinated by all things technology and software development. I work mostly with C#, Java, SQL Server 2012, learning ASP.NET MVC, html 5/CSS/Javascript. I am always looking for new information, and value your feedback (especially where I got something wrong!). You can email me at:

jatten@typecastexception.com

Web Hosting by