summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorjoonhoekim <26rote@gmail.com>2025-12-02 09:48:23 +0900
committerjoonhoekim <26rote@gmail.com>2025-12-02 09:48:23 +0900
commit0964c60a86b2ca6ad24567567dc8a893944b33c2 (patch)
treedb190e625c16eb2be2aef067b362a7bee31e2ba3
parent4b5880064e2362baf85c91f33b2b44baecea3a7f (diff)
(김준회) 권한관리 서비스 구현
-rw-r--r--components/common/permission-checker.tsx2
-rw-r--r--lib/nonsap/auth-service.ts168
-rw-r--r--lib/nonsap/db.ts3
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;