Implementing Role-Based Access Control (RBAC) with Claims Transformation in .NET Core

Amr elshaer
8 min readDec 28, 2024

--

Introduction

Role-Based Access Control (RBAC) is a crucial aspect of modern application security. This article demonstrates how to implement a comprehensive RBAC system in .NET Core, combining role management, claims transformation, and dynamic permission handling.

Key features covered:

  • Role and Permission management with Entity Framework Core
  • Claims transformation for external authentication providers
  • Custom authorization handlers
  • Dynamic policy generation
  • Integration with Keycloak for authentication

System Components

The system consists of several key components:

  1. Data Models
  • Roles
  • Permissions
  • Users
  • Mapping tables (RolePermission, UserRole)

2. Authorization Components

  • Claims Transformation
  • Permission Handler
  • Dynamic Policy Provider

3. Authentication Integration

  • JWT Bearer configuration
  • Keycloak integration

Roles

public class Role
{
public required int Id { get; init; }
public required string Name { get; init; }
public ICollection<Permission> Permissions { get; set; } = default!;
public ICollection<User> Users { get; set; }= default!;
}

public class RoleEnum(int value, string name) : SmartEnum<RoleEnum>(name, value)
{
public static readonly RoleEnum Admin = new (1,"Admin");
public static readonly RoleEnum User = new (2, "User");
}
public class RoleConfiguration:IEntityTypeConfiguration<Role>
{
public void Configure(EntityTypeBuilder<Role> builder)
{
builder.ToTable("Roles");
builder.Property(r => r.Name).HasMaxLength(100).IsRequired();
builder.HasMany<Permission>(r => r.Permissions)
.WithMany()
.UsingEntity<RolePermission>();
builder.HasMany<User>(r => r.Users)
.WithMany(u => u.Roles)
.UsingEntity<UserRole>();
builder.HasData(RoleEnum.List.Select(r => new Role
{
Id = r,
Name = r.Name
}));
}
}

Permissions

public class Permission
{
public required int Id { get; init; }
public required string Name { get; init; }
public ICollection<Role>? Roles { get; set; }
}
public class PermissionEnum(string name, int value) : SmartEnum<PermissionEnum>(name, value)
{
public static readonly PermissionEnum ReadUser = new ("ReadUser", 1);
public static readonly PermissionEnum CreateUser = new ("CreateUser", 2);
public static readonly PermissionEnum ReadWeathers = new ("ReadWeathers", 3);
}

public class PermissionConfiguration:IEntityTypeConfiguration<Permission>
{
public void Configure(EntityTypeBuilder<Permission> builder)
{
builder.ToTable("Permissions");
builder.Property(r => r.Name).HasMaxLength(100).IsRequired();
builder.HasMany<Role>(r => r.Roles)
.WithMany()
.UsingEntity<RolePermission>();
builder.HasData(PermissionEnum.List.Select(r => new Permission
{
Id = r,
Name = r.Name
}));
}
}

RolePermission

public class RolePermission
{
public int RoleId { get; init; }
public int PermissionId { get; init;}
}

public class RolePermissionConfiguration:IEntityTypeConfiguration<RolePermission>
{
public void Configure(EntityTypeBuilder<RolePermission> builder)
{
builder.ToTable("RolePermission");
builder.HasKey(rp => new { rp.RoleId, rp.PermissionId });
builder.HasData(Create(RoleEnum.Admin, PermissionEnum.ReadUser),
Create(RoleEnum.Admin, PermissionEnum.CreateUser),
Create(RoleEnum.User, PermissionEnum.ReadUser),
Create(RoleEnum.Admin, PermissionEnum.ReadWeathers));
}

private static RolePermission Create(RoleEnum role,PermissionEnum permission)
{
return new RolePermission
{
PermissionId = permission,
RoleId = role
};
}
}

Users

public class User
{
public required int Id { get; init; }
public required string Name { get; init; }
public ICollection<Role> Roles { get; set; }
public string? IdentityUserId { get; set; }
}
public class UserConfiguration:IEntityTypeConfiguration<User>
{
public void Configure(EntityTypeBuilder<User> builder)
{
builder.ToTable("Users");
builder.Property(u => u.Name).HasMaxLength(255);
builder.HasMany(u => u.Roles)
.WithMany(r=>r.Users)
.UsingEntity<UserRole>();
builder.HasData(new User()
{
Id = 1,
Name = "Admin user",
});
}
}

UserRole

