Role-Based Access Control (RBAC) is the practice of granting permissions to roles rather than directly to users. A user is assigned a role (user, admin, moderator) and that role determines what they can do. In the MEAN Stack, RBAC spans both layers: the Express API enforces it server-side with middleware (preventing malicious users from bypassing the UI), and Angular enforces it client-side with route guards and template directives (preventing navigation to unauthorised pages and hiding unavailable UI elements). Both layers are necessary — API enforcement is mandatory for security; Angular enforcement is for UX.
RBAC Implementation Layers
| Layer | What It Controls | Why It Matters |
|---|---|---|
| Express middleware | Which API endpoints the user can call | Security — must be enforced; cannot be bypassed by client |
| Angular route guards | Which pages the user can navigate to | UX — prevents seeing error pages; no security value on its own |
| Angular template directives | Which UI elements are shown | UX — hides admin buttons from non-admins; decorative only |
| Angular service methods | Which actions can be attempted | UX — disables or warns before API calls that would fail |
Permission Matrix Example
| Action | user | moderator | admin |
|---|---|---|---|
| View own tasks | ✅ | ✅ | ✅ |
| Create tasks | ✅ | ✅ | ✅ |
| Delete own tasks | ✅ | ✅ | ✅ |
| View all users’ tasks | ❌ | ✅ | ✅ |
| Delete any task | ❌ | ✅ | ✅ |
| Manage users | ❌ | ❌ | ✅ |
| View admin dashboard | ❌ | ❌ | ✅ |
DELETE /api/v1/users/42 directly if the endpoint is not protected. API-level RBAC is non-negotiable.user.role === 'admin' throughout the codebase. Define a permissions object: const PERMISSIONS = { admin: ['manage_users', 'view_all_tasks', 'delete_any_task'], user: ['view_own_tasks', 'create_task'] }. Then check permissionsService.can('delete_any_task'). If you ever add a moderator role, you only update the permissions map — not every place in the codebase that checks role === 'admin'.userId field must be checked against req.user.sub (from the JWT) even if the user has the user role. An admin bypassing user checks is a feature; a regular user accessing another user’s tasks by guessing IDs is a security vulnerability. Always combine role checks with ownership checks for user-owned resources.Complete RBAC Implementation
// ── Express RBAC middleware ───────────────────────────────────────────────
// middleware/rbac.middleware.js
// Simple role check
const requireRole = (...roles) => (req, res, next) => {
if (!req.user) return res.status(401).json({ message: 'Not authenticated' });
if (!roles.includes(req.user.role)) {
return res.status(403).json({
message: `This action requires one of: ${roles.join(', ')}`,
});
}
next();
};
// Ownership check — user owns the resource OR is admin
const requireOwnerOrAdmin = (getResourceUserId) => async (req, res, next) => {
if (req.user.role === 'admin') return next(); // admins bypass ownership check
const resourceUserId = await getResourceUserId(req);
if (!resourceUserId) return res.status(404).json({ message: 'Resource not found' });
if (resourceUserId.toString() !== req.user.sub) {
return res.status(403).json({ message: 'Access denied' });
}
next();
};
// Permission-based middleware
const ROLE_PERMISSIONS = {
user: ['view_own_tasks', 'create_task', 'update_own_task', 'delete_own_task'],
moderator: ['view_own_tasks', 'view_all_tasks', 'create_task',
'update_own_task', 'delete_own_task', 'delete_any_task'],
admin: ['*'], // admin has all permissions
};
const requirePermission = (permission) => (req, res, next) => {
const userPerms = ROLE_PERMISSIONS[req.user?.role] ?? [];
const hasPermission = userPerms.includes('*') || userPerms.includes(permission);
if (!hasPermission) {
return res.status(403).json({ message: `Missing permission: ${permission}` });
}
next();
};
// ── Applying middleware to routes ─────────────────────────────────────────
const router = require('express').Router();
// All task routes require authentication
router.use(verifyAccessToken);
// Public to all authenticated users
router.get('/', asyncHandler(getAllUserTasks));
router.post('/', asyncHandler(createTask));
// Owner or admin only
router.get('/:id', requireOwnerOrAdmin(req => Task.findById(req.params.id).then(t => t?.user)), asyncHandler(getTask));
router.patch('/:id', requireOwnerOrAdmin(req => Task.findById(req.params.id).then(t => t?.user)), asyncHandler(updateTask));
router.delete('/:id', requireOwnerOrAdmin(req => Task.findById(req.params.id).then(t => t?.user)), asyncHandler(deleteTask));
// Admin only
router.get('/admin/all', requireRole('admin'), asyncHandler(getAllTasksForAdmin));
module.exports = router;
// ── Angular permissions service ───────────────────────────────────────────
// core/services/permissions.service.ts
import { Injectable, inject, computed } from '@angular/core';
import { AuthStore } from '../stores/auth.store';
export type Permission =
| 'view_own_tasks' | 'create_task' | 'update_own_task' | 'delete_own_task'
| 'view_all_tasks' | 'delete_any_task' | 'manage_users' | 'view_admin_dashboard';
const ROLE_PERMISSIONS: Record<string, Permission[] | ['*']> = {
user: ['view_own_tasks', 'create_task', 'update_own_task', 'delete_own_task'],
moderator: ['view_own_tasks', 'view_all_tasks', 'create_task',
'update_own_task', 'delete_own_task', 'delete_any_task'],
admin: ['*'],
};
@Injectable({ providedIn: 'root' })
export class PermissionsService {
private authStore = inject(AuthStore);
can(permission: Permission): boolean {
const role = this.authStore.user()?.role;
if (!role) return false;
const perms = ROLE_PERMISSIONS[role] ?? [];
return perms.includes('*') || (perms as Permission[]).includes(permission);
}
canAny(...permissions: Permission[]): boolean {
return permissions.some(p => this.can(p));
}
canAll(...permissions: Permission[]): boolean {
return permissions.every(p => this.can(p));
}
}
// ── *appCan directive — show/hide based on permission ─────────────────────
// shared/directives/can.directive.ts
import { Directive, Input, TemplateRef, ViewContainerRef, OnInit, inject } from '@angular/core';
import { PermissionsService, Permission } from '../../core/services/permissions.service';
@Directive({ selector: '[appCan]', standalone: true })
export class CanDirective implements OnInit {
private tmpl = inject(TemplateRef<any>);
private vcr = inject(ViewContainerRef);
private perms = inject(PermissionsService);
@Input('appCan') permission!: Permission | Permission[];
ngOnInit(): void {
const permissions = Array.isArray(this.permission)
? this.permission
: [this.permission];
if (this.perms.canAny(...permissions)) {
this.vcr.createEmbeddedView(this.tmpl);
}
}
}
// ── *appRole directive — show/hide based on role ───────────────────────────
@Directive({ selector: '[appRole]', standalone: true })
export class RoleDirective implements OnInit {
private tmpl = inject(TemplateRef<any>);
private vcr = inject(ViewContainerRef);
private auth = inject(AuthStore);
@Input('appRole') roles!: string | string[];
ngOnInit(): void {
const roleList = Array.isArray(this.roles) ? this.roles : [this.roles];
if (this.auth.hasRole(roleList)) {
this.vcr.createEmbeddedView(this.tmpl);
}
}
}
// ── Usage in templates ────────────────────────────────────────────────────
// <button *appCan="'delete_any_task'" (click)="deleteTask(id)">Admin Delete</button>
// <section *appRole="'admin'">Admin Panel</section>
// <section *appRole="['admin', 'moderator']">Moderator Zone</section>
// <button *ngIf="permsService.can('create_task')" (click)="newTask()">Add Task</button>
How It Works
Step 1 — Role Is Extracted from JWT on Every Request
The Express auth middleware calls jwt.verify(token, secret) and attaches the decoded payload to req.user. The role embedded in the JWT payload is then available to all subsequent middleware and route handlers as req.user.role. No database lookup is needed to get the role — it is carried in the token itself. This is what makes JWT-based RBAC stateless and scalable.
Step 2 — Ownership Checks Prevent Horizontal Privilege Escalation
Role checks prevent vertical privilege escalation (a user accessing admin features). Ownership checks prevent horizontal privilege escalation (a user accessing another user’s data). The requireOwnerOrAdmin middleware queries the database to get the resource’s owner and compares it to req.user.sub. Even a valid JWT from a regular user cannot access another user’s tasks.
Step 3 — Angular Guards Prevent Navigation; They Are Not Security
An Angular route guard with roleGuard('admin') prevents the admin dashboard component from rendering for non-admins. But it provides zero security — any developer can disable it via the browser console. The guard exists purely for UX: preventing users from seeing confusing “403 Forbidden” pages. The API must enforce the same rules independently.
Step 4 — The *appCan Directive Hides UI Elements Declaratively
Rather than scattering *ngIf="authStore.user()?.role === 'admin'" throughout templates, the *appCan directive centralises permission logic. When roles or permissions change, updating ROLE_PERMISSIONS in PermissionsService is the only change needed. All templates using *appCan="'manage_users'" automatically reflect the updated permissions.
Step 5 — Permission Keys Future-Proof the System
Checking can('delete_any_task') is more future-proof than checking role === 'admin'. If a new moderator role is added with delete permissions, you update the permissions map and that is the only change needed — no template changes, no guard changes. Checking roles directly throughout the codebase means searching for every role === 'admin' and deciding whether the moderator should also have that access.
Common Mistakes
Mistake 1 — Only enforcing RBAC in Angular, not on the API
❌ Wrong — hiding the button does not prevent the API call:
// Angular template only:
<button *appRole="'admin'" (click)="deleteUser(id)">Delete</button>
// Any user can: fetch('/api/v1/users/42', { method: 'DELETE', headers: { Authorization: 'Bearer ...' } })
✅ Correct — enforce on both API and Angular:
// Express: router.delete('/:id', requireRole('admin'), asyncHandler(deleteUser));
// Angular: guard + directive — for UX only
Mistake 2 — Trusting role claims without verifying token signature
❌ Wrong — using role from manually decoded token without verification:
const decoded = JSON.parse(atob(token.split('.')[1]));
if (decoded.role === 'admin') { ... } // signature NOT verified — trivially forgeable!
✅ Correct — always verify with jwt.verify():
const decoded = jwt.verify(token, process.env.JWT_SECRET);
if (decoded.role === 'admin') { ... } // signature verified — tamper-proof
Mistake 3 — Not checking resource ownership (horizontal escalation)
❌ Wrong — any authenticated user can access any task by ID:
router.get('/:id', verifyAccessToken, asyncHandler(async (req, res) => {
const task = await Task.findById(req.params.id); // no ownership check!
res.json({ data: task }); // User B can read User A's tasks!
}));
✅ Correct — add ownership check:
router.get('/:id', verifyAccessToken, asyncHandler(async (req, res) => {
const task = await Task.findOne({ _id: req.params.id, user: req.user.sub });
if (!task) return res.status(404).json({ message: 'Task not found' });
res.json({ data: task });
}));
Quick Reference
| Task | Code |
|---|---|
| Require role (Express) | router.use(requireRole('admin')) |
| Require ownership | router.use(requireOwnerOrAdmin(getOwnerId)) |
| Check permission (Angular) | inject(PermissionsService).can('delete_any_task') |
| Show by permission | *appCan="'manage_users'" |
| Show by role | *appRole="'admin'" or *appRole="['admin', 'mod']" |
| Route role guard | canActivate: [authGuard, roleGuard('admin')] |
| Ownership + role | Ownership check with admin bypass |