import { and, eq } from 'drizzle-orm'; import { NextRequest, NextResponse } from 'next/server'; import { auth } from '@/lib/auth'; import { db } from '@/lib/db'; import { portRoleOverrides, ports, userPortRoles, userProfiles } from '@/lib/db/schema'; import { type RolePermissions } from '@/lib/db/schema/users'; import { createAuditLog } from '@/lib/audit'; import { errorResponse } from '@/lib/errors'; import { logger } from '@/lib/logger'; // ─── Types ──────────────────────────────────────────────────────────────────── /** * Authenticated request context resolved by `withAuth`. * Passed as the second argument to every wrapped route handler. */ export interface AuthContext { userId: string; portId: string; portSlug: string; /** true for super_admin users — bypasses all permission checks. */ isSuperAdmin: boolean; /** * Effective permissions after role + port override deep-merge. * null when isSuperAdmin is true (super admins bypass permission checks). */ permissions: RolePermissions | null; user: { email: string; name: string; }; /** Client IP extracted from X-Forwarded-For header. */ ipAddress: string; userAgent: string; } export type RouteHandler = ( req: NextRequest, ctx: AuthContext, params: Record, ) => Promise>; // ─── deepMerge ─────────────────────────────────────────────────────────────── /** * Recursively merges `source` into `target`. * Used to apply port-level role permission overrides on top of the base role. */ export function deepMerge( target: Record, source: Record, ): Record { const result = { ...target }; for (const key of Object.keys(source)) { const sourceVal = source[key]; const targetVal = result[key]; if ( typeof sourceVal === 'object' && sourceVal !== null && !Array.isArray(sourceVal) && typeof targetVal === 'object' && targetVal !== null && !Array.isArray(targetVal) ) { result[key] = deepMerge( targetVal as Record, sourceVal as Record, ); } else { result[key] = sourceVal; } } return result; } // ─── withAuth ──────────────────────────────────────────────────────────────── /** * Validates the session, loads the user profile, resolves port context and * applies port-level role overrides before calling the inner handler. * * Usage: * ```ts * export const GET = withAuth(handler); * export const POST = withAuth(withPermission('clients', 'create', handler)); * ``` */ export function withAuth( handler: RouteHandler, ): ( req: NextRequest, routeContext: { params: Promise> }, ) => Promise { return async (req, routeContext) => { try { // 1. Validate session via Better Auth. const session = await auth.api.getSession({ headers: req.headers }); if (!session?.user) { return NextResponse.json({ error: 'Authentication required' }, { status: 401 }); } // 2. Load the CRM user profile (keyed on Better Auth user ID). const profile = await db.query.userProfiles.findFirst({ where: eq(userProfiles.userId, session.user.id), }); if (!profile || !profile.isActive) { return NextResponse.json({ error: 'Account disabled' }, { status: 403 }); } // 3. Resolve port context. // Port ID comes from the X-Port-Id header (set by the client after port // selection), falling back to the user's default port from preferences. // It NEVER comes from the request body — SECURITY-GUIDELINES.md §2.1. const portIdFromHeader = req.headers.get('X-Port-Id'); const portId = portIdFromHeader ?? (profile.preferences as { defaultPortId?: string } | null)?.defaultPortId ?? null; if (!portId && !profile.isSuperAdmin) { return NextResponse.json({ error: 'Port context required' }, { status: 400 }); } // 4. Resolve effective permissions. let permissions: RolePermissions | null = null; let portSlug = ''; if (!profile.isSuperAdmin && portId) { const portRole = await db.query.userPortRoles.findFirst({ where: and(eq(userPortRoles.userId, profile.userId), eq(userPortRoles.portId, portId)), with: { role: true, port: true, }, }); if (!portRole) { return NextResponse.json({ error: 'No access to this port' }, { status: 403 }); } permissions = { ...(portRole.role.permissions as RolePermissions) }; portSlug = (portRole.port as { slug: string } | null)?.slug ?? ''; // Apply port-specific role overrides (deep-merge on top of base role). const override = await db.query.portRoleOverrides.findFirst({ where: and( eq(portRoleOverrides.portId, portId), eq(portRoleOverrides.roleId, portRole.roleId), ), }); if (override?.permissionOverrides) { permissions = deepMerge( permissions as unknown as Record, override.permissionOverrides as Record, ) as RolePermissions; } } else if (profile.isSuperAdmin && portId) { // Super admin still needs portSlug for response context. const port = await db.query.ports.findFirst({ where: eq(ports.id, portId), }); portSlug = port?.slug ?? ''; } const ctx: AuthContext = { userId: profile.userId, portId: portId ?? '', portSlug, isSuperAdmin: profile.isSuperAdmin, permissions, user: { email: session.user.email, name: session.user.name, }, ipAddress: req.headers.get('x-forwarded-for')?.split(',')[0]?.trim() ?? 'unknown', userAgent: req.headers.get('user-agent') ?? 'unknown', }; const params = await routeContext.params; return await handler(req, ctx, params); } catch (error) { return errorResponse(error); } }; } // ─── withPermission ────────────────────────────────────────────────────────── /** * Wraps a route handler with a permission gate. * Denied attempts are logged to the audit trail. * * Compose inside withAuth: * ```ts * export const DELETE = withAuth(withPermission('clients', 'delete', handler)); * ``` */ export function withPermission( resource: keyof RolePermissions, action: string, handler: RouteHandler, ): RouteHandler { return async (req, ctx, params) => { if (!ctx.isSuperAdmin) { const resourcePerms = ctx.permissions?.[resource] as Record | undefined; if (!resourcePerms || !resourcePerms[action]) { logger.warn({ userId: ctx.userId, resource, action }, 'Permission denied'); // Log the denied attempt — fire-and-forget; audit must never block response. void createAuditLog({ userId: ctx.userId, portId: ctx.portId, action: 'permission_denied', entityType: resource, entityId: '', metadata: { attemptedAction: action }, ipAddress: ctx.ipAddress, userAgent: ctx.userAgent, }); return NextResponse.json({ error: 'Insufficient permissions' }, { status: 403 }); } } return handler(req, ctx, params); }; }