public class UserRole
{
public int UserId { get; set; }
public int RoleId { get; set; }
}
public class UserRoleConfiguration:IEntityTypeConfiguration<UserRole>
{
public void Configure(EntityTypeBuilder<UserRole> builder)
{
builder.ToTable("UserRoles");
builder.HasKey(ur => new { ur.UserId, ur.RoleId });
builder.HasData(new UserRole()
{
UserId = 1,
RoleId = RoleEnum.Admin
});
}
}

ApplicationDbContext

public class ApplicationDbContext(DbContextOptions<ApplicationDbContext> options) : DbContext(options)
{

public DbSet<User> Users { get; set; }

protected override void OnModelCreating(ModelBuilder modelBuilder)
{
modelBuilder.ApplyConfigurationsFromAssembly(Assembly.GetExecutingAssembly());
base.OnModelCreating(modelBuilder);
}
}


private static IServiceCollection AddApplicationDbContext(this IServiceCollection serviceProvider,IConfiguration configuration)
{
return serviceProvider.AddDbContext<ApplicationDbContext>(options =>
options.UseSqlServer(configuration.GetConnectionString("DefaultConnection")));

}

Configuring authentication strategy

Authentication strategies typically support a variety of configurations that are loaded via options. Minimal apps support loading options from configuration for the following authentication strategies:

The system uses JWT Bearer authentication with Keycloak as the authentication provider:

 private static  IServiceCollection AddAuthentication(this  IServiceCollection serviceProvider)
{
serviceProvider
.AddAuthentication(x =>
{
x.DefaultAuthenticateScheme = JwtBearerDefaults.AuthenticationScheme;
x.DefaultChallengeScheme = JwtBearerDefaults.AuthenticationScheme;
x.DefaultSignInScheme = JwtBearerDefaults.AuthenticationScheme;

})
.AddJwtBearer(
JwtBearerDefaults.AuthenticationScheme,
options =>
{
var baseAddress = "http://localhost:8080";
var realmName = "AuthorizationTest";
options.MetadataAddress = $"{baseAddress}/realms/{{realm_name}}/.well-known/openid-configuration";
options.RequireHttpsMetadata = false; // only for dev
options.SaveToken = true;

options.TokenValidationParameters = new TokenValidationParameters
{

ValidateAudience = false,
ValidateLifetime = true,

ValidateIssuerSigningKey = true,
IssuerSigningKeyResolver = (token, securityToken, kid, validationParameters) =>
{

var keyClient = new HttpClient();
var response = keyClient.GetStringAsync($"{baseAddress}/realms/{realmName}/protocol/openid-connect/certs").Result;

var keys = new JsonWebKeySet(response);
return keys.GetSigningKeys();
},
ValidIssuer = $"{baseAddress}/realms/{realmName}",
ValidateIssuer = true,
ClockSkew = TimeSpan.FromMinutes(5)
};
options.Authority = baseAddress;
}
);

return serviceProvider;

}

Claims Transformation

Now I will make a custom claim transformation to add permissions in our claim to use in the Authorization Handler notice I used claims transformation because I used an external provider responsible for authenticating the user. don’t want to add in jwt token I don’t want to make jwt token size big and if the access token live time is long is not secure if revoked the role from the user.

The claims transformation logic in .NET, implemented via IClaimsTransformation, is executed after authentication but before authorization.

After the authentication middleware has validated the token or user credentials and created the ClaimsPrincipal.Before the request is authorized or the controller/action is executed.

Execution Flow

1- Authentication Middleware: The middleware (e.g., JwtBearer, OpenIdConnect) authenticates the request, validates the token, and creates a ClaimsPrincipal.

2- Claims Transformation: The IClaimsTransformation.TransformAsync() method is called for the ClaimsPrincipal returned from the authentication middleware. This happens for each request.

3- Authorization Middleware: Authorization checks (e.g., policies or roles) use the transformed ClaimsPrincipal.

4- Controller/Action: By the time the controller action is executed, the transformed claims are available via User.Claims.

Important Notes:

Execution Per Request: The IClaimsTransformation implementation is called for every request where a ClaimsPrincipal is created. If caching is desired to avoid recomputing claims, you’ll need to handle it in your implementation.

