## The Real-World Problem
At Preview Technologies, I built an internal Office Management System. The system had four user types — Developers, Team Leads, Managers, and Super Admins — each needing a completely different experience of the same application.
- Developer: sees their own tasks only
- Team Lead: sees team tasks, can mark completed tasks
- Manager: sees all tasks, generates reports
- Super Admin: manages users, roles, and system configuration
This is a classic RBAC problem. Here's how I solved it cleanly.
## Database Design First
Get this right before writing a single line of C#.
```sql
CREATE TABLE Roles (
RoleId INT PRIMARY KEY IDENTITY,
RoleName VARCHAR(50) NOT NULL,
Description VARCHAR(200)
);
CREATE TABLE Users (
UserId INT PRIMARY KEY IDENTITY,
Name VARCHAR(100) NOT NULL,
Email VARCHAR(200) NOT NULL UNIQUE,
PasswordHash VARCHAR(500) NOT NULL,
RoleId INT NOT NULL FOREIGN KEY REFERENCES Roles(RoleId),
IsActive BIT DEFAULT 1,
CreatedAt DATETIME DEFAULT GETDATE()
);
-- Insert roles
INSERT INTO Roles VALUES ('Developer', 'Can view and update own tasks');
INSERT INTO Roles VALUES ('TeamLead', 'Can review and approve team tasks');
INSERT INTO Roles VALUES ('Manager', 'Full task visibility and reporting');
INSERT INTO Roles VALUES ('SuperAdmin', 'Full system access');
```
## Claims-Based Identity Setup
```csharp
// On successful login, create claims
public async Task<IActionResult> Login(LoginModel model)
{
var user = await _userService.ValidateUser(model.Email, model.Password);
if (user == null) return View(model);
var claims = new List<Claim>
{
new Claim(ClaimTypes.NameIdentifier, user.UserId.ToString()),
new Claim(ClaimTypes.Name, user.Name),
new Claim(ClaimTypes.Email, user.Email),
new Claim(ClaimTypes.Role, user.RoleName)
};
var identity = new ClaimsIdentity(claims, CookieAuthenticationDefaults.AuthenticationScheme);
var principal = new ClaimsPrincipal(identity);
await HttpContext.SignInAsync(
CookieAuthenticationDefaults.AuthenticationScheme,
principal,
new AuthenticationProperties { IsPersistent = model.RememberMe }
);
return RedirectToAction("Dashboard");
}
```
## Smart Dashboard Routing
Instead of one dashboard that shows/hides things, route users to role-specific dashboards:
```csharp
public IActionResult Dashboard()
{
var role = User.FindFirst(ClaimTypes.Role)?.Value;
return role switch
{
"Developer" => RedirectToAction("Index", "DeveloperDashboard"),
"TeamLead" => RedirectToAction("Index", "TeamLeadDashboard"),
"Manager" => RedirectToAction("Index", "ManagerDashboard"),
"SuperAdmin" => RedirectToAction("Index", "AdminDashboard"),
_ => RedirectToAction("AccessDenied")
};
}
```
## Custom Authorization Attribute
```csharp
public class RequireRoleAttribute : ActionFilterAttribute
{
private readonly string[] _allowedRoles;
public RequireRoleAttribute(params string[] roles)
{
_allowedRoles = roles;
}
public override void OnActionExecuting(ActionExecutingContext context)
{
var userRole = context.HttpContext.User.FindFirst(ClaimTypes.Role)?.Value;
if (string.IsNullOrEmpty(userRole) || !_allowedRoles.Contains(userRole))
{
context.Result = new RedirectToActionResult("AccessDenied", "Account", null);
return;
}
base.OnActionExecuting(context);
}
}
```
**Usage:**
```csharp
[RequireRole("Manager", "SuperAdmin")]
public IActionResult Reports() => View();
[RequireRole("SuperAdmin")]
public IActionResult UserManagement() => View();
```
## View-Level Role Checks
```html
@if (User.IsInRole("SuperAdmin"))
{
<li><a asp-controller="Admin" asp-action="Users">User Management</a></li>
}
@if (User.IsInRole("Manager") || User.IsInRole("SuperAdmin"))
{
<li><a asp-controller="Reports" asp-action="Index">Reports</a></li>
}
```
## Data-Level Filtering
Role-based access isn't just about what pages users can see — it's about what data they can see.
```csharp
public async Task<IEnumerable<Task>> GetTasksForUser(int userId, string role)
{
return role switch
{
"Developer" => await _repo.GetTasksByUser(userId),
"TeamLead" => await _repo.GetTasksByTeam(userId),
"Manager" or "SuperAdmin" => await _repo.GetAllTasks(),
_ => Enumerable.Empty<Task>()
};
}
```
## Lessons Learned
1. **Design roles before writing code** — changing role structure mid-project is painful
2. **Don't just hide UI elements** — always enforce at the controller/service level too
3. **Log access denials** — patterns in denied access often reveal security probes
4. **Test each role independently** — create test accounts for each role and test thoroughly
## Conclusion
Clean RBAC is about three things: the right database structure, claims-based identity, and consistent enforcement at every layer — controller, service, and data. Get all three right and you have a system that's secure, maintainable, and easy to extend as requirements change.