Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 22 additions & 5 deletions src/lib/auth/acl.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,21 @@ export const ROLES_PERMISSIONS: Record<UserRole, Permission[]> = {
GUEST: [Permission.COURSE_VIEW],
};

const ROLE_HIERARCHY = [UserRole.GUEST, UserRole.STUDENT, UserRole.INSTRUCTOR, UserRole.ADMIN] as const;

const roleHierarchyIndex = new Map<UserRole, number>(
ROLE_HIERARCHY.map((role, index) => [role, index]),
);

const rolePermissionsCache = new Map<UserRole, Permission[]>();

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<string, boolean>();

Expand All @@ -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;
Expand All @@ -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;
Expand Down Expand Up @@ -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);
Expand All @@ -119,5 +135,6 @@ export function isAtLeastRole(userRole: UserRole | null | undefined, role: UserR
*/
export function clearAclCaches(): void {
permissionCache.clear();
rolePermissionsCache.clear();
roleHierarchyCache.clear();
}
89 changes: 76 additions & 13 deletions src/middleware/rbac.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -14,6 +15,63 @@ const ROUTE_PERMISSIONS: Record<string, UserRole> = {
'/profile': UserRole.STUDENT,
};

type RouteDecision = 'allow' | 'login' | 'unauthorized';

class RoutePermissionCache {
private store = new Map<string, { decision: RouteDecision; expiry: number }>();
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
*/
Expand All @@ -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);
}
Loading