public class CustomClaimsTransformation(IServiceProvider serviceProvider):IClaimsTransformation
{
public async Task<ClaimsPrincipal> TransformAsync(ClaimsPrincipal principal)
{
if (principal.HasClaim(c => c.Type == CustomClaims.Permission))
{
return principal;
}

using IServiceScope scope = serviceProvider.CreateScope();

var sender = scope.ServiceProvider.GetRequiredService<ISender>();
var identityId = principal.FindFirstValue(ClaimTypes.NameIdentifier)!;

var result = await sender.Send(
new GetUserPermissionsQuery(identityId));

if (principal.Identity is not ClaimsIdentity identity)
{
return principal;
}

foreach (var permission in result.Permissions)
{
identity.AddClaim(
new Claim(CustomClaims.Permission, permission));
}

return principal;
}
}

Generate Query to get current user permissions

public record GetUserPermissionsQuery(string IdentityUserId) : IRequest<GetUserPermissionsResponse>;
public record GetUserPermissionsResponse(HashSet<string> Permissions);
public class GetUserPermissionsQueryHandler(ApplicationDbContext context) : IRequestHandler<GetUserPermissionsQuery,GetUserPermissionsResponse>
{
public async Task<GetUserPermissionsResponse> Handle(GetUserPermissionsQuery request, CancellationToken cancellationToken)
{
var user = await context.Users.Include(u => u.Roles)
.ThenInclude(r=>r.Permissions)
.FirstOrDefaultAsync(u => u.IdentityUserId == request.IdentityUserId,cancellationToken);
if (user == null)
{
throw new InvalidOperationException($"User with id '{request.IdentityUserId}' not found.");
}

var permissions = user.Roles.SelectMany(r => r.Permissions).Select(p => p.Name)
.ToHashSet();
return new GetUserPermissionsResponse(permissions);
}
}

After we add our claims transformation now we will make Permission Authorization Handler this will be responsible for checking the permission of the user in the endpoint, first creating permission requirement

public class PermissionRequirement(string permission) : IAuthorizationRequirement
{
public string Permission { get; } = permission;
}
public class PermissionAuthorizationHandler : AuthorizationHandler<PermissionRequirement>
{
protected override Task HandleRequirementAsync(
AuthorizationHandlerContext context,
PermissionRequirement requirement)
{
var permissions = context.User.GetPermissions();
if (permissions.Contains(requirement.Permission))
{
context.Succeed(requirement);
}

return Task.CompletedTask;
}
}

Add policy for permission but instead of adding policy for each permission you add like this

builder.Services.AddAuthorization(options =>
{

options.AddPolicy("CanReadWeathers", policy =>
{
policy.Requirements.Add(new PermissionRequirement("ReadWeather")); // Replace with a dynamic value later
});
});

We will generate a dynamic policy provider for the permission in an endpoint like this

public class PermissionPolicyProvider(IOptions<AuthorizationOptions> options)
: DefaultAuthorizationPolicyProvider(options)
{
public override async Task<AuthorizationPolicy?> GetPolicyAsync(string policyName)
{
var policy= await base.GetPolicyAsync(policyName);
if (policy is not null)
{
return policy;
}

return new AuthorizationPolicyBuilder()
.AddRequirements(new PermissionRequirement(policyName))
.Build();
}
}

Now we will register all dependencies

