← all posts

Extending ASP.NET Identity for fine-grained permission control

  • .net
  • security
  • architecture

ASP.NET’s Identity provides a comprehensive framework for managing your application’s security. However, in terms of delegating different levels of access control to users, only Roles are provided. This works perfectly for applications that have relatively simple security requirements. For example, a web application for a gym may differentiate its users between staff who work at the gym, and members who visit the gym and want to track their workout sessions. For this, the gym’s application could simply group its users into two roles — Staff and Member.

More complex enterprise applications, however, may want fine-grained permissions: a way to enforce the principle of least privilege, restrict certain features to the few people authorised to perform them, and ensure users can only access data that is relevant to them. Identity doesn’t provide an easy way to do this out of the box.

When I started developing MyPortal, I knew that I would need to give schools a way to control the individual actions that users would be able to perform. A school’s Management Information System contains lots of sensitive information about children, staff members and other parties. Safeguarding information is typically only available to a handful of staff, so guarding access via roles such as Staff, Student and Parent would be too loose to enforce those security policies.

I chose to use ASP.NET Identity to handle user authentication for MyPortal, but in order to establish its security framework, I knew I’d have to augment Identity’s existing access controls to support individual permissions.

Why not just make every action a role?

I will concede that developers could gate individual actions as Roles. For instance, you could have a CanViewStudentDetails role, and assign users to those roles to grant them access to those actions.

However, in my eye, a “Role” is a group that somebody is assigned to which generally grants access to multiple actions. For instance, a Classroom Teacher role may grant access to read and write attendance data, assessment data, student details, and homework. But there may be other roles that also grant access to a set of those same permissions. An Attendance Officer role may grant access to read/write attendance data. Additionally, users may wish to create their own security roles for specific organisational roles and simply add or remove users from roles instead of having to manage individual permissions for each user.

The distinction matters because the two things change at different rates and for different reasons. Permissions are defined by the software — they only change when a developer adds a feature. Roles are defined by the school — one secondary might have an Attendance Officer, a Head of Year and a Cover Supervisor; another might not. Collapsing the two means every school reorganises your role list, and every new feature reorganises theirs.

The design

I then got to work creating a design that would allow me to implement my vision for managing access controls. First, I would need a new entity, Permission:

  • Id
  • Name
  • FriendlyName
  • Area

There would also need to be a link table between Roles and Permissions so that multiple roles can grant the same permissions.

Three of those four fields are there for the humans. Name is what the code checks — Attendance.EditAttendanceMarks — and never changes once shipped. FriendlyName is what a school administrator reads when they are building a role: “Edit attendance marks”. Area groups the list, so the role editor shows a hundred-odd permissions as a set of collapsible sections rather than as one long alphabetical wall.

The permission names themselves live twice: as rows in the database, and as constants in a static class mirroring the same structure.

public static class Permissions
{
    public static class Attendance
    {
        public const string ViewAttendanceMarks = "Attendance.ViewAttendanceMarks";
        public const string EditAttendanceMarks = "Attendance.EditAttendanceMarks";
        public const string UseRestrictedCodes  = "Attendance.UseRestrictedCodes";
    }
}

That duplication earns its keep. The database rows are what a school’s roles point at; the constants are what the code points at, which means a call site is checked by the compiler instead of by a typo-shaped runtime bug at four o’clock on results day. MyPortal currently has 109 of them across 21 areas.

The fourth field, added later: who a permission is for

Those four fields shipped first, and they were enough to be wrong in a particular way.

MyPortal has three kinds of user — staff, students and parents — each with their own portal. Permissions, though, were a single flat pool. Nothing in the model stopped an administrator from creating a Student role and granting it SystemAdmin.EditUsers. Nobody was going to do that on purpose. But “nobody would do that on purpose” is not a security control, and a permission model that can express an absurdity will eventually be made to express one.

