Skip to main content
  1. Blog/

Handling Authorization and Role-Based Access Control (RBAC)

·19 mins
Nitin Kumar Singh
Author
Nitin Kumar Singh
I build enterprise AI solutions and cloud-native systems. I write about architecture patterns, AI agents, Azure, and modern development practices — with full source code.
Table of Contents

Introduction
#

Role-Based Access Control (RBAC) is a critical component of secure application design that restricts access to resources based on user roles and permissions. This article explores how the Contact Management Application implements a flexible and maintainable RBAC system that covers both the backend API and frontend Angular application, integrating with JWT authentication to secure endpoints and UI elements while maintaining the separation of concerns that Clean Architecture demands.

This article provides a detailed, step-by-step guide to implementing RBAC in the Contact Management Application, covering:

  1. Database Entities and relationships for RBAC
  2. Dynamic Policy Provider for runtime policy creation (not static registration!)
  3. Custom Authorization Attributes for securing API endpoints
  4. Custom Permission Handler to validate user access
  5. Frontend Permission System with Angular guards, directives, and services
  6. Admin UI for managing role-permission mappings
  7. Complete Authentication Flow from login to permission enforcement


System Architecture Overview
#

Before diving into implementation details, let’s understand the overall RBAC architecture with this diagram:

flowchart TB subgraph Database["Database Layer"] Users[(Users)] Roles[(Roles)] UserRoles[(UserRoles)] Pages[(Pages)] Operations[(Operations)] Permissions[(Permissions)] RolePermissions[(RolePermissions)] end subgraph Backend["Backend API (.NET)"] AuthController[Users Controller] PermissionHandler[Permission Handler] PolicyProvider[Custom Policy Provider] AuthAttribute["[AuthorizePermission]"] end subgraph Frontend["Frontend (Angular)"] LoginComponent[Login Component] PermissionService[Permission Service] PermissionGuard[Permission Guard] PermissionDirective["*hasPermission Directive"] AuthState[Auth State Service] end Users --> UserRoles Roles --> UserRoles Roles --> RolePermissions Pages --> Permissions Operations --> Permissions Permissions --> RolePermissions AuthController --> |"JWT + RolePermissions"| LoginComponent LoginComponent --> AuthState AuthState --> PermissionService PermissionService --> PermissionGuard PermissionService --> PermissionDirective AuthAttribute --> PolicyProvider PolicyProvider --> PermissionHandler PermissionHandler --> |"Validate Against DB"| RolePermissions

Part of the Series
#

This article is part of a series covering the Contact Management Application, a sample project demonstrating Clean Architecture principles. Other articles include:

  1. Clean Architecture: Introduction to the Project Structure{:target="_blank" rel=“noopener noreferrer”}

  2. Clean Architecture: Implementing AutoMapper for DTO Mapping and Audit Logging{:target="_blank" rel=“noopener noreferrer”}

  3. Clean Architecture: Validating Inputs with FluentValidation{:target="_blank" rel=“noopener noreferrer”}

  4. Clean Architecture: Dependency Injection Setup Across Layers{:target="_blank" rel=“noopener noreferrer”}

  5. Clean Architecture: Handling Authorization and Role-Based Access Control (RBAC) (You are here)

  6. Clean Architecture: Implementing Activity Logging with Custom Attributes{:target="_blank" rel=“noopener noreferrer”}

  7. Clean Architecture: Unit of Work Pattern and Its Role in Managing Transactions{:target="_blank" rel=“noopener noreferrer”}

  8. Clean Architecture: Using Dapper for Data Access and Repository Pattern{:target="_blank" rel=“noopener noreferrer”}

  9. Clean Architecture: Best Practices for Creating and Using DTOs in the API{:target="_blank" rel=“noopener noreferrer”}

  10. Clean Architecture: Error Handling and Exception Management in the API{:target="_blank" rel=“noopener noreferrer”}

  11. Clean Architecture: Dockerizing the .NET Core API, Angular and MS SQL Server{:target="_blank" rel=“noopener noreferrer”}

  12. Clean Architecture: Seeding Initial Data Using Docker Compose and SQL Scripts{:target="_blank" rel=“noopener noreferrer”}


1. RBAC Database Design
#

1.1 Entity Relationship Diagram
#

The RBAC system uses six interconnected entities. Here’s how they relate to each other:

