Over 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.
Note: If you are using the Identity 2.0 RTM released March 20, 2014, there are a few breaking changes which are incompatible with this project. If you are using the Identity 1.0 releasae that is currently standard in the MVC project template from Visual Studio, all should be well. I plan to publish an article detailing how to accomplish this same thing using Identity 2.0 in the very near future.
UPDATE 8/11/2014: For Identity 2.0 See ASP.NET Identity 2.0: Implementing Group-Based Permissions Management
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.
Obviously, if your security needs become too complex, it may be time to consider alternative permissions management systems. Also, as a commenter on Reddit pointed out, ClaimsIdentity
and ClaimsAuthorizationManager
may offer additional options. For additional info, see Going Beyond Usernames and Roles with Claims-Based Security in .NET 4.5. Finally, the preview release of Identity 2.0 shows promise for additional flexibility, including 2-stage authentication.
More on this later. First, some background.
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)
{
}
[Authorize(Roles = "HrAdmin, CanEditPayroll, CanProcessPayroll")]
[HttpPost]
public ActionResult EditPayroll(string id)
{
}
[Authorize(Roles = "HrAdmin, CanProcessPayroll")]
[HttpPost]
public ActionResult ProcessPayroll(string id)
{
}
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.
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!).
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.
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.
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
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; }
}
}
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. **
If you want to better understand what goes on under the hood with Identity (or any other .NET source which is not yet publicly available), check out either JustDecompile by Telerik (free) or Reflector from Red Gate (user to be free, but now is "free to try"). Either of these excellent Decompilation tools will give you an interesting look at what is happening underneath your code. That is how I figured this part out.
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");
}
modelBuilder.Entity<IdentityUser>().ToTable("AspNetUsers");
EntityTypeConfiguration<ApplicationUser> table =
modelBuilder.Entity<ApplicationUser>().ToTable("AspNetUsers");
table.Property((ApplicationUser u) => u.UserName).IsRequired();
modelBuilder.Entity<ApplicationUser>()
.HasMany<IdentityUserRole>((ApplicationUser u) => u.Roles);
modelBuilder.Entity<IdentityUserRole>()
.HasKey((IdentityUserRole r) =>
new { UserId = r.UserId, RoleId = r.RoleId }).ToTable("AspNetUserRoles");
modelBuilder.Entity<ApplicationUser>()
.HasMany<ApplicationUserGroup>((ApplicationUser u) => u.Groups);
modelBuilder.Entity<ApplicationUserGroup>()
.HasKey((ApplicationUserGroup r) =>
new { UserId = r.UserId, GroupId = r.GroupId }).ToTable("ApplicationUserGroups");
modelBuilder.Entity<Group>()
.HasMany<ApplicationRoleGroup>((Group g) => g.Roles);
modelBuilder.Entity<ApplicationRoleGroup>()
.HasKey((ApplicationRoleGroup gr) =>
new { RoleId = gr.RoleId, GroupId = gr.GroupId }).ToTable("ApplicationRoleGroups");
EntityTypeConfiguration<Group> groupsConfig = modelBuilder.Entity<Group>().ToTable("Groups");
groupsConfig.Property((Group r) => r.Name).IsRequired();
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);
modelBuilder.Entity<IdentityRole>().ToTable("AspNetRoles");
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>
{
new public virtual IDbSet<ApplicationRole> Roles { get; set; }
public virtual IDbSet<Group> Groups { get; set; }
public ApplicationDbContext()
: base("DefaultConnection")
{
}
protected override void OnModelCreating(DbModelBuilder modelBuilder)
{
}
}
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)
{
var groupsWithRole = user.Groups
.Where(g => g.Group.Roles
.Any(r => r.RoleId == currentRoleId)).Count();
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();
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);
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.
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()
{
_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()
{
var allGroups = _db.Groups;
var superAdmins = allGroups.First(g => g.Name == "SuperAdmins");
foreach (string name in _superAdminRoleNames)
{
_idManager.AddRoleToGroup(superAdmins.Id, name);
}
var groupAdmins = _db.Groups.First(g => g.Name == "GroupAdmins");
foreach (string name in _groupAdminRoleNames)
{
_idManager.AddRoleToGroup(groupAdmins.Id, name);
}
var userAdmins = _db.Groups.First(g => g.Name == "UserAdmins");
foreach (string name in _userAdminRoleNames)
{
_idManager.AddRoleToGroup(userAdmins.Id, name);
}
var users = _db.Groups.First(g => g.Name == "Users");
foreach (string name in _userRoleNames)
{
_idManager.AddRoleToGroup(users.Id, name);
}
}
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
};
_idManager.CreateUser(newUser, "Password1");
}
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.
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:
Looks like everything went ok!
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
John on GoogleCodeProject