Skip to content
Open
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
25 changes: 20 additions & 5 deletions src/lib/auth/acl.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,13 +24,27 @@ 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)!;
}

/**
* 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);
}

Expand Down Expand Up @@ -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;
}
93 changes: 78 additions & 15 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,33 +15,95 @@
'/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
* 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}/`),

Check failure on line 93 in src/middleware/rbac.ts

View workflow job for this annotation

GitHub Actions / type-check

Declaration or statement expected.

Check failure on line 93 in src/middleware/rbac.ts

View workflow job for this annotation

GitHub Actions / type-check

';' expected.

Check failure on line 93 in src/middleware/rbac.ts

View workflow job for this annotation

GitHub Actions / type-check

Unterminated regular expression literal.

Check failure on line 93 in src/middleware/rbac.ts

View workflow job for this annotation

GitHub Actions / type-check

';' expected.
)?.[1];
)?[1];

Check failure on line 94 in src/middleware/rbac.ts

View workflow job for this annotation

GitHub Actions / type-check

Declaration or statement expected.

Check failure on line 94 in src/middleware/rbac.ts

View workflow job for this annotation

GitHub Actions / type-check

Declaration or statement expected.

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