erDiagram User ||--o{ UserRole : "has" Role ||--o{ UserRole : "assigned to" Role ||--o{ RolePermission : "has" Permission ||--o{ RolePermission : "granted via" Page ||--o{ Permission : "has" Operation ||--o{ Permission : "has" User { uuid Id PK string FirstName string LastName string Username string Email string Password } Role { uuid Id PK string Name string Description } UserRole { uuid Id PK uuid UserId FK uuid RoleId FK } Page { uuid Id PK string Name string Url int PageOrder } Operation { uuid Id PK string Name string Description } Permission { uuid Id PK uuid PageId FK uuid OperationId FK string Description } RolePermission { uuid Id PK uuid RoleId FK uuid PermissionId FK }

1.2 Core Entities
#

Let’s examine each entity and its role in the RBAC system:

Page Entity
#

Represents a resource, feature, or section in the application (e.g., Contacts, Users, Roles). Each page represents something that can be protected with permissions.

// Location: Contact.Domain/Entities/Page.cs
public class Page : BaseEntity
{
    public required string Name { get; set; }
    public required string Url { get; set; }
    public int PageOrder { get; set; }
}

Example Data:

IdNameUrlPageOrder
guid-1Contacts/contact1
guid-2Users/user2
guid-3Roles/role3

Operation Entity
#

Defines the actions that can be performed on any page. These are reusable across all pages.

// Location: Contact.Domain/Entities/Operation.cs
public class Operation : BaseEntity
{
    public required string Name { get; set; }
    public required string Description { get; set; }
}

Example Data:

IdNameDescription
op-1ReadAllows reading/viewing data
op-2CreateAllows creating new records
op-3UpdateAllows modifying existing records
op-4DeleteAllows removing records

Permission Entity
#

Connects a Page to an Operation, creating specific permissions like “Contacts.Read” or “Users.Delete”.

// Location: Contact.Domain/Entities/Permission.cs
public class Permission : BaseEntity
{
    public Guid PageId { get; set; }
    public Guid OperationId { get; set; }
    public required string Description { get; set; }
}

Example Data:

IdPageIdOperationIdDescription
perm-1ContactsReadCan view contacts
perm-2ContactsCreateCan create contacts
perm-3ContactsUpdateCan update contacts
perm-4ContactsDeleteCan delete contacts

Role Entity
#

Defines user roles such as Admin, Manager, or User. Each role is a collection of permissions.

// Location: Contact.Domain/Entities/Role.cs
public class Role : BaseEntity
{
    public required string Name { get; set; }
    public required string Description { get; set; }
}

Example Data:

IdNameDescription
role-1AdminFull system access
role-2ManagerCan manage contacts and users
role-3UserRead-only access

RolePermission Entity
#

Maps roles to their permissions. This is the key table that determines what each role can do.

// Location: Contact.Domain/Entities/RolePermission.cs
public class RolePermission : BaseEntity
{
    public Guid RoleId { get; set; }
    public Guid PermissionId { get; set; }
}

Example Data:

IdRoleIdPermissionId(Result)
rp-1AdminContacts.ReadAdmin can read contacts
rp-2AdminContacts.CreateAdmin can create contacts
rp-3AdminContacts.UpdateAdmin can update contacts
rp-4AdminContacts.DeleteAdmin can delete contacts
rp-5UserContacts.ReadUser can only read contacts

UserRole Entity
#

Assigns roles to users. A user can have multiple roles.

// Location: Contact.Domain/Entities/UserRole.cs
public class UserRole : BaseEntity
{
    public Guid UserId { get; set; }
    public Guid RoleId { get; set; }
}

Example Data:

IdUserIdRoleId
ur-1alice-idAdmin
ur-2bob-idUser
ur-3bob-idManager

2. Backend Authorization Infrastructure
#

2.1 How Permission Checking Works
#

When an API endpoint is called, here’s the complete flow:

sequenceDiagram participant Client participant API as API Endpoint participant Attr as [AuthorizePermission] participant Provider as CustomPolicyProvider participant Handler as PermissionHandler participant DB as Database Client->>API: HTTP Request with JWT API->>Attr: Check [AuthorizePermission("Contacts.Create")] Attr->>Provider: Get policy "Contacts.CreatePolicy" Provider->>Provider: Create policy with PermissionRequirement Provider->>Handler: Evaluate requirement Handler->>Handler: Extract user roles from JWT claims Handler->>DB: Query RolePermission mappings DB-->>Handler: Return role permissions Handler->>Handler: Check if user's role has permission alt Permission Granted Handler-->>API: Success API-->>Client: 200 OK + Response else Permission Denied Handler-->>API: Forbidden API-->>Client: 403 Forbidden end

2.2 The AuthorizePermission Attribute
#

This custom attribute provides a clean syntax for protecting endpoints. Instead of writing complex policy names, you simply specify the permission in Page.Operation format.

// Location: Contact.Api/Core/Authorization/AuthorizePermissionAttribute.cs
using Microsoft.AspNetCore.Authorization;

namespace Contact.Api.Core.Authorization;

public class AuthorizePermissionAttribute : AuthorizeAttribute
{
    public AuthorizePermissionAttribute(string permission)
    {
        // Converts "Contacts.Create" to "Contacts.CreatePolicy"
        Policy = $"{permission}Policy";
    }
}

Usage Example:

// Location: Contact.Api/Controllers/ContactPersonController.cs
[HttpPost]
[ActivityLog("Creating new Contact")]
[AuthorizePermission("Contacts.Create")]
public async Task<IActionResult> Add(CreateContactPerson createContactPerson)
{
    var createdContactPerson = await _contactPersonService.Add(createContactPerson);
    return CreatedAtAction(nameof(GetById), new { id = createdContactPerson.Id }, createdContactPerson);
}

[HttpPut("{id}")]
[ActivityLog("Updating existing Contact")]
[AuthorizePermission("Contacts.Update")]
public async Task<IActionResult> Update(Guid id, UpdateContactPerson updateContactPerson)
{
    await _contactPersonService.Update(id, updateContactPerson);
    return NoContent();
}

[HttpDelete("{id}")]
[ActivityLog("Deleting existing Contact")]
[AuthorizePermission("Contacts.Delete")]
public async Task<IActionResult> Delete(Guid id)
{
    await _contactPersonService.Delete(id);
    return NoContent();
}

[HttpGet]
[AuthorizePermission("Contacts.Read")]
public async Task<IActionResult> Get()
{
    var contactPersons = await _contactPersonService.GetAll();
    return Ok(contactPersons);
}

2.3 The Permission Requirement
#

A simple class that holds the permission string for the authorization requirement:

// Location: Contact.Api/Core/Authorization/PermissionRequirement.cs
using Microsoft.AspNetCore.Authorization;

namespace Contact.Api.Core.Authorization;

public class PermissionRequirement : IAuthorizationRequirement
{
    public string Permission { get; }

    public PermissionRequirement(string permission)
    {
        Permission = permission;
    }
}

2.4 The Custom Authorization Policy Provider
#

This is the key component that makes dynamic permissions possible. Instead of registering all policies at startup (which would require restarting the app when permissions change), this provider creates policies on-demand at runtime.

// Location: Contact.Api/Core/Authorization/CustomAuthorizationPolicyProvider.cs
using Microsoft.AspNetCore.Authorization;
using Microsoft.Extensions.Options;

namespace Contact.Api.Core.Authorization;

public class CustomAuthorizationPolicyProvider : IAuthorizationPolicyProvider
{
    private readonly DefaultAuthorizationPolicyProvider _fallbackPolicyProvider;

    public CustomAuthorizationPolicyProvider(IOptions<AuthorizationOptions> options)
    {
        _fallbackPolicyProvider = new DefaultAuthorizationPolicyProvider(options);
    }

    public Task<AuthorizationPolicy?> GetPolicyAsync(string policyName)
    {
        // Check if this is a permission-based policy (ends with "Policy")
        if (policyName.EndsWith("Policy"))
        {
            // Dynamically create a policy with PermissionRequirement
            var policy = new AuthorizationPolicyBuilder()
                .AddRequirements(new PermissionRequirement(policyName))
                .Build();
            return Task.FromResult<AuthorizationPolicy?>(policy);
        }

        // Fall back to default policy provider for non-permission policies
        return _fallbackPolicyProvider.GetPolicyAsync(policyName);
    }

    public Task<AuthorizationPolicy> GetDefaultPolicyAsync()
    {
        return _fallbackPolicyProvider.GetDefaultPolicyAsync();
    }

    public Task<AuthorizationPolicy?> GetFallbackPolicyAsync()
    {
        return _fallbackPolicyProvider.GetFallbackPolicyAsync();
    }
}

Why Dynamic Policy Provider?

The traditional approach requires registering all policies at startup:

// Traditional approach - requires app restart for new permissions
services.AddAuthorization(options =>
{
    options.AddPolicy("Contacts.ReadPolicy", policy => ...);
    options.AddPolicy("Contacts.CreatePolicy", policy => ...);
    // Must add every policy here!
});

With CustomAuthorizationPolicyProvider:

// Dynamic approach - policies created at runtime
// Just register the provider, policies are created on-demand
services.AddSingleton<IAuthorizationPolicyProvider, CustomAuthorizationPolicyProvider>();

2.5 The Permission Handler
#

This is where the actual permission validation happens. It checks if the authenticated user’s roles have the required permission.

// Location: Contact.Api/Core/Authorization/PermissionHandler.cs
using Contact.Application.Interfaces;
using Microsoft.AspNetCore.Authorization;

namespace Contact.Api.Core.Authorization;

public class PermissionHandler : AuthorizationHandler<PermissionRequirement>
{
    private readonly IServiceProvider _serviceProvider;

    public PermissionHandler(IServiceProvider serviceProvider)
    {
        _serviceProvider = serviceProvider;
    }

    protected override async Task HandleRequirementAsync(
        AuthorizationHandlerContext context, 
        PermissionRequirement requirement)
    {
        // Create a scope to resolve scoped services
        using var scope = _serviceProvider.CreateScope();
        var userService = scope.ServiceProvider.GetRequiredService<IUserService>();
        var rolePermissionService = scope.ServiceProvider.GetRequiredService<IRolePermissionService>();

        // Check if user is authenticated
        if (context.User.Identity?.IsAuthenticated != true)
        {
            return; // Not authenticated, don't succeed
        }

        // Get user's roles from JWT claims
        var roles = await userService.GetUserRolesAsync(context.User);
        
        // Get all role-permission mappings from database
        var rolePermissionMappings = await rolePermissionService.GetRolePermissionMappingsAsync();

        // Build list of permissions for user's roles
        // Format: "PageName.OperationNamePolicy" (e.g., "Contacts.CreatePolicy")
        var userPermissions = rolePermissionMappings
            .Where(rpm => roles.Contains(rpm.RoleName))
            .Select(rpm => $"{rpm.PageName}.{rpm.OperationName}Policy");

        // Check if user has the required permission
        if (userPermissions.Contains(requirement.Permission))
        {
            context.Succeed(requirement);
        }
    }
}

2.6 Registering Authorization Services
#

In Program.cs, register the authorization components:

// Location: Contact.Api/Program.cs

// Add the custom policy provider
builder.Services.AddSingleton<IAuthorizationPolicyProvider, CustomAuthorizationPolicyProvider>();

// Add the permission handler
builder.Services.AddScoped<IAuthorizationHandler, PermissionHandler>();

// Configure JWT authentication
builder.Services.AddAuthentication(options =>
{
    options.DefaultAuthenticateScheme = JwtBearerDefaults.AuthenticationScheme;
    options.DefaultChallengeScheme = JwtBearerDefaults.AuthenticationScheme;
})
.AddJwtBearer(options =>
{
    options.TokenValidationParameters = new TokenValidationParameters
    {
        ValidateIssuer = true,
        ValidateAudience = true,
        ValidateLifetime = true,
        ValidateIssuerSigningKey = true,
        ValidIssuer = builder.Configuration["Jwt:Issuer"],
        ValidAudience = builder.Configuration["Jwt:Audience"],
        IssuerSigningKey = new SymmetricSecurityKey(
            Encoding.UTF8.GetBytes(builder.Configuration["Jwt:Secret"]!))
    };
});

3. Authentication Flow and Permission Delivery
#

3.1 How Users Get Their Permissions
#

When a user logs in, the backend returns not just a JWT token, but also the complete list of role permissions. This enables the frontend to make permission decisions without additional API calls.

sequenceDiagram participant User participant Angular as Angular App participant API as Backend API participant DB as Database User->>Angular: Enter credentials Angular->>API: POST /api/users/authenticate API->>DB: Validate credentials DB-->>API: User found API->>DB: Get user roles (UserRoles table) DB-->>API: User has "Admin" role API->>DB: Get role permissions (RolePermissions + Permissions) DB-->>API: Return all permissions for "Admin" API->>API: Generate JWT with role claims API-->>Angular: { token, user, rolePermissions[] } Angular->>Angular: Store in localStorage Angular->>Angular: Update AuthStateService User->>Angular: Navigate to protected route Angular->>Angular: PermissionGuard checks permission Angular->>Angular: PermissionService.hasPermission() alt Has Permission Angular-->>User: Show page else No Permission Angular-->>User: Redirect with error message end

3.2 Backend: UserService.Authenticate()
#

The authentication endpoint returns the user’s role permissions:

// Location: Contact.Application/Services/UserService.cs
public async Task<AuthenticateResponse?> Authenticate(AuthenticateRequest model)
{
    // Find user by email
    var user = await _userRepository.GetUserByEmail(model.Email);
    
    if (user == null) return null;
    
    // Verify password
    if (!BCrypt.Net.BCrypt.Verify(model.Password, user.Password))
        return null;

    // Generate JWT token with role claims
    var token = GenerateJwtToken(user);
    
    // Get all role permissions for this user
    var rolePermissions = await GetRolePermissionMappingsAsync(user.Id);

    // Return response with token AND permissions
    return new AuthenticateResponse(user, token, rolePermissions.ToList());
}

3.3 Backend: JWT Token Generation
#

The JWT token includes the user’s roles as claims, which are used for backend authorization:

// Location: Contact.Application/Services/UserService.cs
private string GenerateJwtToken(User user)
{
    var tokenHandler = new JwtSecurityTokenHandler();
    var key = Encoding.ASCII.GetBytes(_appSettings.Secret);
    
    // Create claims including user roles
    var claims = new List<Claim>
    {
        new Claim(ClaimTypes.NameIdentifier, user.Id.ToString()),
        new Claim(ClaimTypes.Email, user.Email),
        new Claim(ClaimTypes.Name, user.Username)
    };
    
    // Add role claims (used by PermissionHandler)
    var roles = _userRoleRepository.GetUserRolesAsync(user.Id).Result;
    foreach (var role in roles)
    {
        claims.Add(new Claim(ClaimTypes.Role, role.Name));
    }
    
    var tokenDescriptor = new SecurityTokenDescriptor
    {
        Subject = new ClaimsIdentity(claims),
        Expires = DateTime.UtcNow.AddDays(7),
        SigningCredentials = new SigningCredentials(
            new SymmetricSecurityKey(key), 
            SecurityAlgorithms.HmacSha256Signature),
        Issuer = _appSettings.Issuer,
        Audience = _appSettings.Audience
    };
    
    var token = tokenHandler.CreateToken(tokenDescriptor);
    return tokenHandler.WriteToken(token);
}

4. Frontend Permission System (Angular)
#

The Angular frontend implements a complete permission system with three main components:

  1. PermissionService - Checks if user has specific permissions
  2. PermissionGuard - Protects routes
  3. HasPermission Directive - Conditionally shows/hides UI elements

4.1 Auth State Service
#

First, let’s see how the user state (including permissions) is managed:

// Location: frontend/src/app/@core/services/auth-state.service.ts
import { Injectable, signal, computed } from '@angular/core';
import { User } from '@core/models/user.model';

@Injectable({ providedIn: 'root' })
export class AuthStateService {
  // Signal-based state management
  private userState = signal<User | null>(null);
  
  // Computed value for easy access
  readonly user = computed(() => this.userState());
  readonly isLoggedIn = computed(() => !!this.userState());

  constructor() {
    // Initialize from localStorage on app start
    this.initializeFromStorage();
  }

  private initializeFromStorage(): void {
    if (typeof window !== 'undefined') {
      const stored = localStorage.getItem('currentUser');
      if (stored) {
        try {
          const user = JSON.parse(stored);
          this.userState.set(user);
        } catch {
          localStorage.removeItem('currentUser');
        }
      }
    }
  }

  updateUser(user: User | null): void {
    this.userState.set(user);
    if (user) {
      localStorage.setItem('currentUser', JSON.stringify(user));
    } else {
      localStorage.removeItem('currentUser');
    }
  }

  getUser(): User | null {
    return this.userState();
  }
}

4.2 Permission Service
#

This service provides the core permission checking logic:

// Location: frontend/src/app/@core/services/permission.service.ts
import { Injectable, inject } from '@angular/core';
import { AuthStateService } from './auth-state.service';

@Injectable({ providedIn: 'root' })
export class PermissionService {
  private authStateService = inject(AuthStateService);

  /**
   * Check if current user has permission for a specific page and operation
   * @param page - The page/resource name (e.g., 'Contacts', 'Users')
   * @param operation - The operation name (e.g., 'Read', 'Create', 'Update', 'Delete')
   * @returns true if user has the permission, false otherwise
   */
  hasPermission(page: string, operation: string): boolean {
    const user = this.authStateService.getUser();
    
    // No user = no permissions
    if (!user || !user.rolePermissions) {
      return false;
    }
    
    // Check if any of user's role permissions match
    return user.rolePermissions.some(
      (permission) =>
        permission.pageName === page && 
        permission.operationName === operation
    );
  }
}

4.3 Permission Guard
#

Protects routes from unauthorized access:

// Location: frontend/src/app/@core/guards/permission.guard.ts
import { inject } from '@angular/core';
import { CanActivateFn, Router } from '@angular/router';
import { PermissionService } from '@core/services/permission.service';
import { MatSnackBar } from '@angular/material/snack-bar';

/**
 * Factory function that creates a permission guard for specific page/operation
 * @param page - The page name to check permission for
 * @param operation - The operation to check permission for
 * @returns CanActivateFn that checks the permission
 */
export function permissionGuard(page: string, operation: string): CanActivateFn {
  return () => {
    const permissionService = inject(PermissionService);
    const router = inject(Router);
    const snackBar = inject(MatSnackBar);

    // Check if user has the required permission
    if (permissionService.hasPermission(page, operation)) {
      return true;
    }

    // Show error message and redirect
    snackBar.open(
      `Access Denied: You don't have permission to ${operation.toLowerCase()} ${page.toLowerCase()}`,
      'Close',
      { duration: 5000, panelClass: ['error-snackbar'] }
    );

    // Redirect to home or dashboard
    router.navigate(['/']);
    return false;
  };
}

Usage in Routes:

// Location: frontend/src/app/feature/contact-person/contact-person.routes.ts
import { Routes } from '@angular/router';
import { permissionGuard } from '@core/guards/permission.guard';

export const CONTACT_ROUTES: Routes = [
  {
    path: '',
    component: ContactListComponent,
    canActivate: [permissionGuard('Contacts', 'Read')]
  },
  {
    path: 'add',
    component: ContactFormComponent,
    canActivate: [permissionGuard('Contacts', 'Create')]
  },
  {
    path: 'edit/:id',
    component: ContactFormComponent,
    canActivate: [permissionGuard('Contacts', 'Update')]
  }
];

4.4 HasPermission Directive
#

This structural directive conditionally renders UI elements based on permissions:

// Location: frontend/src/app/@shared/directives/permission.directive.ts
import {
  Directive,
  Input,
  TemplateRef,
  ViewContainerRef,
  inject,
  OnInit,
} from '@angular/core';
import { PermissionService } from '@core/services/permission.service';

@Directive({
  selector: '[hasPermission]',
  standalone: true,
})
export class HasPermissionDirective implements OnInit {
  private templateRef = inject(TemplateRef<any>);
  private viewContainer = inject(ViewContainerRef);
  private permissionService = inject(PermissionService);

  @Input('hasPermission') permission!: { page: string; operation: string };

  ngOnInit(): void {
    this.updateView();
  }

  private updateView(): void {
    // Clear any existing view
    this.viewContainer.clear();

    // Check permission and render if allowed
    if (this.permissionService.hasPermission(
      this.permission.page, 
      this.permission.operation
    )) {
      // Create the view (show the element)
      this.viewContainer.createEmbeddedView(this.templateRef);
    }
    // If no permission, element is not rendered at all
  }
}

Usage in Templates:

<!-- Location: frontend/src/app/feature/contact-person/contact-list/contact-list.component.html -->

<!-- Add button only visible to users with Create permission -->
<button 
  mat-raised-button 
  color="primary"
  *hasPermission="{ page: 'Contacts', operation: 'Create' }"
  routerLink="add">
  <mat-icon>add</mat-icon>
  Add Contact
</button>

<!-- Edit button in table row -->
<button 
  mat-icon-button 
  *hasPermission="{ page: 'Contacts', operation: 'Update' }"
  [routerLink]="['edit', contact.id]">
  <mat-icon>edit</mat-icon>
</button>

<!-- Delete button in table row -->
<button 
  mat-icon-button 
  color="warn"
  *hasPermission="{ page: 'Contacts', operation: 'Delete' }"
  (click)="deleteContact(contact)">
  <mat-icon>delete</mat-icon>
</button>

4.5 Permission-Based Navigation Menu
#

The sidebar navigation filters menu items based on user permissions:

// Location: frontend/src/app/@shared/components/custom-sidenav/custom-sidenav.component.ts
import { Component, inject } from '@angular/core';
import { PermissionService } from '@core/services/permission.service';

interface MenuItem {
  icon: string;
  label: string;
  route: string;
  page: string;      // Permission page
  operation: string; // Permission operation
}

@Component({
  selector: 'app-custom-sidenav',
  templateUrl: './custom-sidenav.component.html',
  standalone: true,
  // ... imports
})
export class CustomSidenavComponent {
  private permissionService = inject(PermissionService);

  // All possible menu items
  allMenuItems: MenuItem[] = [
    { icon: 'contacts', label: 'Contacts', route: '/contact', page: 'Contacts', operation: 'Read' },
    { icon: 'people', label: 'Users', route: '/user', page: 'Users', operation: 'Read' },
    { icon: 'admin_panel_settings', label: 'Roles', route: '/admin/role', page: 'Roles', operation: 'Read' },
    { icon: 'lock', label: 'Permissions', route: '/admin/permission', page: 'Permissions', operation: 'Read' },
    { icon: 'assignment_ind', label: 'Role Permissions', route: '/admin/role-permission', page: 'RolePermissions', operation: 'Read' },
  ];

  // Filter menu items based on permissions
  get visibleMenuItems(): MenuItem[] {
    return this.allMenuItems.filter(item => 
      this.permissionService.hasPermission(item.page, item.operation)
    );
  }

  // Helper method for use in template
  hasPermission(page: string, operation: string): boolean {
    return this.permissionService.hasPermission(page, operation);
  }
}

5. Admin UI: Role-Permission Mapping
#

The application includes an admin interface for managing role permissions. This allows administrators to assign and revoke permissions without code changes.

5.1 Role Permission Mapping Screen
#

flowchart LR subgraph "Admin UI" RoleDropdown[Select Role Dropdown] PageList[Expandable Page List] OperationCheckboxes[Operation Checkboxes] SaveButton[Save Button] end RoleDropdown --> |"Select 'Admin'"| PageList PageList --> |"Expand 'Contacts'"| OperationCheckboxes OperationCheckboxes --> |"Check Read, Create, Update"| SaveButton SaveButton --> |"Save to Database"| Database[(RolePermissions)]

5.2 Role Permission Mapping Component
#

// Location: frontend/src/app/feature/admin/role-permission-mapping/role-permission-mapping.component.ts
import { Component, OnInit, inject, signal } from '@angular/core';
import { RolePermissionService } from '@core/services/role-permission.service';
import { RoleService } from '@core/services/role.service';
import { PermissionService as AdminPermissionService } from '@core/services/admin-permission.service';
import { NotificationService } from '@core/services/notification.service';

interface PageWithOperations {
  pageId: string;
  pageName: string;
  expanded: boolean;
  operations: OperationPermission[];
}

interface OperationPermission {
  operationId: string;
  operationName: string;
  permissionId: string;
  isSelected: boolean;
}

@Component({
  selector: 'app-role-permission-mapping',
  templateUrl: './role-permission-mapping.component.html',
  standalone: true,
  // ... imports
})
export class RolePermissionMappingComponent implements OnInit {
  private roleService = inject(RoleService);
  private permissionService = inject(AdminPermissionService);
  private rolePermissionService = inject(RolePermissionService);
  private notificationService = inject(NotificationService);

  // State using signals
  roles = signal<Role[]>([]);
  selectedRoleId = signal<string>('');
  pages = signal<PageWithOperations[]>([]);
  loading = signal<boolean>(false);

  ngOnInit(): void {
    this.loadRoles();
  }

  async loadRoles(): Promise<void> {
    this.roleService.getRoles().subscribe({
      next: (roles) => this.roles.set(roles),
      error: (err) => this.notificationService.error('Failed to load roles')
    });
  }

  onRoleChange(roleId: string): void {
    this.selectedRoleId.set(roleId);
    this.loadPermissionsForRole(roleId);
  }

  async loadPermissionsForRole(roleId: string): Promise<void> {
    this.loading.set(true);
    
    // Get all permissions grouped by page
    this.permissionService.getPermissionsGroupedByPage().subscribe({
      next: (groupedPermissions) => {
        // Get current role's permissions
        this.rolePermissionService.getRolePermissions(roleId).subscribe({
          next: (rolePermissions) => {
            // Mark which permissions are selected for this role
            const pages = groupedPermissions.map(page => ({
              ...page,
              expanded: false,
              operations: page.operations.map(op => ({
                ...op,
                isSelected: rolePermissions.some(rp => rp.permissionId === op.permissionId)
              }))
            }));
            this.pages.set(pages);
            this.loading.set(false);
          }
        });
      }
    });
  }

  togglePage(pageIndex: number): void {
    const pages = [...this.pages()];
    pages[pageIndex].expanded = !pages[pageIndex].expanded;
    this.pages.set(pages);
  }

  togglePermission(pageIndex: number, opIndex: number): void {
    const pages = [...this.pages()];
    pages[pageIndex].operations[opIndex].isSelected = 
      !pages[pageIndex].operations[opIndex].isSelected;
    this.pages.set(pages);
  }

  savePermissions(): void {
    const roleId = this.selectedRoleId();
    
    // Collect all selected permission IDs
    const selectedPermissionIds = this.pages()
      .flatMap(page => page.operations)
      .filter(op => op.isSelected)
      .map(op => op.permissionId);

    const payload = {
      roleId: roleId,
      permissionIds: selectedPermissionIds
    };

    this.rolePermissionService.saveRolePermissions(payload).subscribe({
      next: () => {
        this.notificationService.success('Permissions saved successfully');
      },
      error: (err) => {
        this.notificationService.error('Failed to save permissions');
      }
    });
  }
}

5.3 Role Permission Mapping Template
#

<!-- Location: frontend/src/app/feature/admin/role-permission-mapping/role-permission-mapping.component.html -->
<div class="role-permission-container">
  <mat-card>
    <mat-card-header>
      <mat-card-title>Role Permission Mapping</mat-card-title>
      <mat-card-subtitle>Assign permissions to roles</mat-card-subtitle>
    </mat-card-header>
    
    <mat-card-content>
      <!-- Role Selection -->
      <mat-form-field appearance="outline" class="full-width">
        <mat-label>Select Role</mat-label>
        <mat-select (selectionChange)="onRoleChange($event.value)">
          @for (role of roles(); track role.id) {
            <mat-option [value]="role.id">{{ role.name }}</mat-option>
          }
        </mat-select>
      </mat-form-field>

      <!-- Permission Matrix -->
      @if (selectedRoleId()) {
        <div class="permission-matrix">
          @for (page of pages(); track page.pageId; let i = $index) {
            <mat-expansion-panel [expanded]="page.expanded" (opened)="togglePage(i)">
              <mat-expansion-panel-header>
                <mat-panel-title>
                  <mat-icon>folder</mat-icon>
                  {{ page.pageName }}
                </mat-panel-title>
                <mat-panel-description>
                  {{ page.operations.filter(o => o.isSelected).length }} / {{ page.operations.length }} permissions
                </mat-panel-description>
              </mat-expansion-panel-header>

              <div class="operations-grid">
                @for (operation of page.operations; track operation.operationId; let j = $index) {
                  <mat-checkbox
                    [checked]="operation.isSelected"
                    (change)="togglePermission(i, j)">
                    {{ operation.operationName }}
                  </mat-checkbox>
                }
              </div>
            </mat-expansion-panel>
          }
        </div>

        <!-- Save Button -->
        <div class="actions">
          <button 
            mat-raised-button 
            color="primary" 
            (click)="savePermissions()"
            [disabled]="loading()">
            <mat-icon>save</mat-icon>
            Save Permissions
          </button>
        </div>
      }
    </mat-card-content>
  </mat-card>
</div>

6. Complete Permission Flow Summary
#

Here’s how everything works together when a user interacts with the application:

Phase 1: User Login
#

flowchart LR A[User enters credentials] --> B[API validates] B --> C[Generate JWT] C --> D[Fetch role permissions] D --> E[Return token + permissions] E --> F[Store in localStorage]

Phase 2: Route Navigation
#

flowchart LR A[User clicks Contacts] --> B[PermissionGuard] B --> C{Has Contacts.Read?} C -->|Yes| D[Load Component] C -->|No| E[Redirect + Error]

Phase 3: UI Rendering
#

flowchart LR A[Component loads] --> B[*hasPermission directive] B --> C{Has Contacts.Create?} C -->|Yes| D[Show Add button] C -->|No| E[Hide Add button]

Phase 4: API Authorization
#

flowchart LR A[Submit form] --> B[POST /api/contacts] B --> C[AuthorizePermission] C --> D[PolicyProvider] D --> E[PermissionHandler] E --> F{Role has permission?} F -->|Yes| G[201 Created] F -->|No| H[403 Forbidden]

Complete Flow Overview
#

PhaseComponentActionResult
1. LoginAuthServiceAuthenticate userJWT + permissions stored
2. NavigatePermissionGuardCheck route accessAllow or redirect
3. Render*hasPermissionCheck UI visibilityShow or hide elements
4. API CallPermissionHandlerValidate requestProcess or reject

7. Benefits of This RBAC Implementation
#

7.1 Dynamic & Flexible
#

  • No app restart required when permissions change
  • New pages/operations can be added to database without code changes
  • Role permissions can be modified via Admin UI

7.2 Granular Access Control
#

  • Each API endpoint can have its own permission
  • UI elements can be shown/hidden individually
  • Multiple roles can be assigned to users

7.3 Clean Architecture Compliant
#

  • Authorization logic is separated from business logic
  • Domain entities are independent of authorization concerns
  • Infrastructure handles database queries for permissions

7.4 Full-Stack Security
#

  • Backend: API endpoints protected with custom attributes
  • Frontend: Routes protected with guards, UI elements with directives
  • Double protection: Even if UI is bypassed, API enforces permissions

7.5 Maintainable & Testable
#

  • Permission checks are centralized in dedicated services
  • Easy to unit test permission logic
  • Clear separation of concerns

Conclusion
#

This article demonstrated how to implement a comprehensive Role-Based Access Control system in the Contact Management Application using Clean Architecture principles. The key components include:

  1. Database Entities: A flexible schema with Pages, Operations, Permissions, Roles, and their mappings
  2. Dynamic Policy Provider: Runtime policy creation without startup registration
  3. Permission Handler: Server-side validation against database permissions
  4. Frontend Services: PermissionService for centralized permission checking
  5. Route Guards: Protection at the route level
  6. Directives: Fine-grained UI element visibility control
  7. Admin UI: User-friendly interface for managing permissions

By implementing both backend and frontend authorization, this approach provides defense in depth - even if a malicious user bypasses the UI restrictions, the API will still enforce permissions.

For a more detailed look at the project, you can access the full source code on GitHub:

GitHub Repository: Contact Management Application{:target="_blank" rel=“noopener noreferrer”}

In the next article, we will discuss Activity Logging to track user actions within the system.

Related

Implementing AutoMapper for DTO Mapping with Audit Details

·7 mins
AutoMapper is a powerful object-object mapper that simplifies the transformation between data models. In Clean Architecture, it plays a vital role in maintaining a clear separation of concerns between layers. By automating mapping and handling fields like CreatedBy, CreatedOn, UpdatedBy, and UpdatedOn, AutoMapper reduces boilerplate code and ensures consistency.

Using Dapper for Data Access and Repository Pattern

·9 mins
Introduction # In this article, we will explore how the Contact Management Application integrates Dapper for efficient data access. Dapper is a micro-ORM (Object-Relational Mapper) that excels in performance by directly interacting with SQL. We will focus on how Dapper is utilized within the Repository Pattern to ensure clean, maintainable, and efficient access to the database. Additionally, we will explore how Dapper works in tandem with the Unit of Work pattern to manage transactions.

Validating Inputs with FluentValidation

·6 mins
Introduction # In this article, we will explore how to implement input validation in the Contact Management Application using FluentValidation. FluentValidation is a robust .NET library that facilitates the creation of flexible and extensible validation rules for your models. By integrating seamlessly with ASP.NET Core, it allows you to maintain clean separation between validation logic and business logic, adhering to the principles of Clean Architecture.