import { headers } from 'next/headers'; import { unstable_cache } from 'next/cache'; import db from '@/db/db'; import { users } from '@/db/schema/users'; import { eq } from 'drizzle-orm'; import { getAllScreens, getAuthsByScreenId, getUserRoles, } from './db'; export type AuthAction = 'SEARCH' | 'ADD' | 'DEL' | 'SAVE' | 'PRINT' | 'DOWN' | 'UP' | 'APPROVAL' | 'PREV' | 'NEXT'; interface AuthCheckResult { authorized: boolean; message?: string; } // --- 캐시된 데이터 조회 함수들 --- /** * 부서 계층 구조 전체 캐싱 * organization 테이블이 약 1000건 미만이므로 전체를 메모리에 캐싱하여 조회 성능 최적화 * 재검증 주기: 1시간 (조직 구조는 자주 변경되지 않음) */ const getDepartmentHierarchy = unstable_cache( async () => { const allOrgs = await db.query.organization.findMany({ columns: { departmentCode: true, uprDepartmentCode: true, } }); // deptCode -> uprDeptCode 매핑 생성 const hierarchy: Record = {}; allOrgs.forEach(org => { if (org.departmentCode) { hierarchy[org.departmentCode] = org.uprDepartmentCode; } }); return hierarchy; }, ['nonsap-dept-hierarchy-all'], { revalidate: 3600 } ); /** * 사용자 정보 캐싱 * 권한 체크 시마다 호출되므로 짧게 캐싱 */ const getCachedUser = unstable_cache( async (userId: number) => { return await db.query.users.findFirst({ where: eq(users.id, userId), columns: { nonsapUserId: true, deptCode: true, employeeNumber: true, } }); }, ['nonsap-user-info-by-id'], { revalidate: 60 } // 1분 ); // ------------------------------- async function getCurrentUrlFromHeaders(): Promise { const headerList = await headers(); return headerList.get('x-pathname') || null; } /** * NONSAP 권한 검증 함수 * @param userId 사용자 ID (Postgres users.id) * @param requiredActions 필요한 권한 목록 (예: ['SEARCH']) * @param url (Optional) 검증할 URL. 생략 시 현재 요청의 URL을 자동으로 감지 시도. */ export async function verifyNonsapPermission( userId: number, requiredActions: AuthAction[], url?: string, options: { logic?: 'AND' | 'OR' } = { logic: 'AND' } ): Promise { // 1. URL 결정 const targetUrl = url || await getCurrentUrlFromHeaders(); if (!targetUrl) { // console.log(`[AuthDebug] URL Missing. User: ${userId}`); return { authorized: false, message: "URL을 확인할 수 없습니다. URL 인자를 명시해주세요." }; } // URL 정규화 (언어 코드 제거) // 예: /en/partners/dashboard -> /partners/dashboard const normalizedUrl = targetUrl.replace(/^\/[a-z]{2}(\/|$)/, '/'); // console.log(`[AuthDebug] Checking Permission. User: ${userId}, URL: ${normalizedUrl}, Required: ${requiredActions.join(',')}`); // 2. 화면 식별 const allScreens = await getAllScreens(); // DB URL과 현재 URL 매칭 (Longest Prefix Match) const screen = allScreens .filter(s => { const dbUrl = s.SCR_URL.startsWith('/') ? s.SCR_URL : `/${s.SCR_URL}`; // 정확히 일치하거나 하위 경로인 경우 return normalizedUrl === dbUrl || normalizedUrl.startsWith(`${dbUrl}/`); }) .sort((a, b) => b.SCR_URL.length - a.SCR_URL.length)[0]; if (!screen) { // 관리되지 않는 페이지 -> Pass // console.log(`[AuthDebug] Unmanaged Page. Allowed.`); return { authorized: true, message: "관리되지 않는 페이지입니다." }; } if (screen.DEL_YN === 'Y') { // console.log(`[AuthDebug] Deleted Screen.`); return { authorized: false, message: "삭제된 화면입니다." }; } if (screen.SCRT_CHK_YN === 'N') { // console.log(`[AuthDebug] Public Screen (No Check). Allowed.`); return { authorized: true, message: "권한 체크가 필요 없는 화면입니다." }; } // console.log(`[AuthDebug] Screen Identified: ${screen.SCR_ID} (${screen.SCR_NM})`); // 3. 사용자 정보 조회 (Cached) // const user = await db.query.users.findFirst({ ... }) -> 캐시 함수로 대체 const user = await getCachedUser(userId); if (!user) { // console.log(`[AuthDebug] User Not Found in DB.`); return { authorized: false, message: "사용자 정보를 찾을 수 없습니다." }; } // 4. 권한 정보 조회 const auths = await getAuthsByScreenId(screen.SCR_ID); // 5. 권한 검증 (Merging) const finalAuth = { SEARCH: '0', ADD: '0', DEL: '0', SAVE: '0', PRINT: '0', DOWN: '0', UP: '0', APPROVAL: '0', PREV: '0', NEXT: '0' }; // Role 정보 조회 const myRoles = await getUserRoles(user.employeeNumber || user.nonsapUserId || ''); const myRoleIds = myRoles.map(r => r.ROLE_ID); // console.log(`[AuthDebug] User Info - ID: ${user.nonsapUserId}, Dept: ${user.deptCode}, Roles: [${myRoleIds.join(', ')}]`); // 부서 계층구조 조회 (Lazy Loading: 부서 권한 체크가 필요한 경우에만 호출해도 되지만, // loop 안에서 호출하기보다 밖에서 한 번 호출해두는 것이 깔끔함. 캐시되므로 비용 저렴) // 하지만 auths에 'E' 타입이 없으면 아예 안 불러도 됨. let deptHierarchy: Record | null = null; // 'E' 타입 권한이 있는지 확인 const hasDeptHierarchyAuth = auths.some(a => a.ACSR_GB_CD === 'E'); if (hasDeptHierarchyAuth && user.deptCode) { deptHierarchy = await getDepartmentHierarchy(); } for (const auth of auths) { let isMatch = false; let matchType = ''; if (auth.ACSR_GB_CD === 'U') { // 사용자 직접 매핑 if (auth.ACSR_ID === user.nonsapUserId) { isMatch = true; matchType = 'USER'; } } else if (auth.ACSR_GB_CD === 'R') { // 역할 매핑 if (myRoleIds.includes(auth.ACSR_ID)) { isMatch = true; matchType = 'ROLE'; } } else if (auth.ACSR_GB_CD === 'D') { // 부서 매핑 (정확히 일치) if (auth.ACSR_ID === user.deptCode) { isMatch = true; matchType = 'DEPT'; } } else if (auth.ACSR_GB_CD === 'E') { // 부서 매핑 (하위 포함 - 상위 부서로 거슬러 올라가며 확인) if (user.deptCode && deptHierarchy) { let currentDept = user.deptCode; let depth = 0; // 최대 20단계까지만 확인 (무한루프 방지) while (currentDept && depth < 20) { if (currentDept === auth.ACSR_ID) { isMatch = true; matchType = 'DEPT_HIERARCHY'; break; } // 캐시된 계층구조 맵에서 상위 부서 조회 const uprDept = deptHierarchy[currentDept]; if (!uprDept || uprDept === 'TOP' || uprDept === currentDept) { break; } currentDept = uprDept; depth++; } } } if (isMatch) { // console.log(`[AuthDebug] Match Found! Rule: [${matchType}] ${auth.ACSR_ID}`); // 권한 합치기 (OR) if (auth.AUTH_CD_SEARCH === '1') finalAuth.SEARCH = '1'; if (auth.AUTH_CD_ADD === '1') finalAuth.ADD = '1'; if (auth.AUTH_CD_DEL === '1') finalAuth.DEL = '1'; if (auth.AUTH_CD_SAVE === '1') finalAuth.SAVE = '1'; if (auth.AUTH_CD_PRINT === '1') finalAuth.PRINT = '1'; if (auth.AUTH_CD_DOWN === '1') finalAuth.DOWN = '1'; if (auth.AUTH_CD_UP === '1') finalAuth.UP = '1'; if (auth.AUTH_CD_APPROVAL === '1') finalAuth.APPROVAL = '1'; if (auth.AUTH_CD_PREV === '1') finalAuth.PREV = '1'; if (auth.AUTH_CD_NEXT === '1') finalAuth.NEXT = '1'; } } // console.log(`[AuthDebug] Final Permissions:`, JSON.stringify(finalAuth)); // 6. 요구사항 검증 const check = (action: AuthAction) => { switch (action) { case 'SEARCH': return finalAuth.SEARCH === '1'; case 'ADD': return finalAuth.ADD === '1'; case 'DEL': return finalAuth.DEL === '1'; case 'SAVE': return finalAuth.SAVE === '1'; case 'PRINT': return finalAuth.PRINT === '1'; case 'DOWN': return finalAuth.DOWN === '1'; case 'UP': return finalAuth.UP === '1'; case 'APPROVAL': return finalAuth.APPROVAL === '1'; case 'PREV': return finalAuth.PREV === '1'; case 'NEXT': return finalAuth.NEXT === '1'; default: return false; } }; if (options.logic === 'OR') { const authorized = requiredActions.some(action => check(action)); // console.log(`[AuthDebug] Result (OR): ${authorized}`); return { authorized }; } else { const authorized = requiredActions.every(action => check(action)); // console.log(`[AuthDebug] Result (AND): ${authorized}`); return { authorized }; } }