diff options
| -rw-r--r-- | app/[lng]/evcp/(evcp)/layout.tsx | 32 | ||||
| -rw-r--r-- | components/common/permission-checker.tsx | 36 | ||||
| -rw-r--r-- | lib/nonsap/auth-service.ts | 175 | ||||
| -rw-r--r-- | lib/nonsap/db.ts | 68 | ||||
| -rw-r--r-- | lib/nonsap/nonsap-auth-plan.md | 181 | ||||
| -rw-r--r-- | middleware.ts | 12 |
6 files changed, 501 insertions, 3 deletions
diff --git a/app/[lng]/evcp/(evcp)/layout.tsx b/app/[lng]/evcp/(evcp)/layout.tsx index 82b53307..7fe7f3e7 100644 --- a/app/[lng]/evcp/(evcp)/layout.tsx +++ b/app/[lng]/evcp/(evcp)/layout.tsx @@ -1,12 +1,40 @@ import { ReactNode } from 'react'; import { Header } from '@/components/layout/Header'; import { SiteFooter } from '@/components/layout/Footer'; +import { getServerSession } from "next-auth"; +import { authOptions } from "@/app/api/auth/[...nextauth]/route"; +import { verifyNonsapPermission } from "@/lib/nonsap/auth-service"; +import { PermissionChecker } from "@/components/common/permission-checker"; + +export default async function EvcpLayout({ children }: { children: ReactNode }) { + const session = await getServerSession(authOptions); + + let isAuthorized = true; + let authMessage = ""; + + // Only check permission if user is logged in + if (session?.user?.id) { + try { + const result = await verifyNonsapPermission( + parseInt(session.user.id), + ['SEARCH'] + ); + isAuthorized = result.authorized; + authMessage = result.message || ""; + } catch (error) { + console.error("Permission check failed:", error); + // Default to true in case of error to avoid blocking access due to system error + // but logic could be changed to false for strict security + isAuthorized = true; + authMessage = "Permission check error"; + } + } -export default function EvcpLayout({ children }: { children: ReactNode }) { return ( <div className="relative flex min-h-svh flex-col bg-background"> {/* <div className="relative flex min-h-svh flex-col bg-slate-100 "> */} <Header /> + <PermissionChecker authorized={isAuthorized} message={authMessage} /> <main className="flex flex-1 flex-col"> <div className='container-wrapper'> {children} @@ -15,4 +43,4 @@ export default function EvcpLayout({ children }: { children: ReactNode }) { <SiteFooter/> </div> ); -}
\ No newline at end of file +} diff --git a/components/common/permission-checker.tsx b/components/common/permission-checker.tsx new file mode 100644 index 00000000..209e0022 --- /dev/null +++ b/components/common/permission-checker.tsx @@ -0,0 +1,36 @@ +"use client"; + +import { useEffect } from "react"; +import { toast } from "sonner"; +import { usePathname } from "next/navigation"; + +interface PermissionCheckerProps { + authorized: boolean; + message?: string; +} + +export function PermissionChecker({ authorized, message }: PermissionCheckerProps) { + const pathname = usePathname(); + + useEffect(() => { + // Only show toast if authorization failed + if (!authorized) { + toast.error("Permission Denied", { + description: message || "You do not have permission to view this page. (Dev Mode: Viewing anyway)", + duration: 5000, + action: { + label: "Close", + onClick: () => toast.dismiss(), + }, + }); + } else { + // 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 }); + } + }, [authorized, message, pathname]); + + return null; +} + diff --git a/lib/nonsap/auth-service.ts b/lib/nonsap/auth-service.ts new file mode 100644 index 00000000..5a338eea --- /dev/null +++ b/lib/nonsap/auth-service.ts @@ -0,0 +1,175 @@ +import { headers } from 'next/headers'; +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, + getAuthsByScreenId, + getUserRoles, +} from './db'; + +export type AuthAction = 'SEARCH' | 'ADD' | 'DEL' | 'SAVE' | 'PRINT' | 'DOWN' | 'UP' | 'APPROVAL' | 'PREV' | 'NEXT'; + +interface AuthCheckResult { + authorized: boolean; + message?: string; +} + +async function getCurrentUrlFromHeaders(): Promise<string | null> { + const headerList = 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<AuthCheckResult> { + // 1. URL 결정 + const targetUrl = url || await getCurrentUrlFromHeaders(); + if (!targetUrl) { + return { authorized: false, message: "URL을 확인할 수 없습니다. URL 인자를 명시해주세요." }; + } + + // URL 정규화 (언어 코드 제거) + // 예: /en/partners/dashboard -> /partners/dashboard + const normalizedUrl = targetUrl.replace(/^\/[a-z]{2}(\/|$)/, '/'); + + // 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 + return { authorized: true, message: "관리되지 않는 페이지입니다." }; + } + + if (screen.DEL_YN === 'Y') { + return { authorized: false, message: "삭제된 화면입니다." }; + } + + if (screen.SCRT_CHK_YN === 'N') { + 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, + } + }); + + if (!user) { + return { authorized: false, message: "사용자 정보를 찾을 수 없습니다." }; + } + + // 4. 권한 정보 조회 + const auths = await getAuthsByScreenId(screen.SCR_ID); + + // 5. 권한 검증 (Merging) + const finalAuth = { + SEARCH: 'N', ADD: 'N', DEL: 'N', SAVE: 'N', PRINT: 'N', DOWN: 'N', UP: 'N', APPROVAL: 'N', PREV: 'N', NEXT: 'N' + }; + + // Role 정보 조회 + const myRoles = await getUserRoles(user.employeeNumber || user.nonsapUserId || ''); + + for (const auth of auths) { + let isMatch = false; + + if (auth.ACSR_GB_CD === 'U') { + // 사용자 직접 매핑 + if (auth.ACSR_ID === user.nonsapUserId) isMatch = true; + } else if (auth.ACSR_GB_CD === 'R') { + // 역할 매핑 + if (myRoles.some(r => r.ROLE_ID === auth.ACSR_ID)) isMatch = true; + } else if (auth.ACSR_GB_CD === 'D') { + // 부서 매핑 (정확히 일치) + if (auth.ACSR_ID === user.deptCode) isMatch = true; + } else if (auth.ACSR_GB_CD === 'E') { + // 부서 매핑 (하위 포함 - 상위 부서로 거슬러 올라가며 확인) + if (user.deptCode) { + let currentDept = user.deptCode; + let depth = 0; + // 최대 20단계까지만 확인 (무한루프 방지) + while (currentDept && depth < 20) { + if (currentDept === auth.ACSR_ID) { + isMatch = true; + break; + } + + // 상위 부서 조회 + // 성능 최적화를 위해 캐싱 고려 가능하나, 일단 직접 조회 + const org = await db.query.organization.findFirst({ + where: eq(organization.departmentCode, currentDept), + columns: { uprDepartmentCode: true } + }); + + if (!org || !org.uprDepartmentCode || org.uprDepartmentCode === 'TOP' || org.uprDepartmentCode === currentDept) { + break; + } + currentDept = org.uprDepartmentCode; + depth++; + } + } + } + + if (isMatch) { + // 권한 합치기 (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'; + } + } + + // 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'; + default: return false; + } + }; + + if (options.logic === 'OR') { + const authorized = requiredActions.some(action => check(action)); + return { authorized }; + } else { + const authorized = requiredActions.every(action => check(action)); + return { authorized }; + } +} diff --git a/lib/nonsap/db.ts b/lib/nonsap/db.ts new file mode 100644 index 00000000..2b3cbda3 --- /dev/null +++ b/lib/nonsap/db.ts @@ -0,0 +1,68 @@ +import { oracleKnex } from '@/lib/oracle-db/db'; +import { unstable_cache } from 'next/cache'; + +// Types +export interface ScreenEvcp { + SCR_ID: string; + SCR_URL: string; + SCRT_CHK_YN: string; // 'Y' | 'N' + DEL_YN: string; // 'Y' | 'N' +} + +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_ADD: string; + AUTH_CD_DEL: string; + AUTH_CD_SAVE: string; + AUTH_CD_PRINT: string; + AUTH_CD_DOWN: string; + AUTH_CD_UP: string; + AUTH_CD_APPROVAL: string; + AUTH_CD_PREV: string; + AUTH_CD_NEXT: string; +} + +export interface RoleEvcp { + ROLE_ID: string; + ROLE_NM: string; +} + +export interface RoleRelEvcp { + ROLE_ID: string; + EMPNO: string; +} + +// Fetch functions with cache + +export const getAllScreens = unstable_cache( + async () => { + const result = await oracleKnex('CMCVW_SCR_EVCP') + .select('*'); + return result as ScreenEvcp[]; + }, + ['nonsap-all-screens'], + { revalidate: 60 } +); + +export const getAuthsByScreenId = unstable_cache( + async (scrId: string) => { + const result = await oracleKnex('CMCVW_SCR_AUTH_EVCP') + .where('SCR_ID', scrId); + return result as ScreenAuthEvcp[]; + }, + ['nonsap-auths-by-screen-id'], + { revalidate: 60 } +); + +export const getUserRoles = unstable_cache( + async (empNo: string) => { + const result = await oracleKnex('CMCVW_ROLE_REL_EVCP') + .where('EMPNO', empNo); + return result as RoleRelEvcp[]; + }, + ['nonsap-user-roles'], + { revalidate: 60 } +); diff --git a/lib/nonsap/nonsap-auth-plan.md b/lib/nonsap/nonsap-auth-plan.md new file mode 100644 index 00000000..fab5e7da --- /dev/null +++ b/lib/nonsap/nonsap-auth-plan.md @@ -0,0 +1,181 @@ +# 외부 시스템(NONSAP) 연동 권한 관리 구현 계획 (v2) + +본 문서는 외부 오라클 시스템(NONSAP)의 뷰 테이블을 연동하여 프로젝트의 권한 관리를 처리하기 위한 구현 계획입니다. + +## 1. 개요 + +- **목표**: 외부 시스템에서 관리되는 화면 및 권한 정보를 조회하여, 웹 애플리케이션의 페이지 접근 제어 및 기능별 권한 제어를 수행합니다. +- **환경**: Node.js Runtime (Not Edge/FaaS). +- **DB 연결**: `lib/oracle-db/db.ts`의 `oracleKnex`를 사용하여 오라클 DB에 접속합니다. + +## 2. 데이터 구조 및 관계 + +제공된 4개의 뷰 테이블을 사용하여 권한을 판단합니다. + +### 2.1 테이블 정의 + +1. **`CMCVW_SCR_EVCP` (화면 목록)** + * **역할**: 시스템의 모든 화면과 해당 URL을 정의합니다. + * **주요 컬럼**: `SCR_ID` (PK), `SCR_URL`, `SCRT_CHK_YN`, `DEL_YN` + +2. **`CMCVW_SCR_AUTH_EVCP` (화면 권한)** + * **역할**: 각 화면(`SCR_ID`)에 대해 접근 가능한 대상(`ACSR_ID`)과 권한 상세(`AUTH_CD`)를 정의합니다. + * **주요 컬럼**: `SCR_ID` (FK), `ACSR_GB_CD` (접근자 구분), `ACSR_ID`, `AUTH_CD_*` (권한 플래그) + * **ACSR_GB_CD 유형**: + * `'U'`: 사용자 (User) + * `'R'`: 역할 (Role) + * `'D'`: 부서 (Department - 하위 미포함) + * `'E'`: 부서 (Department - 하위 포함) + +3. **`CMCVW_ROLE_EVCP` (역할 목록)** + * **역할**: 시스템에 존재하는 역할(Role)을 정의합니다. + * **주요 컬럼**: `ROLE_ID` (PK), `ROLE_NM` + +4. **`CMCVW_ROLE_REL_EVCP` (역할-사용자 매핑)** + * **역할**: 각 역할(`ROLE_ID`)에 소속된 사용자(`EMPNO`)를 정의합니다. + * **주요 컬럼**: `ROLE_ID` (FK), `EMPNO` + +## 3. 구현 상세 + +### 3.1 데이터 페칭 및 캐싱 전략 + +* **DB Client**: `lib/oracle-db/db.ts`의 `oracleKnex` 사용. +* **캐싱**: `unstable_cache` (Next.js)를 사용하여 DB 부하를 줄이고 응답 속도를 확보합니다. (TTL: 60초) + +### 3.2 권한 검증 함수 설계 + +URL 인자를 선택적으로 받을 수 있도록 설계하여, 호출 편의성을 높입니다. + +```typescript +// lib/nonsap/auth-service.ts + +export type AuthAction = 'SEARCH' | 'ADD' | 'DEL' | 'SAVE' | 'PRINT' | 'DOWN' | 'UP' | 'APPROVAL' | 'PREV' | 'NEXT'; + +/** + * 위 Action의 의미는 아래와 같다. + * - 조회 + * - 추가 + * - 삭제 + * - 저장 + * - 출력 + * - 파일받기 + * - 파일올림 + * - 결재상신 + * - PrevPage + * - NextPage + */ + +interface AuthCheckResult { + authorized: boolean; + message?: string; +} + +/** + * NONSAP 권한 검증 함수 + * @param empNo 사용자 사번 + * @param requiredActions 필요한 권한 목록 (예: ['SEARCH']) + * @param url (Optional) 검증할 URL. 생략 시 현재 요청의 URL을 자동으로 감지 시도. + */ +export async function verifyNonsapPermission( + empNo: string, + requiredActions: AuthAction[], + url?: string, + options?: { logic?: 'AND' | 'OR' } // default: 'AND' +): Promise<AuthCheckResult> { + // 1. URL 결정 + const targetUrl = url || await getCurrentUrlFromHeaders(); + if (!targetUrl) { + throw new Error("URL을 확인할 수 없습니다. URL 인자를 명시해주세요."); + } + + // 2. 권한 검증 로직 수행 (oracleKnex 사용) + // ... +} + +// Helper: 헤더에서 URL 추출 (Middleware 사전 작업 필요) +import { headers } from 'next/headers'; +async function getCurrentUrlFromHeaders(): Promise<string | null> { + const headerList = headers(); + // Middleware에서 'x-pathname' 헤더를 심어주어야 함 + return headerList.get('x-pathname') || null; +} +``` + +### 3.3 권한 검증 로직 (Core Logic) + +1. **화면 식별**: + * `CMCVW_SCR_EVCP`에서 `SCR_URL`이 `targetUrl`과 일치하는 행 조회 (`oracleKnex` 사용). + * `DEL_YN` == 'N', `SCRT_CHK_YN` == 'Y' 확인. + * 일치 항목 없으면: 관리되지 않는 페이지로 간주 -> **Pass**. + +2. **권한 확인 (ACSR_GB_CD 유형별 처리)**: + * `CMCVW_SCR_AUTH_EVCP` 조회 (`SCR_ID` 기준). 조회된 각 권한 레코드(`authRecord`)에 대해 다음 로직 수행: + + * **Case U (User)**: `ACSR_GB_CD == 'U'` + * `authRecord.ACSR_ID`가 대상 사용자의 `nonsap_user_id` (Postgres `users` 테이블)와 일치하는지 확인. + + * **Case R (Role)**: `ACSR_GB_CD == 'R'` + * `authRecord.ACSR_ID` (Role ID)가 `CMCVW_ROLE_REL_EVCP` 테이블에서 해당 사용자의 사번(`EMPNO`)과 매핑되어 있는지 확인. + + * **Case D (Department - Exact)**: `ACSR_GB_CD == 'D'` + * `authRecord.ACSR_ID` (Dept Code)가 대상 사용자의 `deptCode` (Postgres `users` 테이블)와 정확히 일치하는지 확인. + + * **Case E (Department - Recursive)**: `ACSR_GB_CD == 'E'` + * 사용자의 부서(`user.deptCode`)부터 시작하여 상위 부서로 거슬러 올라가며 확인. + * **데이터 소스**: `db/schema/knox/organization.ts`의 `organization` 테이블 (Knox 조직도). + * **Logic**: + 1. `currentDeptCode = user.deptCode` + 2. Loop: + * `currentDeptCode == authRecord.ACSR_ID` 이면 **Match (권한 있음)**. + * `organization` 테이블에서 `departmentCode == currentDeptCode`인 행 조회. + * 상위 부서 코드(`uprDepartmentCode`) 확인. + * **종료 조건 (권한 없음)**: + * `uprDepartmentCode` is NULL + * `uprDepartmentCode == currentDeptCode` (자기 참조) + * `uprDepartmentCode == 'TOP'` + * `currentDeptCode = uprDepartmentCode` 로 갱신하고 반복. + +3. **Action 체크**: + * **Step 3-1. 권한 통합 (Merging)**: + * 위 단계에서 **Match**된 모든 권한 레코드들의 `AUTH_CD_*` 컬럼 값을 **OR 연산(Union)**하여 사용자의 '최종 보유 권한'을 산출합니다. + * 예: Role A(Search=Y, Save=N) + Role B(Search=N, Save=Y) => 최종(Search=Y, Save=Y). + * **Step 3-2. 요구사항 검증 (Checking)**: + * `requiredActions` 목록과 '최종 보유 권한'을 비교합니다. + * **AND 모드 (Default)**: `requiredActions`의 **모든** 항목에 대해 권한이 있어야 통과 (`every`). (보안상 안전) + * **OR 모드**: `requiredActions` 중 **하나라도** 권한이 있으면 통과 (`some`). (메뉴 노출 등 UI 제어용) + +## 4. 단계별 실행 계획 + +### Step 1: Middleware 설정 (URL 감지용) +* `middleware.ts`에서 요청 URL의 pathname을 `x-pathname` 헤더에 담아 넘겨주는 로직 추가. +* 이 헤더는 Server Component/Action에서 `headers()`를 통해 접근 가능해짐. + +### Step 2: 데이터 접근 레이어 구현 (`lib/nonsap/db.ts`) +* `oracleKnex`를 사용하여 4개 테이블을 조회하는 함수 구현. +* `unstable_cache` 적용. + +### Step 3: 권한 서비스 구현 (`lib/nonsap/auth-service.ts`) +* `verifyNonsapPermission` 함수 구현. +* URL 자동 감지 로직 포함. +* 부서 계층 구조 조회 로직(Case E) 구현 (Knox Organization 테이블 연동). + +### Step 4: 적용 (Layout/Page/Action) +* **페이지 접근 제어**: `app/[lng]/layout.tsx` 또는 각 Page 컴포넌트 상단에서 `verifyNonsapPermission(..., ['SEARCH'])` 호출. +* **기능 제어**: Server Action 내부에서 `verifyNonsapPermission(..., ['SAVE'])` 등 호출. + +## 5. 이슈 및 고려사항 + +1. **URL 매핑 정규화**: + * DB의 `SCR_URL`은 `/partners/dashboard` 형태이나, 실제 요청은 `/[lng]/partners/dashboard` 일 수 있음. + * Middleware 또는 Helper 함수에서 `lng` 파트(예: `/en`, `/ko`)를 제거하고 매칭하는 로직 필요. + +2. **Oracle DB 의존성**: + * `oracledb` 라이브러리는 Node.js 환경에서만 동작하므로, 권한 체크 로직은 반드시 **Server Component** 또는 **Server Action** 레벨에서 수행되어야 함. (Middleware에서 직접 DB 조회 불가) + +3. **부서 계층 정보**: + * Knox Organization 테이블(`db/schema/knox/organization.ts`)을 사용하여 상위 부서 정보를 조회합니다. + +## 구현 한 부분 + +evcp 경로의 layout.tsx에서 SEARCH 권한 있는지 authority check 함 +없으면 toast로 없다고 경고만 해줌 (nonsap측에 모든 설정 다 한건 아닐 테니)
\ No newline at end of file diff --git a/middleware.ts b/middleware.ts index e74cb653..6a825e6f 100644 --- a/middleware.ts +++ b/middleware.ts @@ -332,7 +332,17 @@ export async function middleware(request: NextRequest) { /** * 10. 위 조건에 걸리지 않았다면 그대로 Next.js로 넘긴다. */ - const response = NextResponse.next(); + /** + * 10. 위 조건에 걸리지 않았다면 그대로 Next.js로 넘긴다. + */ + const requestHeaders = new Headers(request.headers); + requestHeaders.set('x-pathname', pathname); + + const response = NextResponse.next({ + request: { + headers: requestHeaders, + }, + }); // 만료된 세션 쿠키 정리 (공개 경로 포함) if (token) { |