There are two places to stop it. You can filter the list the role editor shows, or you can make it an invariant the server enforces. The first is ergonomics: an administrator building a parent role should not have to scroll past eighty irrelevant staff permissions to find the two that apply. The second is security. They look like the same rule, and it is tempting to implement it once — in the UI, where you can see it working. That instinct is exactly backwards. The editor is a convenience; the API is the boundary.

So the permission gained a UserType, and so did the role:

public class ApplicationRole : IdentityRole<Guid>
{
    public string? Description { get; set; }
    public bool IsSystem { get; set; }
    public UserType UserType { get; set; }
    public bool IsDefault { get; set; }
}

The rule is then one line, checked on every role update before anything is written:

var mismatch = permissionIds.Any(id =>
    byId.TryGetValue(id, out var p) && p.UserType != role.UserType);

A mismatch throws, with a message that tells the administrator which portal the offending permission actually belongs to. Portal.ViewOwnTimetable can be granted to a student role and Portal.ViewChildTimetable to a parent role; neither can be granted anything belonging to staff, and no amount of crafting the request by hand will change that. The editor still filters the list, because scrolling past eighty irrelevant rows is miserable — but it filters for comfort, not for safety.

The same method has a sibling I’d recommend to anyone building this. Matching the audience stops a role being given permissions that make no sense for it; it does nothing about an administrator quietly granting themselves more than they started with. So changes to protected permissions — anything under System. — are checked against what the person making the change already holds:

throw new ForbiddenException(
    $"You cannot change administrative permissions you do not hold yourself: ...");

Between them, those two guards turn a whole category of misconfiguration from something you have to remember not to do into something the system will not represent. That is the version of a security control I trust: not a warning in the documentation, not a disabled checkbox, but a request that comes back refused.

Teaching the framework to ask

What I wanted at the call site was this, and nothing more:

[Permission(PermissionMode.RequireAny, Permissions.Attendance.EditAttendanceMarks)]
public async Task<IActionResult> SaveRegister(...)

ASP.NET Core’s authorisation pipeline is policy-based, which sounds promising until you look at how policies are normally created. You register them at startup, by name, one at a time:

options.AddPolicy("CanEditAttendanceMarks", p => p.RequireClaim(...));

With 109 permissions that is 109 lines of registration to keep in lockstep with 109 constants and 109 database rows. Worse, I didn’t only want to require a single permission. Some endpoints should accept any of a set; a few should demand all of one. Those are combinations, and you cannot enumerate combinations at startup — there are too many, and you don’t know which ones the codebase will eventually want.

The policy name is just a string you control

The way out is a detail of the framework that is easy to miss: the thing [Authorize] carries is a policy name, and a policy name is an arbitrary string. Nothing says it has to be a label registered in advance. It can be the requirement itself, encoded.

So PermissionAttribute derives from AuthorizeAttribute and writes its arguments into the name:

public PermissionAttribute(PermissionMode mode, params string[] permissions)
{
    Mode = mode;
    Permissions = permissions;
    Policy = $"{Prefix}{mode}:{string.Join(',', Permissions)}";
}

An attribute now produces a policy name like perm:RequireAny:Attendance.EditAttendanceMarks, which carries everything the authorisation system needs to know.

The other half is IAuthorizationPolicyProvider. ASP.NET asks it for a policy by name, and the default implementation looks the name up in the registry it built at startup. Mine parses it instead:

if (policyName.StartsWith(PermPrefix, StringComparison.OrdinalIgnoreCase))
{
    var payload = policyName[PermPrefix.Length..];
    var parts = payload.Split(':', 2);
    // ...parse mode and the comma-separated permissions...

    var policy = new AuthorizationPolicyBuilder()
        .RequireAuthenticatedUser()
        .AddRequirements(new PermissionRequirement(mode, perms))
        .Build();

    _cache.TryAdd(policyName, policy);
    return Task.FromResult<AuthorizationPolicy?>(policy);
}

return _fallback.GetPolicyAsync(policyName);

