Complete documentation of Role-Based Access Control implementation with user-level overrides, permission resolution, and ALLOW/DENY logic for the ProKure system.
The ProKure RBAC system uses a hybrid model combining role-based permissions with user-level overrides. This allows both standardized role assignment and granular per-user customization.
When checking if a user has a specific permission, the system follows this priority order:
First, check if the user has an explicit DENY for this permission in admin.user_permissions. If found, immediately return FALSE - DENY always wins.
SELECT 1 FROM admin.user_permissions WHERE user_id = @userId AND permission_id = (SELECT id FROM admin.permissions WHERE permission_code = 'PR.EDIT') AND grant_type = 'DENY' AND is_active = true;
If no DENY found, check if the user has an explicit ALLOW override. This grants permissions beyond their roles.
SELECT 1 FROM admin.user_permissions WHERE user_id = @userId AND permission_id = (SELECT id FROM admin.permissions WHERE permission_code = 'PR.EDIT') AND grant_type = 'ALLOW' AND is_active = true;
If no user-level override exists, aggregate permissions from all assigned roles.
SELECT DISTINCT p.permission_code FROM admin.user_roles ur JOIN admin.role_permissions rp ON ur.role_id = rp.role_id JOIN admin.permissions p ON rp.permission_id = p.id WHERE ur.user_id = @userId AND ur.is_active = true AND p.is_active = true;
If no permission is found at any level, access is denied. This follows the principle of least privilege.
| Priority | Source | Type | Effect |
|---|---|---|---|
| 1 (Highest) | admin.user_permissions |
DENY | Immediately blocks access |
| 2 | admin.user_permissions |
ALLOW | Grants access (overrides roles) |
| 3 | admin.role_permissions |
Role ALLOW | Grants access from assigned roles |
| 4 (Default) | No entry found | Implicit DENY | Access denied by default |
DENY always wins over ALLOW at any level. This follows standard security practices where explicit restrictions cannot be bypassed by broader permissions.
Requirement: User "John" should have PR_CREATOR role but without EDIT permission.
PR_CREATOR role includes: PR.CREATE, PR.EDIT, PR.VIEW, PR.DELETE
INSERT INTO admin.user_roles (user_id, role_id, assigned_by, is_active) VALUES (@johnId, @prCreatorRoleId, @adminId, true);
Create a user-level DENY to block the EDIT permission.
INSERT INTO admin.user_permissions (user_id, permission_id, grant_type, granted_by, is_active) VALUES ( @johnId, (SELECT id FROM admin.permissions WHERE permission_code = 'PR.EDIT'), 'DENY', @adminId, true );
John's final effective permissions:
| Permission | From Role | User Override | Final Result |
|---|---|---|---|
PR.CREATE |
ALLOW | - | ALLOW |
PR.EDIT |
ALLOW | DENY | DENY |
PR.VIEW |
ALLOW | - | ALLOW |
PR.DELETE |
ALLOW | - | ALLOW |
The permission check logic implemented in the .NET API layer:
public async Task<bool> HasPermissionAsync(int userId, string permissionCode) { // Step 1: Check user-level DENY (highest priority) var userDeny = await _context.UserPermissions .AnyAsync(up => up.UserId == userId && up.Permission.PermissionCode == permissionCode && up.GrantType == "DENY" && up.IsActive); if (userDeny) return false; // DENY wins immediately // Step 2: Check user-level ALLOW var userAllow = await _context.UserPermissions .AnyAsync(up => up.UserId == userId && up.Permission.PermissionCode == permissionCode && up.GrantType == "ALLOW" && up.IsActive); if (userAllow) return true; // Step 3: Check role-level permissions var roleAllow = await _context.UserRoles .Where(ur => ur.UserId == userId && ur.IsActive) .SelectMany(ur => ur.Role.RolePermissions) .AnyAsync(rp => rp.Permission.PermissionCode == permissionCode && rp.Permission.IsActive); return roleAllow; }
At login, the API resolves all permissions and returns only the final ALLOWED permissions to Angular. The frontend never needs to handle DENY logic - it simply works with the list of granted permissions.
The frontend receives pre-resolved permissions and uses them for UI control:
// AuthService - stores permissions from login response @Injectable({ providedIn: 'root' }) export class AuthService { private permissions: string[] = []; setPermissions(permissions: string[]) { this.permissions = permissions; } hasPermission(code: string): boolean { return this.permissions.includes(code); } hasAnyPermission(codes: string[]): boolean { return codes.some(code => this.permissions.includes(code)); } } // Usage in component template <button *ngIf="auth.hasPermission('PR.CREATE')">Create PR</button> <button *ngIf="auth.hasPermission('PR.EDIT')">Edit PR</button> <!-- Hidden for John -->
Permission codes follow the pattern: MODULE.ACTION
| Module | Actions | Example Codes |
|---|---|---|
| PR (Purchase Request) | CREATE, VIEW, EDIT, DELETE, APPROVE | PR.CREATE, PR.APPROVE |
| RFQ (Request for Quote) | CREATE, VIEW, EDIT, PUBLISH, COMPARE | RFQ.PUBLISH, RFQ.COMPARE |
| PO (Purchase Order) | CREATE, VIEW, EDIT, APPROVE, CANCEL | PO.APPROVE, PO.CANCEL |
| VENDOR | CREATE, VIEW, EDIT, APPROVE, BLACKLIST | VENDOR.APPROVE, VENDOR.BLACKLIST |
| GRN (Goods Receipt) | CREATE, VIEW, EDIT, CONFIRM | GRN.CREATE, GRN.CONFIRM |
| INVOICE | CREATE, VIEW, APPROVE, PAY | INVOICE.APPROVE, INVOICE.PAY |
| ADMIN | USER_MANAGE, ROLE_MANAGE, CONFIG | ADMIN.USER_MANAGE, ADMIN.CONFIG |