diff options
| author | joonhoekim <26rote@gmail.com> | 2025-12-02 09:48:23 +0900 |
|---|---|---|
| committer | joonhoekim <26rote@gmail.com> | 2025-12-02 09:48:23 +0900 |
| commit | 0964c60a86b2ca6ad24567567dc8a893944b33c2 (patch) | |
| tree | db190e625c16eb2be2aef067b362a7bee31e2ba3 | |
| parent | 4b5880064e2362baf85c91f33b2b44baecea3a7f (diff) | |
(김준회) 권한관리 서비스 구현
| -rw-r--r-- | components/common/permission-checker.tsx | 2 | ||||
| -rw-r--r-- | lib/nonsap/auth-service.ts | 168 | ||||
| -rw-r--r-- | lib/nonsap/db.ts | 3 |
3 files changed, 127 insertions, 46 deletions
diff --git a/components/common/permission-checker.tsx b/components/common/permission-checker.tsx index 209e0022..51d924be 100644 --- a/components/common/permission-checker.tsx +++ b/components/common/permission-checker.tsx @@ -27,7 +27,7 @@ export function PermissionChecker({ authorized, message }: PermissionCheckerProp // Optional: Show success toast only if explicitly needed, // but usually we don't show toast for success to avoid noise. // Uncomment for debugging: - toast.success("Authorized", { description: "Access granted.", duration: 1000 }); + toast.success("Authorized", { description: "Access granted.", duration: 5000 }); } }, [authorized, message, pathname]); diff --git a/lib/nonsap/auth-service.ts b/lib/nonsap/auth-service.ts index 5a338eea..7bfc7f12 100644 --- a/lib/nonsap/auth-service.ts +++ b/lib/nonsap/auth-service.ts @@ -1,7 +1,7 @@ import { headers } from 'next/headers'; +import { unstable_cache } from 'next/cache'; import db from '@/db/db'; import { users } from '@/db/schema/users'; -import { organization } from '@/db/schema/knox/organization'; import { eq } from 'drizzle-orm'; import { getAllScreens, @@ -16,8 +16,59 @@ interface AuthCheckResult { 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<string, string | null> = {}; + 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<string | null> { - const headerList = headers(); + const headerList = await headers(); return headerList.get('x-pathname') || null; } @@ -36,6 +87,7 @@ export async function verifyNonsapPermission( // 1. URL 결정 const targetUrl = url || await getCurrentUrlFromHeaders(); if (!targetUrl) { + // console.log(`[AuthDebug] URL Missing. User: ${userId}`); return { authorized: false, message: "URL을 확인할 수 없습니다. URL 인자를 명시해주세요." }; } @@ -43,6 +95,8 @@ export async function verifyNonsapPermission( // 예: /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(); @@ -57,28 +111,28 @@ export async function verifyNonsapPermission( 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: "권한 체크가 필요 없는 화면입니다." }; } - // 3. 사용자 정보 조회 (Postgres) - const user = await db.query.users.findFirst({ - where: eq(users.id, userId), - columns: { - nonsapUserId: true, - deptCode: true, - employeeNumber: true, - } - }); + // 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: "사용자 정보를 찾을 수 없습니다." }; } @@ -87,89 +141,115 @@ export async function verifyNonsapPermission( // 5. 권한 검증 (Merging) const finalAuth = { - SEARCH: 'N', ADD: 'N', DEL: 'N', SAVE: 'N', PRINT: 'N', DOWN: 'N', UP: 'N', APPROVAL: 'N', PREV: 'N', NEXT: 'N' + 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<string, string | null> | 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; + if (auth.ACSR_ID === user.nonsapUserId) { + isMatch = true; + matchType = 'USER'; + } } else if (auth.ACSR_GB_CD === 'R') { // 역할 매핑 - if (myRoles.some(r => r.ROLE_ID === auth.ACSR_ID)) isMatch = true; + 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; + if (auth.ACSR_ID === user.deptCode) { + isMatch = true; + matchType = 'DEPT'; + } } else if (auth.ACSR_GB_CD === 'E') { // 부서 매핑 (하위 포함 - 상위 부서로 거슬러 올라가며 확인) - if (user.deptCode) { + 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 org = await db.query.organization.findFirst({ - where: eq(organization.departmentCode, currentDept), - columns: { uprDepartmentCode: true } - }); + // 캐시된 계층구조 맵에서 상위 부서 조회 + const uprDept = deptHierarchy[currentDept]; - if (!org || !org.uprDepartmentCode || org.uprDepartmentCode === 'TOP' || org.uprDepartmentCode === currentDept) { + if (!uprDept || uprDept === 'TOP' || uprDept === currentDept) { break; } - currentDept = org.uprDepartmentCode; + currentDept = uprDept; depth++; } } } if (isMatch) { + // console.log(`[AuthDebug] Match Found! Rule: [${matchType}] ${auth.ACSR_ID}`); // 권한 합치기 (OR) - if (auth.AUTH_CD_SEARCH === 'Y') finalAuth.SEARCH = 'Y'; - if (auth.AUTH_CD_ADD === 'Y') finalAuth.ADD = 'Y'; - if (auth.AUTH_CD_DEL === 'Y') finalAuth.DEL = 'Y'; - if (auth.AUTH_CD_SAVE === 'Y') finalAuth.SAVE = 'Y'; - if (auth.AUTH_CD_PRINT === 'Y') finalAuth.PRINT = 'Y'; - if (auth.AUTH_CD_DOWN === 'Y') finalAuth.DOWN = 'Y'; - if (auth.AUTH_CD_UP === 'Y') finalAuth.UP = 'Y'; - if (auth.AUTH_CD_APPROVAL === 'Y') finalAuth.APPROVAL = 'Y'; - if (auth.AUTH_CD_PREV === 'Y') finalAuth.PREV = 'Y'; - if (auth.AUTH_CD_NEXT === 'Y') finalAuth.NEXT = 'Y'; + 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 === 'Y'; - case 'ADD': return finalAuth.ADD === 'Y'; - case 'DEL': return finalAuth.DEL === 'Y'; - case 'SAVE': return finalAuth.SAVE === 'Y'; - case 'PRINT': return finalAuth.PRINT === 'Y'; - case 'DOWN': return finalAuth.DOWN === 'Y'; - case 'UP': return finalAuth.UP === 'Y'; - case 'APPROVAL': return finalAuth.APPROVAL === 'Y'; - case 'PREV': return finalAuth.PREV === 'Y'; - case 'NEXT': return finalAuth.NEXT === 'Y'; + 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 }; } } diff --git a/lib/nonsap/db.ts b/lib/nonsap/db.ts index 2b3cbda3..9ac5ec0a 100644 --- a/lib/nonsap/db.ts +++ b/lib/nonsap/db.ts @@ -4,6 +4,7 @@ import { unstable_cache } from 'next/cache'; // Types export interface ScreenEvcp { SCR_ID: string; + SCR_NM: string; SCR_URL: string; SCRT_CHK_YN: string; // 'Y' | 'N' DEL_YN: string; // 'Y' | 'N' @@ -13,7 +14,7 @@ export interface ScreenAuthEvcp { SCR_ID: string; ACSR_GB_CD: string; // 'U' | 'R' | 'D' | 'E' ACSR_ID: string; - AUTH_CD_SEARCH: string; // 'Y' | 'N' + AUTH_CD_SEARCH: string; // '1' | '0' AUTH_CD_ADD: string; AUTH_CD_DEL: string; AUTH_CD_SAVE: string; |
