From fddb109b345150b02a4a3c17c1964bcc9ffcaca9 Mon Sep 17 00:00:00 2001 From: Ishola Dev <111701092+Ishola001@users.noreply.github.com> Date: Mon, 31 Aug 2026 10:36:29 +0100 Subject: [PATCH 1/2] fix: Cache route permission checks in RBAC middleware (#1197) --- src/middleware/rbac.ts | 93 +++++++++++++++++++++++++++++++++++------- 1 file changed, 78 insertions(+), 15 deletions(-) diff --git a/src/middleware/rbac.ts b/src/middleware/rbac.ts index 9e7dbdb4..d0cc5a9e 100644 --- a/src/middleware/rbac.ts +++ b/src/middleware/rbac.ts @@ -1,4 +1,5 @@ import { NextResponse } from 'next/server'; + import type { NextRequest } from 'next/server'; import { UserRole } from '@/types/api'; import { isAtLeastRole } from '@/lib/auth/acl'; @@ -14,33 +15,95 @@ const ROUTE_PERMISSIONS: Record = { '/profile': UserRole.STUDENT, }; +type RouteDecision = 'allow' | 'login' | 'unauthorized'; + +class RoutePermissionCache { + private store = new Map(); + private readonly TTL_MS = 60_000; + private readonly MAX_SIZE = 1000; + + get(key: string): RouteDecision | null { + const entry = this.store.get(key); + if (!entry) return null; + if (Date.now() > entry.expiry) { + this.store.delete(key); + return null; + } + return entry.decision; + } + + set(key: string, decision: RouteDecision): void { + if (this.store.size >= this.MAX_SIZE) { + const oldestKey = this.store.keys().next().value; + if (oldestKey !== undefined) { + this.store.delete(oldestKey); + } + } + this.store.set(key, { decision, expiry: Date.now() + this.TTL_MS }); + } + + clear(): void { + this.store.clear(); + } +} + +const routePermissionCache = new RoutePermissionCache(); + +function getSessionId(request: NextRequest): string { + return request.cookies.get('session')?.value ?? 'anonymous'; +} + +function getCacheKey( + pathname: string, + userRole: UserRole | null, + sessionId: string, +): string { + return `${sessionId}:${pathname}:${userRole ?? 'none'}'; +} + +function decisionToResponse( + decision: RouteDecision, + request: NextRequest, +): NextResponse | null { + if (decision === 'allow') return null; + if (decision === 'login') { + return NextResponse.redirect(new URL('/login', request.url)); + } + return NextResponse.redirect(new URL('/unauthorized', request.url)); +} + /** - * RBAC Helper for Middleware + * RBAT Helper for Middleware */ export function checkRoutePermission( request: NextRequest, userRole: UserRole | null, ): NextResponse | null { const { pathname } = request.nextUrl; + const sessionId = getSessionId(request); + const cacheKey = getCacheKey(pathname, userRole, sessionId); + + const cachedDecision = routePermissionCache.get(cacheKey); + if (cachedDecision) { + return decisionToResponse(cachedDecision, request); + } // Find the required role for the current path const requiredRole = Object.entries(ROUTE_PERMISSIONS).find( ([path]) => pathname === path || pathname.startsWith(`${path}/`), - )?.[1]; + )?[1]; + let decision: RouteDecision; if (!requiredRole) { - return null; // No specific role required for this route - } - - // If no user role is provided, they are probably not logged in - if (!userRole) { - return NextResponse.redirect(new URL('/login', request.url)); + decision = 'allow'; + } else if (!userRole) { + decision = 'login'; + } else if (!isAtLeastRole(userRole, requiredRole)) { + decision = 'unauthorized'; + } else { + decision = 'allow'; } - if (!isAtLeastRole(userRole, requiredRole)) { - // Redirect to an unauthorized page or dashboard - return NextResponse.redirect(new URL('/unauthorized', request.url)); - } - - return null; // Access granted -} + routePermissionCache.set(cacheKey, decision); + return decisionToResponse(decision, request); +} \ No newline at end of file From 5354cb03257b921eb62cc0ad35686c6afabe4358 Mon Sep 17 00:00:00 2001 From: Ishola Dev <111701092+Ishola001@users.noreply.github.com> Date: Mon, 31 Aug 2026 10:36:30 +0100 Subject: [PATCH 2/2] fix: Cache route permission checks in RBAC middleware (#1197) --- src/lib/auth/acl.ts | 25 ++++++++++++++++++++----- 1 file changed, 20 insertions(+), 5 deletions(-) diff --git a/src/lib/auth/acl.ts b/src/lib/auth/acl.ts index 3aea0576..82e42d26 100644 --- a/src/lib/auth/acl.ts +++ b/src/lib/auth/acl.ts @@ -24,13 +24,27 @@ export const ROLES_PERMISSIONS: Record = { GUEST: [Permission.COURSE_VIEW], }; +const ROLE_HIERARCHY = [UserRole.GUEST, UserRole.STUDENT, UserRole.INSTRUCTOR, UserRole.ADMIN] as const; + +const roleHierarchyIndex = new Map( + ROLE_HIERARCHY.map((role, index) => [role, index]),); + +const rolePermissionsCache = new Map(); + +function getPermissionsForRole(role: UserRole): Permission[] { + if (!rolePermissionsCache.has(role)) { + rolePermissionsCache.set(role, ROLES_PERMISSIONS[role] ?? []); + } + return rolePermissionsCache.get(role)!; +} + /** - * Check if a user (or any object that contains a role) has a specific permission. + * Check if a user (or any object that contains a role) has a specific permission. */ export function hasPermission(user: RoleHolder | null | undefined, permission: Permission): boolean { if (!user) return false; - const permissions = ROLES_PERMISSIONS[user.role] ?? []; + const permissions = getPermissionsForRole(user.role); return permissions.includes(permission); } @@ -75,9 +89,10 @@ export function isAtLeast(user: RoleHolder | null | undefined, role: UserRole): export function isAtLeastRole(userRole: UserRole | null | undefined, role: UserRole): boolean { if (!userRole) return false; - const hierarchy = [UserRole.GUEST, UserRole.STUDENT, UserRole.INSTRUCTOR, UserRole.ADMIN]; - const userRoleIndex = hierarchy.indexOf(userRole); - const requiredRoleIndex = hierarchy.indexOf(role); + const userRoleIndex = roleHierarchyIndex.get(userRole); + const requiredRoleIndex = roleHierarchyIndex.get(role); + + if (userRoleIndex === undefined || requiredRoleIndex === undefined) return false; return userRoleIndex >= requiredRoleIndex; } \ No newline at end of file