Policies are now built the first time each distinct name is seen, cached in a ConcurrentDictionary, and anything that isn’t mine falls through to the default provider so ordinary [Authorize] attributes keep working untouched. The registration ceremony disappears entirely: adding a permission is a constant and a database row, and the call site works immediately. There are 205 of those call sites now, and not one line of policy registration behind them.

The same mechanism took a second prefix later on. ut: policies gate an endpoint by user type, parsed by the same provider into a different requirement — once the name is yours, the vocabulary is extensible.

Actually enforcing it is then the boring part, which is how it should be: a PermissionRequirement holding the mode and the names, and a handler that asks the permission service about each one and applies Any or All. The one detail worth calling out is what happens when the list is empty — the handler returns without succeeding, so an attribute that somehow ends up with no permissions denies rather than admits. Authorisation code should fail closed, and the cheapest place to guarantee that is the branch nobody expects to take.

The claims I deliberately didn’t issue

The obvious way to make permission checks fast is to put them in the token. Resolve the user’s permissions once at sign-in, write them in as claims, and every subsequent check is a lookup against a principal already in memory. No database, no latency.

I didn’t do that, and it’s the decision in this system I’d defend hardest.

A token is a snapshot. The moment you write permissions into one, you’ve promised the holder those permissions until it expires. Revoke someone’s access to safeguarding records and they keep it for the rest of the token’s lifetime — and “they’ll lose it within the hour” is not an answer a school wants to hear about a member of staff they’ve just moved off child protection. The same goes for disabling an account outright.

So MyPortal’s tokens carry almost nothing: a user type, a security stamp, and a person id.

public const string UserType = "ut";
public const string UserSecurityStamp = "usrver";
public const string PersonId = "pid";

Permissions are resolved per request, from the database of record, every time. Revocation takes effect on the next request. The account-disabled check rides along the same path, so a disabled user’s permission set collapses to empty immediately rather than when their session happens to lapse.

Cache the roles, not the users

Resolving per request is only defensible if it’s cheap, and a SQL round trip on every authorised call is not cheap.

The thing that makes it affordable is noticing where the slow-moving data actually is. Permissions hang off roles. Roles are few — a school might have fifteen — and their contents change when an administrator edits them, which is to say almost never. Users are many, and their role membership changes far more often than the roles themselves do. So the cache is keyed by role, not by user, and a user’s permission set is assembled as the union of their roles’ cached sets.

The entries are held with a ten-minute sliding expiry and a one-hour absolute cap, and each role gets its own SemaphoreSlim so that a popular role expiring doesn’t send fifty concurrent requests to the database for the same answer. The first one through the gate queries; the rest wait, re-check, and find it already there. A post-eviction callback drops the semaphore along with the entry, so the lock dictionary doesn’t quietly accumulate one lock per role that ever existed.

Invalidation is then a single line in the right place: when a role’s permissions are edited, that role’s cache entry is dropped. Every user in the role picks up the change on their next request, no matter how many of them there are and without a single token being reissued.

What I took away from it

  • The framework is more open than the documentation implies. The policy name being a plain string is the whole trick here, and IAuthorizationPolicyProvider is a supported extension point that turns a registration problem into a parsing problem.
  • Roles are for people, permissions are for code. Keeping them separate is what lets a school invent a Cover Supervisor role I’ve never heard of, out of permissions I defined a year earlier.
  • Cache what changes slowly, not what there is a lot of. Caching per role instead of per user is the difference between a cache that works and one that is permanently cold.
  • Convenience in authorisation code has a half-life. Permissions in the token would have been faster and simpler, right up until the first time somebody needed access revoked now.

Identity gave me authentication, a user store, and a role model to build on, and that’s a lot of load-bearing work I didn’t have to write. What it didn’t give me was a way to say that this person may edit attendance marks but not use restricted codes, and that a school could decide that for itself. Three hundred lines of authorisation plumbing later, safeguarding records in MyPortal are visible to the handful of people a school has deliberately granted them to — and to nobody else, from the next request onwards.