public static class DependencyInjection
{
public static IServiceCollection AddServices(this IServiceCollection serviceProvider,IConfiguration configuration)
{
return serviceProvider.AddAuthentication()
.AddSystemAuthorization()
.AddApplicationDbContext(configuration)
.AddEndpoints(Assembly.GetExecutingAssembly());
}
private static IServiceCollection AddEndpoints(this IServiceCollection serviceProvider,
Assembly assembly)
{
var endpoints=assembly.DefinedTypes.Where(type=>type is {IsAbstract:false,IsInterface:false} &&
type.IsAssignableTo(typeof(IEndpoint)))
.Select(type=>ServiceDescriptor.Transient(typeof(IEndpoint),type)).ToArray();
serviceProvider.TryAddEnumerable(endpoints);
return serviceProvider;
}

public static IApplicationBuilder MapEndpoints(this WebApplication app,
RouteGroupBuilder? routeGroupBuilder=null)
{
IEnumerable<IEndpoint> endpoints = app.Services.GetRequiredService<IEnumerable<IEndpoint>>();
IEndpointRouteBuilder builder = routeGroupBuilder is null ? app : routeGroupBuilder;
foreach (var endpoint in endpoints)
{
endpoint.MapEndpoint(builder);
}

return app;
}
private static IServiceCollection AddApplicationDbContext(this IServiceCollection serviceProvider,IConfiguration configuration)
{
return serviceProvider.AddDbContext<ApplicationDbContext>(options =>
options.UseSqlServer(configuration.GetConnectionString("DefaultConnection")));

}
private static IServiceCollection AddSystemAuthorization(this IServiceCollection serviceProvider)
{
serviceProvider.AddAuthorization();
serviceProvider.AddScoped<IClaimsTransformation, CustomClaimsTransformation>();
serviceProvider.AddSingleton<IAuthorizationHandler, PermissionAuthorizationHandler>();
serviceProvider.AddSingleton<IAuthorizationPolicyProvider, PermissionPolicyProvider>();

return serviceProvider;
}

private static IServiceCollection AddAuthentication(this IServiceCollection serviceProvider)
{
serviceProvider
.AddAuthentication(x =>
{
x.DefaultAuthenticateScheme = JwtBearerDefaults.AuthenticationScheme;
x.DefaultChallengeScheme = JwtBearerDefaults.AuthenticationScheme;
x.DefaultSignInScheme = JwtBearerDefaults.AuthenticationScheme;

})
.AddJwtBearer(
JwtBearerDefaults.AuthenticationScheme,
options =>
{
var baseAddress = "http://localhost:8080";
var realmName = "AuthorizationTest";
options.MetadataAddress = $"{baseAddress}/realms/{{realm_name}}/.well-known/openid-configuration";
options.RequireHttpsMetadata = false; // only for dev
options.SaveToken = true;

options.TokenValidationParameters = new TokenValidationParameters
{

ValidateAudience = false,
ValidateLifetime = true,

ValidateIssuerSigningKey = true,
IssuerSigningKeyResolver = (token, securityToken, kid, validationParameters) =>
{

var keyClient = new HttpClient();
var response = keyClient.GetStringAsync($"{baseAddress}/realms/{realmName}/protocol/openid-connect/certs").Result;

var keys = new JsonWebKeySet(response);
return keys.GetSigningKeys();
},
ValidIssuer = $"{baseAddress}/realms/{realmName}",
ValidateIssuer = true,
ClockSkew = TimeSpan.FromMinutes(5)
};
options.Authority = baseAddress;
}
);

return serviceProvider;

}

}

and generate a weather endpoint to test roles and permissions , I generated two one without authorization and another with

public class GetWeather:IEndpoint
{
public void MapEndpoint(IEndpointRouteBuilder app)
{

var summaries = new[]
{
"Freezing", "Bracing", "Chilly", "Cool", "Mild", "Warm", "Balmy", "Hot", "Sweltering", "Scorching"
};

app.MapGet("/weatherforecast", () =>
{
var forecast = Enumerable.Range(1, 5).Select(index =>
new WeatherForecast
(
DateOnly.FromDateTime(DateTime.Now.AddDays(index)),
Random.Shared.Next(-20, 55),
summaries[Random.Shared.Next(summaries.Length)]
))
.ToArray();
return forecast;
})
.WithName("GetWeatherForecastNoAuthorized")
.WithTags(Tags.Weathers)
.MapToApiVersion(1);

app.MapGet("/weatherforecast", () =>
{
var forecast = Enumerable.Range(1, 5).Select(index =>
new WeatherForecast
(
DateOnly.FromDateTime(DateTime.Now.AddDays(index)),
Random.Shared.Next(-20, 55),
summaries[Random.Shared.Next(summaries.Length)]
))
.ToArray();
return forecast;
})
.WithName("GetWeatherForecastAuthorized")
.WithTags(Tags.Weathers)
.RequireAuthorization(PermissionEnum.ReadWeathers.Name)
.MapToApiVersion(2);

}
record WeatherForecast(DateOnly Date, int TemperatureC, string? Summary)
{
public int TemperatureF => 32 + (int)(TemperatureC / 0.5556);
}
}

Now I use keycloak UI to generate Realm and user instead of create API to create user and login user I also use keycloak for quick demonstration of the execution flow.

First, create a realm with the name we configure in authentication then create a user

from the client tap, I can go to the link to log in and get an access token , don’t forget to add identityId of a user in the user table in the database you can get the user id in keycloak in the user details.

you can inspect and get token from the network

you can get an access token for the user

add token in Authorize I configure swagger and configure minimal API for using versioning v1 and v2 and IEndpoint you can see the project on Github repo.

Feel Free to add comments for ask about anything 😊.

Thanks For Milan Jovanović

https://www.milanjovanovic.tech/blog/master-claims-transformation-for-flexible-aspnetcore-authorization

--

--

Amr elshaer
Amr elshaer

Written by Amr elshaer

Software engineer | .Net ,C# ,Angular, Javascript

No responses yet