diff --git a/src/lib/auth/acl.ts b/src/lib/auth/acl.ts index a204574c..83a7df3d 100644 --- a/src/lib/auth/acl.ts +++ b/src/lib/auth/acl.ts @@ -24,6 +24,21 @@ 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)!; +} + // Cache for permission lookups to avoid repeated evaluation const permissionCache = new Map(); @@ -35,7 +50,7 @@ function getPermissionCacheKey(role: UserRole, permission: Permission): string { } /** - * 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; @@ -44,7 +59,7 @@ export function hasPermission(user: RoleHolder | null | undefined, permission: P const cached = permissionCache.get(cacheKey); if (cached !== undefined) return cached; - const permissions = ROLES_PERMISSIONS[user.role] ?? []; + const permissions = getPermissionsForRole(user.role); const result = permissions.includes(permission); permissionCache.set(cacheKey, result); return result; @@ -105,9 +120,10 @@ export function isAtLeastRole(userRole: UserRole | null | undefined, role: UserR const cached = roleHierarchyCache.get(cacheKey); if (cached !== undefined) return cached; - 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; const result = userRoleIndex >= requiredRoleIndex; roleHierarchyCache.set(cacheKey, result); @@ -119,5 +135,6 @@ export function isAtLeastRole(userRole: UserRole | null | undefined, role: UserR */ export function clearAclCaches(): void { permissionCache.clear(); + rolePermissionsCache.clear(); roleHierarchyCache.clear(); } \ No newline at end of file diff --git a/src/middleware/rbac.ts b/src/middleware/rbac.ts index 9e7dbdb4..6cc85aa5 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,6 +15,63 @@ 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 */ @@ -22,25 +80,30 @@ export function checkRoutePermission( 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]; + 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