Role-Based Access Control — Permissions, RBAC Middleware, and Angular Directives

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
Note: Always enforce permissions on the server side. Angular route guards and template directives are UX conveniences — a technical user can bypass them entirely by crafting API requests in the browser console. The Express middleware is the real security boundary. Even if an admin button is hidden in the Angular template, anyone can call DELETE /api/v1/users/42 directly if the endpoint is not protected. API-level RBAC is non-negotiable.
Tip: Build a permissions service in Angular that maps roles to permission keys — rather than checking 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'.
Warning: Do not trust the role from the JWT for resource-level authorisation on the server. A task’s 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

🧠 Test Yourself

An Angular template hides the “Delete User” button using *appRole="'admin'". A regular user opens the browser console and sends a DELETE request to /api/v1/users/42 with their valid JWT. What happens?