diff --git a/.cursorrules b/.cursorrules index e2fa0458..5f89313e 100644 --- a/.cursorrules +++ b/.cursorrules @@ -855,3 +855,95 @@ opacity-50 cursor-not-allowed" - 이모지 사용 금지 (명시적 요청 없이) - 심플하고 깔끔한 디자인 유지 +--- + +## 사용자 관리 필수 규칙 + +### 최고 관리자(SUPER_ADMIN) 가시성 제한 + +**핵심 원칙**: 회사 관리자(COMPANY_ADMIN)와 일반 사용자(USER)는 **절대로** 최고 관리자(company_code = "*")를 볼 수 없어야 합니다. + +#### 백엔드 구현 필수사항 + +모든 사용자 관련 API에서 다음 필터링 로직을 **반드시** 적용해야 합니다: + +```typescript +// 최고 관리자 필터링 (필수) +if (req.user && req.user.companyCode !== "*") { + // 최고 관리자가 아닌 경우, company_code가 "*"인 사용자는 제외 + whereConditions.push(`company_code != '*'`); + logger.info("최고 관리자 필터링 적용", { userCompanyCode: req.user.companyCode }); +} +``` + +**SQL 쿼리 예시:** +```sql +SELECT * FROM user_info +WHERE 1=1 + AND company_code != '*' -- 최고 관리자 제외 + AND company_code = $1 -- 회사별 필터링 +``` + +#### 적용 대상 API (필수) + +다음 사용자 관련 API에 최고 관리자 필터링을 **반드시** 적용해야 합니다: + +1. **사용자 목록 조회** (`GET /api/admin/users`) + - 사용자 관리 페이지 + - 권한 그룹 멤버 선택 (Dual List Box) + - 검색/필터 결과 + +2. **사용자 검색** (`GET /api/admin/users/search`) + - 자동완성/타입어헤드 + - 드롭다운 선택 + +3. **부서별 사용자 조회** (`GET /api/admin/users/by-department`) + - 부서 필터링 시 + +4. **사용자 상세 조회** (`GET /api/admin/users/:userId`) + - 최고 관리자의 상세 정보는 최고 관리자만 볼 수 있음 + +#### 프론트엔드 추가 보호 (권장) + +백엔드에서 이미 필터링되지만, 프론트엔드에서도 추가 체크를 권장합니다: + +```typescript +// 컴포넌트에서 최고 관리자 제외 +const visibleUsers = users.filter(user => { + // 최고 관리자만 최고 관리자를 볼 수 있음 + if (user.companyCode === "*" && !isSuperAdmin) { + return false; + } + return true; +}); +``` + +#### 예외 사항 + +- **최고 관리자(company_code = "*")** 는 모든 사용자(다른 최고 관리자 포함)를 볼 수 있습니다. +- 최고 관리자는 다른 회사의 데이터도 조회할 수 있습니다. + +#### 체크리스트 + +새로운 사용자 관련 기능 개발 시 다음을 확인하세요: + +- [ ] `req.user.companyCode !== "*"` 체크 추가 +- [ ] `company_code != '*'` WHERE 조건 추가 +- [ ] 로깅으로 필터링 적용 여부 확인 +- [ ] 최고 관리자로 로그인하여 정상 작동 확인 +- [ ] 회사 관리자로 로그인하여 최고 관리자가 안 보이는지 확인 + +#### 관련 파일 + +- `backend-node/src/controllers/adminController.ts` - `getUserList()` 함수 참고 +- `backend-node/src/middleware/authMiddleware.ts` - 권한 체크 +- `frontend/components/admin/UserManagement.tsx` - 사용자 목록 UI +- `frontend/components/admin/RoleDetailManagement.tsx` - 멤버 선택 UI + +#### 보안 주의사항 + +- 클라이언트 측 필터링만으로는 부족합니다 (우회 가능). +- 반드시 백엔드 SQL 쿼리에서 필터링해야 합니다. +- API 응답에 최고 관리자 정보가 절대 포함되어서는 안 됩니다. +- 로그에 필터링 여부를 기록하여 감사 추적을 남기세요. + diff --git a/backend-node/nodemon.json b/backend-node/nodemon.json index dc43f881..6923e9e9 100644 --- a/backend-node/nodemon.json +++ b/backend-node/nodemon.json @@ -1,15 +1,6 @@ { "watch": ["src"], - "ignore": [ - "src/**/*.spec.ts", - "src/**/*.test.ts", - "data/**", - "uploads/**", - "logs/**", - "*.log" - ], "ext": "ts,json", - "exec": "ts-node src/app.ts", - "delay": 2000 + "ignore": ["src/**/*.spec.ts"], + "exec": "node -r ts-node/register/transpile-only src/app.ts" } - diff --git a/backend-node/src/app.ts b/backend-node/src/app.ts index 979d191b..b75e6685 100644 --- a/backend-node/src/app.ts +++ b/backend-node/src/app.ts @@ -62,6 +62,8 @@ import yardLayoutRoutes from "./routes/yardLayoutRoutes"; // 야드 관리 3D import flowRoutes from "./routes/flowRoutes"; // 플로우 관리 import flowExternalDbConnectionRoutes from "./routes/flowExternalDbConnectionRoutes"; // 플로우 전용 외부 DB 연결 import workHistoryRoutes from "./routes/workHistoryRoutes"; // 작업 이력 관리 +import tableHistoryRoutes from "./routes/tableHistoryRoutes"; // 테이블 변경 이력 조회 +import roleRoutes from "./routes/roleRoutes"; // 권한 그룹 관리 import { BatchSchedulerService } from "./services/batchSchedulerService"; // import collectionRoutes from "./routes/collectionRoutes"; // 임시 주석 // import batchRoutes from "./routes/batchRoutes"; // 임시 주석 @@ -218,6 +220,8 @@ app.use("/api/yard-layouts", yardLayoutRoutes); // 야드 관리 3D app.use("/api/flow-external-db", flowExternalDbConnectionRoutes); // 플로우 전용 외부 DB 연결 app.use("/api/flow", flowRoutes); // 플로우 관리 (마지막에 등록하여 다른 라우트와 충돌 방지) app.use("/api/work-history", workHistoryRoutes); // 작업 이력 관리 +app.use("/api/table-history", tableHistoryRoutes); // 테이블 변경 이력 조회 +app.use("/api/roles", roleRoutes); // 권한 그룹 관리 // app.use("/api/collections", collectionRoutes); // 임시 주석 // app.use("/api/batch", batchRoutes); // 임시 주석 // app.use('/api/users', userRoutes); @@ -245,12 +249,19 @@ app.listen(PORT, HOST, async () => { logger.info(`🔗 Health check: http://${HOST}:${PORT}/health`); logger.info(`🌐 External access: http://39.117.244.52:${PORT}/health`); - // 대시보드 마이그레이션 실행 + // 데이터베이스 마이그레이션 실행 try { - const { runDashboardMigration } = await import("./database/runMigration"); + const { + runDashboardMigration, + runTableHistoryActionMigration, + runDtgManagementLogMigration, + } = await import("./database/runMigration"); + await runDashboardMigration(); + await runTableHistoryActionMigration(); + await runDtgManagementLogMigration(); } catch (error) { - logger.error(`❌ 대시보드 마이그레이션 실패:`, error); + logger.error(`❌ 마이그레이션 실패:`, error); } // 배치 스케줄러 초기화 @@ -279,17 +290,18 @@ app.listen(PORT, HOST, async () => { const { mailSentHistoryService } = await import( "./services/mailSentHistoryService" ); - + cron.schedule("0 2 * * *", async () => { try { logger.info("🗑️ 30일 지난 삭제된 메일 자동 삭제 시작..."); - const deletedCount = await mailSentHistoryService.cleanupOldDeletedMails(); + const deletedCount = + await mailSentHistoryService.cleanupOldDeletedMails(); logger.info(`✅ 30일 지난 메일 ${deletedCount}개 자동 삭제 완료`); } catch (error) { logger.error("❌ 메일 자동 삭제 실패:", error); } }); - + logger.info(`⏰ 메일 자동 삭제 스케줄러가 시작되었습니다. (매일 새벽 2시)`); } catch (error) { logger.error(`❌ 메일 자동 삭제 스케줄러 시작 실패:`, error); diff --git a/backend-node/src/controllers/adminController.ts b/backend-node/src/controllers/adminController.ts index bbef0e02..4af01653 100644 --- a/backend-node/src/controllers/adminController.ts +++ b/backend-node/src/controllers/adminController.ts @@ -17,19 +17,25 @@ export async function getAdminMenus( res: Response ): Promise { try { - logger.info("=== 메뉴 목록 조회 시작 ==="); + logger.info("=== 관리자 메뉴 목록 조회 시작 ==="); - // 현재 로그인한 사용자의 회사 코드와 로케일 가져오기 + // 현재 로그인한 사용자의 정보 가져오기 + const userId = req.user?.userId; const userCompanyCode = req.user?.companyCode || "ILSHIN"; + const userType = req.user?.userType; const userLang = (req.query.userLang as string) || "ko"; const menuType = req.query.menuType as string | undefined; // menuType 파라미터 추가 + logger.info(`사용자 ID: ${userId}`); logger.info(`사용자 회사 코드: ${userCompanyCode}`); + logger.info(`사용자 유형: ${userType}`); logger.info(`사용자 로케일: ${userLang}`); logger.info(`메뉴 타입: ${menuType || "전체"}`); const paramMap = { + userId, userCompanyCode, + userType, userLang, menuType, // menuType 추가 }; @@ -37,7 +43,7 @@ export async function getAdminMenus( const menuList = await AdminService.getAdminMenuList(paramMap); logger.info( - `메뉴 조회 결과: ${menuList.length}개 (타입: ${menuType || "전체"})` + `관리자 메뉴 조회 결과: ${menuList.length}개 (타입: ${menuType || "전체"}, 회사: ${userCompanyCode})` ); if (menuList.length > 0) { logger.info("첫 번째 메뉴:", menuList[0]); @@ -76,21 +82,29 @@ export async function getUserMenus( try { logger.info("=== 사용자 메뉴 목록 조회 시작 ==="); - // 현재 로그인한 사용자의 회사 코드와 로케일 가져오기 + // 현재 로그인한 사용자의 정보 가져오기 + const userId = req.user?.userId; const userCompanyCode = req.user?.companyCode || "ILSHIN"; + const userType = req.user?.userType; const userLang = (req.query.userLang as string) || "ko"; + logger.info(`사용자 ID: ${userId}`); logger.info(`사용자 회사 코드: ${userCompanyCode}`); + logger.info(`사용자 유형: ${userType}`); logger.info(`사용자 로케일: ${userLang}`); const paramMap = { + userId, userCompanyCode, + userType, userLang, }; const menuList = await AdminService.getUserMenuList(paramMap); - logger.info(`사용자 메뉴 조회 결과: ${menuList.length}개`); + logger.info( + `사용자 메뉴 조회 결과: ${menuList.length}개 (회사: ${userCompanyCode})` + ); if (menuList.length > 0) { logger.info("첫 번째 메뉴:", menuList[0]); } @@ -195,6 +209,8 @@ export const getUserList = async (req: AuthenticatedRequest, res: Response) => { search_email, deptCode, status, + companyCode, // 회사 코드 필터 추가 + size, // countPerPage 대신 사용 가능 } = req.query; // Raw Query를 사용한 사용자 목록 조회 @@ -203,6 +219,23 @@ export const getUserList = async (req: AuthenticatedRequest, res: Response) => { let queryParams: any[] = []; let paramIndex = 1; + // 회사 코드 필터 (권한 그룹 멤버 관리 시 사용) + if (companyCode && typeof companyCode === "string" && companyCode.trim()) { + whereConditions.push(`company_code = $${paramIndex}`); + queryParams.push(companyCode.trim()); + paramIndex++; + logger.info("회사 코드 필터 적용", { companyCode }); + } + + // 최고 관리자 필터링 (회사 관리자와 일반 사용자는 최고 관리자를 볼 수 없음) + if (req.user && req.user.companyCode !== "*") { + // 최고 관리자가 아닌 경우, company_code가 "*"인 사용자는 제외 + whereConditions.push(`company_code != '*'`); + logger.info("최고 관리자 필터링 적용", { + userCompanyCode: req.user.companyCode, + }); + } + // 검색 조건 처리 if (search && typeof search === "string" && search.trim()) { // 통합 검색 @@ -303,6 +336,16 @@ export const getUserList = async (req: AuthenticatedRequest, res: Response) => { } } + // 현재 로그인한 사용자의 회사 코드 필터 (슈퍼관리자가 아닌 경우) + if (req.user && req.user.companyCode !== "*" && !companyCode) { + whereConditions.push(`company_code = $${paramIndex}`); + queryParams.push(req.user.companyCode); + paramIndex++; + logger.info("사용자 회사 코드 필터 적용", { + companyCode: req.user.companyCode, + }); + } + // 기존 필터들 if (deptCode) { whereConditions.push(`dept_code = $${paramIndex}`); @@ -331,7 +374,8 @@ export const getUserList = async (req: AuthenticatedRequest, res: Response) => { const totalCount = parseInt(countResult[0]?.total || "0", 10); // 사용자 목록 조회 - const offset = (Number(page) - 1) * Number(countPerPage); + const limit = size ? Number(size) : Number(countPerPage); + const offset = (Number(page) - 1) * limit; const usersQuery = ` SELECT sabun, @@ -357,11 +401,7 @@ export const getUserList = async (req: AuthenticatedRequest, res: Response) => { LIMIT $${paramIndex} OFFSET $${paramIndex + 1} `; - const users = await query(usersQuery, [ - ...queryParams, - Number(countPerPage), - offset, - ]); + const users = await query(usersQuery, [...queryParams, limit, offset]); // 응답 데이터 가공 const processedUsers = users.map((user) => ({ @@ -393,8 +433,8 @@ export const getUserList = async (req: AuthenticatedRequest, res: Response) => { searchType, pagination: { page: Number(page), - limit: Number(countPerPage), - totalPages: Math.ceil(totalCount / Number(countPerPage)), + limit: limit, + totalPages: Math.ceil(totalCount / limit), }, message: "사용자 목록 조회 성공", }; @@ -404,7 +444,8 @@ export const getUserList = async (req: AuthenticatedRequest, res: Response) => { returnedCount: processedUsers.length, searchType, currentPage: Number(page), - countPerPage: Number(countPerPage), + limit: limit, + companyCode: companyCode || "all", }); res.status(200).json(response); @@ -1379,7 +1420,7 @@ export const getDepartmentList = async ( // 회사 코드 필터 if (companyCode) { - whereConditions.push(`company_name = $${paramIndex}`); + whereConditions.push(`company_code = $${paramIndex}`); queryParams.push(companyCode); paramIndex++; } @@ -1420,6 +1461,7 @@ export const getDepartmentList = async ( data_type, status, sales_yn, + company_code, company_name FROM dept_info ${whereClause} @@ -1445,6 +1487,7 @@ export const getDepartmentList = async ( dataType: dept.data_type, status: dept.status || "active", salesYn: dept.sales_yn, + companyCode: dept.company_code, companyName: dept.company_name, children: [], }); @@ -1480,6 +1523,7 @@ export const getDepartmentList = async ( dataType: dept.data_type, status: dept.status || "active", salesYn: dept.sales_yn, + companyCode: dept.company_code, companyName: dept.company_name, })), }, @@ -1947,10 +1991,23 @@ export const changeUserStatus = async ( export const saveUser = async (req: AuthenticatedRequest, res: Response) => { try { const userData = req.body; - logger.info("사용자 저장 요청", { userData, user: req.user }); + const isUpdate = req.method === "PUT"; // PUT 요청이면 수정 + + logger.info("사용자 저장 요청", { + userData, + user: req.user, + isUpdate, + method: req.method, + }); // 필수 필드 검증 - const requiredFields = ["userId", "userName", "userPassword"]; + let requiredFields = ["userId", "userName"]; + + // 신규 등록 시에만 비밀번호 필수 + if (!isUpdate) { + requiredFields.push("userPassword"); + } + for (const field of requiredFields) { if (!userData[field] || userData[field].trim() === "") { res.status(400).json({ @@ -1965,10 +2022,15 @@ export const saveUser = async (req: AuthenticatedRequest, res: Response) => { } } - // 비밀번호 암호화 - const encryptedPassword = await EncryptUtil.encrypt(userData.userPassword); + // 비밀번호 암호화 (비밀번호가 제공된 경우에만) + let encryptedPassword = null; + if (userData.userPassword) { + encryptedPassword = await EncryptUtil.encrypt(userData.userPassword); + } // Raw Query를 사용한 사용자 저장 (upsert with ON CONFLICT) + const updatePasswordClause = encryptedPassword ? "user_password = $4," : ""; + const [savedUser] = await query( `INSERT INTO user_info ( user_id, user_name, user_name_eng, user_password, @@ -1979,7 +2041,7 @@ export const saveUser = async (req: AuthenticatedRequest, res: Response) => { ON CONFLICT (user_id) DO UPDATE SET user_name = $2, user_name_eng = $3, - user_password = $4, + ${updatePasswordClause} dept_code = $5, dept_name = $6, position_code = $7, @@ -1998,7 +2060,7 @@ export const saveUser = async (req: AuthenticatedRequest, res: Response) => { userData.userId, userData.userName, userData.userNameEng || null, - encryptedPassword, + encryptedPassword || "", // 빈 문자열로 넣되, UPDATE에서는 조건부로 제외 userData.deptCode || null, userData.deptName || null, userData.positionCode || null, @@ -2017,23 +2079,26 @@ export const saveUser = async (req: AuthenticatedRequest, res: Response) => { ); // 기존 사용자인지 새 사용자인지 확인 (regdate로 판단) - const isUpdate = + const isExistingUser = savedUser.regdate && new Date(savedUser.regdate).getTime() < Date.now() - 1000; - logger.info(isUpdate ? "사용자 정보 수정 완료" : "새 사용자 등록 완료", { - userId: userData.userId, - }); + logger.info( + isExistingUser ? "사용자 정보 수정 완료" : "새 사용자 등록 완료", + { + userId: userData.userId, + } + ); const response = { success: true, result: true, - message: isUpdate + message: isExistingUser ? "사용자 정보가 수정되었습니다." : "사용자가 등록되었습니다.", data: { userId: userData.userId, - isUpdate, + isUpdate: isExistingUser, }, }; diff --git a/backend-node/src/controllers/dataflowDiagramController.ts b/backend-node/src/controllers/dataflowDiagramController.ts index ad64db21..2af23c0d 100644 --- a/backend-node/src/controllers/dataflowDiagramController.ts +++ b/backend-node/src/controllers/dataflowDiagramController.ts @@ -1,4 +1,5 @@ import { Request, Response } from "express"; +import { AuthenticatedRequest } from "../types/auth"; import { getDataflowDiagrams as getDataflowDiagramsService, getDataflowDiagramById as getDataflowDiagramByIdService, @@ -12,15 +13,33 @@ import { logger } from "../utils/logger"; /** * 관계도 목록 조회 (페이지네이션) */ -export const getDataflowDiagrams = async (req: Request, res: Response) => { +export const getDataflowDiagrams = async ( + req: AuthenticatedRequest, + res: Response +) => { try { const page = parseInt(req.query.page as string) || 1; const size = parseInt(req.query.size as string) || 20; const searchTerm = req.query.searchTerm as string; - const companyCode = - (req.query.companyCode as string) || - (req.headers["x-company-code"] as string) || - "*"; + const userCompanyCode = req.user?.companyCode; + + // 슈퍼 관리자는 쿼리 파라미터로 회사 지정 가능, 일반/회사 관리자는 자신의 회사만 + let companyCode: string; + if (userCompanyCode === "*") { + // 슈퍼 관리자: 쿼리 파라미터 사용 또는 전체 + companyCode = (req.query.companyCode as string) || "*"; + } else { + // 회사 관리자/일반 사용자: 강제로 자신의 회사 코드 적용 + companyCode = userCompanyCode || "*"; + } + + logger.info("관계도 목록 조회", { + userId: req.user?.userId, + userCompanyCode, + filterCompanyCode: companyCode, + page, + size, + }); const result = await getDataflowDiagramsService( companyCode, @@ -46,13 +65,21 @@ export const getDataflowDiagrams = async (req: Request, res: Response) => { /** * 특정 관계도 조회 */ -export const getDataflowDiagramById = async (req: Request, res: Response) => { +export const getDataflowDiagramById = async ( + req: AuthenticatedRequest, + res: Response +) => { try { const diagramId = parseInt(req.params.diagramId); - const companyCode = - (req.query.companyCode as string) || - (req.headers["x-company-code"] as string) || - "*"; + const userCompanyCode = req.user?.companyCode; + + // 슈퍼 관리자는 쿼리 파라미터로 회사 지정 가능, 일반/회사 관리자는 자신의 회사만 + let companyCode: string; + if (userCompanyCode === "*") { + companyCode = (req.query.companyCode as string) || "*"; + } else { + companyCode = userCompanyCode || "*"; + } if (isNaN(diagramId)) { return res.status(400).json({ @@ -87,7 +114,10 @@ export const getDataflowDiagramById = async (req: Request, res: Response) => { /** * 새로운 관계도 생성 */ -export const createDataflowDiagram = async (req: Request, res: Response) => { +export const createDataflowDiagram = async ( + req: AuthenticatedRequest, + res: Response +) => { try { const { diagram_name, @@ -96,27 +126,31 @@ export const createDataflowDiagram = async (req: Request, res: Response) => { category, control, plan, - company_code, - created_by, - updated_by, } = req.body; - logger.info(`새 관계도 생성 요청:`, { diagram_name, company_code }); + const userCompanyCode = req.user?.companyCode; + const userId = req.user?.userId || "SYSTEM"; + + // 회사 코드는 로그인한 사용자의 회사 코드 사용 (슈퍼 관리자는 요청 body에서 지정 가능) + let companyCode: string; + if (userCompanyCode === "*" && req.body.company_code) { + // 슈퍼 관리자가 특정 회사로 생성하는 경우 + companyCode = req.body.company_code; + } else { + // 일반 사용자/회사 관리자는 자신의 회사로 생성 + companyCode = userCompanyCode || "*"; + } + + logger.info(`새 관계도 생성 요청:`, { + diagram_name, + companyCode, + userId, + userCompanyCode, + }); logger.info(`node_positions:`, node_positions); logger.info(`category:`, category); logger.info(`control:`, control); logger.info(`plan:`, plan); - logger.info(`전체 요청 Body:`, JSON.stringify(req.body, null, 2)); - const companyCode = - company_code || - (req.query.companyCode as string) || - (req.headers["x-company-code"] as string) || - "*"; - const userId = - created_by || - updated_by || - (req.headers["x-user-id"] as string) || - "SYSTEM"; if (!diagram_name || !relationships) { return res.status(400).json({ @@ -184,24 +218,31 @@ export const createDataflowDiagram = async (req: Request, res: Response) => { /** * 관계도 수정 */ -export const updateDataflowDiagram = async (req: Request, res: Response) => { +export const updateDataflowDiagram = async ( + req: AuthenticatedRequest, + res: Response +) => { try { const diagramId = parseInt(req.params.diagramId); - const { updated_by } = req.body; - const companyCode = - (req.query.companyCode as string) || - (req.headers["x-company-code"] as string) || - "*"; - const userId = - updated_by || (req.headers["x-user-id"] as string) || "SYSTEM"; + const userCompanyCode = req.user?.companyCode; + const userId = req.user?.userId || "SYSTEM"; - logger.info(`관계도 수정 요청 - ID: ${diagramId}, Company: ${companyCode}`); + // 슈퍼 관리자는 쿼리 파라미터로 회사 지정 가능, 일반/회사 관리자는 자신의 회사만 + let companyCode: string; + if (userCompanyCode === "*") { + companyCode = (req.query.companyCode as string) || "*"; + } else { + companyCode = userCompanyCode || "*"; + } + + logger.info(`관계도 수정 요청`, { + diagramId, + companyCode, + userId, + userCompanyCode, + }); logger.info(`요청 Body:`, JSON.stringify(req.body, null, 2)); logger.info(`node_positions:`, req.body.node_positions); - logger.info(`요청 Body 키들:`, Object.keys(req.body)); - logger.info(`요청 Body 타입:`, typeof req.body); - logger.info(`node_positions 타입:`, typeof req.body.node_positions); - logger.info(`node_positions 값:`, req.body.node_positions); if (isNaN(diagramId)) { return res.status(400).json({ @@ -265,13 +306,21 @@ export const updateDataflowDiagram = async (req: Request, res: Response) => { /** * 관계도 삭제 */ -export const deleteDataflowDiagram = async (req: Request, res: Response) => { +export const deleteDataflowDiagram = async ( + req: AuthenticatedRequest, + res: Response +) => { try { const diagramId = parseInt(req.params.diagramId); - const companyCode = - (req.query.companyCode as string) || - (req.headers["x-company-code"] as string) || - "*"; + const userCompanyCode = req.user?.companyCode; + + // 슈퍼 관리자는 쿼리 파라미터로 회사 지정 가능, 일반/회사 관리자는 자신의 회사만 + let companyCode: string; + if (userCompanyCode === "*") { + companyCode = (req.query.companyCode as string) || "*"; + } else { + companyCode = userCompanyCode || "*"; + } if (isNaN(diagramId)) { return res.status(400).json({ @@ -306,21 +355,25 @@ export const deleteDataflowDiagram = async (req: Request, res: Response) => { /** * 관계도 복제 */ -export const copyDataflowDiagram = async (req: Request, res: Response) => { +export const copyDataflowDiagram = async ( + req: AuthenticatedRequest, + res: Response +) => { try { const diagramId = parseInt(req.params.diagramId); - const { - new_name, - companyCode: bodyCompanyCode, - userId: bodyUserId, - } = req.body; - const companyCode = - bodyCompanyCode || - (req.query.companyCode as string) || - (req.headers["x-company-code"] as string) || - "*"; - const userId = - bodyUserId || (req.headers["x-user-id"] as string) || "SYSTEM"; + const { new_name } = req.body; + const userCompanyCode = req.user?.companyCode; + const userId = req.user?.userId || "SYSTEM"; + + // 회사 코드는 로그인한 사용자의 회사 코드 사용 + let companyCode: string; + if (userCompanyCode === "*" && req.body.companyCode) { + // 슈퍼 관리자가 특정 회사로 복제하는 경우 + companyCode = req.body.companyCode; + } else { + // 일반 사용자/회사 관리자는 자신의 회사로 복제 + companyCode = userCompanyCode || "*"; + } if (isNaN(diagramId)) { return res.status(400).json({ diff --git a/backend-node/src/controllers/flowController.ts b/backend-node/src/controllers/flowController.ts index f596af97..3ff159f1 100644 --- a/backend-node/src/controllers/flowController.ts +++ b/backend-node/src/controllers/flowController.ts @@ -1,3 +1,4 @@ +// @ts-nocheck /** * 플로우 관리 컨트롤러 */ @@ -34,6 +35,7 @@ export class FlowController { const { name, description, tableName, dbSourceType, dbConnectionId } = req.body; const userId = (req as any).user?.userId || "system"; + const userCompanyCode = (req as any).user?.companyCode; console.log("🔍 createFlowDefinition called with:", { name, @@ -41,6 +43,7 @@ export class FlowController { tableName, dbSourceType, dbConnectionId, + userCompanyCode, }); if (!name) { @@ -66,7 +69,8 @@ export class FlowController { const flowDef = await this.flowDefinitionService.create( { name, description, tableName, dbSourceType, dbConnectionId }, - userId + userId, + userCompanyCode ); res.json({ @@ -88,12 +92,25 @@ export class FlowController { getFlowDefinitions = async (req: Request, res: Response): Promise => { try { const { tableName, isActive } = req.query; + const user = (req as any).user; + const userCompanyCode = user?.companyCode; + + console.log("🎯 getFlowDefinitions called:", { + userId: user?.userId, + userCompanyCode: userCompanyCode, + userType: user?.userType, + tableName, + isActive, + }); const flows = await this.flowDefinitionService.findAll( tableName as string | undefined, - isActive !== undefined ? isActive === "true" : undefined + isActive !== undefined ? isActive === "true" : undefined, + userCompanyCode ); + console.log(`✅ Returning ${flows.length} flows to user ${user?.userId}`); + res.json({ success: true, data: flows, @@ -312,6 +329,7 @@ export class FlowController { fieldMappings, integrationType, integrationConfig, + displayConfig, } = req.body; const step = await this.flowStepService.update(id, { @@ -329,6 +347,7 @@ export class FlowController { fieldMappings, integrationType, integrationConfig, + displayConfig, }); if (!step) { diff --git a/backend-node/src/controllers/roleController.ts b/backend-node/src/controllers/roleController.ts new file mode 100644 index 00000000..3c6ed1e5 --- /dev/null +++ b/backend-node/src/controllers/roleController.ts @@ -0,0 +1,864 @@ +import { Response } from "express"; +import { AuthenticatedRequest } from "../types/auth"; +import { ApiResponse } from "../types/common"; +import { RoleService } from "../services/roleService"; +import { logger } from "../utils/logger"; +import { + isSuperAdmin, + isCompanyAdmin, + canAccessCompanyData, +} from "../utils/permissionUtils"; + +/** + * 권한 그룹 목록 조회 + * - 회사 관리자: 자기 회사 권한 그룹만 조회 + * - 최고 관리자: 모든 회사 권한 그룹 조회 (companyCode 미지정 시 전체 조회) + */ +export const getRoleGroups = async ( + req: AuthenticatedRequest, + res: Response +): Promise => { + try { + const search = req.query.search as string | undefined; + const companyCode = req.query.companyCode as string | undefined; + + // 최고 관리자가 아닌 경우 자기 회사만 조회 + let targetCompanyCode: string | undefined; + if (isSuperAdmin(req.user)) { + // 최고 관리자: companyCode 파라미터가 있으면 해당 회사만, 없으면 전체 조회 + targetCompanyCode = companyCode; + logger.info("권한 그룹 목록 조회 (최고 관리자)", { + userId: req.user?.userId, + targetCompanyCode: targetCompanyCode || "전체", + search, + }); + } else { + // 일반 관리자: 자기 회사만 조회 + targetCompanyCode = req.user?.companyCode; + if (!targetCompanyCode) { + res.status(400).json({ + success: false, + message: "회사 코드가 필요합니다", + }); + return; + } + logger.info("권한 그룹 목록 조회 (회사 관리자)", { + userId: req.user?.userId, + companyCode: targetCompanyCode, + search, + }); + } + + const roleGroups = await RoleService.getRoleGroups( + targetCompanyCode, + search + ); + + const response: ApiResponse = { + success: true, + message: "권한 그룹 목록 조회 성공", + data: roleGroups, + }; + + res.status(200).json(response); + } catch (error) { + logger.error("권한 그룹 목록 조회 실패", { error }); + res.status(500).json({ + success: false, + message: "권한 그룹 목록 조회 중 오류가 발생했습니다", + error: error instanceof Error ? error.message : "Unknown error", + }); + } +}; + +/** + * 권한 그룹 상세 조회 + */ +export const getRoleGroupById = async ( + req: AuthenticatedRequest, + res: Response +): Promise => { + try { + const objid = parseInt(req.params.id, 10); + + if (isNaN(objid)) { + res.status(400).json({ + success: false, + message: "유효하지 않은 권한 그룹 ID입니다", + }); + return; + } + + const roleGroup = await RoleService.getRoleGroupById(objid); + + if (!roleGroup) { + res.status(404).json({ + success: false, + message: "권한 그룹을 찾을 수 없습니다", + }); + return; + } + + // 권한 체크: 슈퍼관리자 또는 해당 회사 관리자만 조회 가능 + if ( + !isSuperAdmin(req.user) && + !canAccessCompanyData(req.user, roleGroup.companyCode) + ) { + res.status(403).json({ + success: false, + message: "권한이 없습니다", + }); + return; + } + + const response: ApiResponse = { + success: true, + message: "권한 그룹 상세 조회 성공", + data: roleGroup, + }; + + res.status(200).json(response); + } catch (error) { + logger.error("권한 그룹 상세 조회 실패", { error }); + res.status(500).json({ + success: false, + message: "권한 그룹 상세 조회 중 오류가 발생했습니다", + error: error instanceof Error ? error.message : "Unknown error", + }); + } +}; + +/** + * 권한 그룹 생성 + * - 회사 관리자: 자기 회사에만 권한 그룹 생성 가능 + * - 최고 관리자: 모든 회사에 권한 그룹 생성 가능 + */ +export const createRoleGroup = async ( + req: AuthenticatedRequest, + res: Response +): Promise => { + try { + const { authName, authCode, companyCode } = req.body; + + if (!authName || !authCode || !companyCode) { + res.status(400).json({ + success: false, + message: "필수 정보가 누락되었습니다 (authName, authCode, companyCode)", + }); + return; + } + + // 권한 체크: 회사 관리자 이상만 생성 가능 + if (!isSuperAdmin(req.user) && !isCompanyAdmin(req.user)) { + res.status(403).json({ + success: false, + message: "권한 그룹 생성 권한이 없습니다", + }); + return; + } + + // 회사 관리자는 자기 회사에만 권한 그룹 생성 가능 + if (!isSuperAdmin(req.user) && req.user?.companyCode !== companyCode) { + res.status(403).json({ + success: false, + message: "다른 회사의 권한 그룹을 생성할 수 없습니다", + }); + return; + } + + const roleGroup = await RoleService.createRoleGroup({ + authName, + authCode, + companyCode, + writer: req.user?.userId || "SYSTEM", + }); + + const response: ApiResponse = { + success: true, + message: "권한 그룹 생성 성공", + data: roleGroup, + }; + + res.status(201).json(response); + } catch (error) { + logger.error("권한 그룹 생성 실패", { error }); + res.status(500).json({ + success: false, + message: "권한 그룹 생성 중 오류가 발생했습니다", + error: error instanceof Error ? error.message : "Unknown error", + }); + } +}; + +/** + * 권한 그룹 수정 + */ +export const updateRoleGroup = async ( + req: AuthenticatedRequest, + res: Response +): Promise => { + try { + const objid = parseInt(req.params.id, 10); + const { authName, authCode, status } = req.body; + + if (isNaN(objid)) { + res.status(400).json({ + success: false, + message: "유효하지 않은 권한 그룹 ID입니다", + }); + return; + } + + // 기존 권한 그룹 조회 + const existingRoleGroup = await RoleService.getRoleGroupById(objid); + if (!existingRoleGroup) { + res.status(404).json({ + success: false, + message: "권한 그룹을 찾을 수 없습니다", + }); + return; + } + + // 권한 체크 + if ( + !isSuperAdmin(req.user) && + !canAccessCompanyData(req.user, existingRoleGroup.companyCode) + ) { + res.status(403).json({ + success: false, + message: "권한 그룹 수정 권한이 없습니다", + }); + return; + } + + const roleGroup = await RoleService.updateRoleGroup(objid, { + authName, + authCode, + status, + }); + + const response: ApiResponse = { + success: true, + message: "권한 그룹 수정 성공", + data: roleGroup, + }; + + res.status(200).json(response); + } catch (error) { + logger.error("권한 그룹 수정 실패", { error }); + res.status(500).json({ + success: false, + message: "권한 그룹 수정 중 오류가 발생했습니다", + error: error instanceof Error ? error.message : "Unknown error", + }); + } +}; + +/** + * 권한 그룹 삭제 + */ +export const deleteRoleGroup = async ( + req: AuthenticatedRequest, + res: Response +): Promise => { + try { + const objid = parseInt(req.params.id, 10); + + if (isNaN(objid)) { + res.status(400).json({ + success: false, + message: "유효하지 않은 권한 그룹 ID입니다", + }); + return; + } + + // 기존 권한 그룹 조회 + const existingRoleGroup = await RoleService.getRoleGroupById(objid); + if (!existingRoleGroup) { + res.status(404).json({ + success: false, + message: "권한 그룹을 찾을 수 없습니다", + }); + return; + } + + // 권한 체크 + if ( + !isSuperAdmin(req.user) && + !canAccessCompanyData(req.user, existingRoleGroup.companyCode) + ) { + res.status(403).json({ + success: false, + message: "권한 그룹 삭제 권한이 없습니다", + }); + return; + } + + await RoleService.deleteRoleGroup(objid); + + const response: ApiResponse = { + success: true, + message: "권한 그룹 삭제 성공", + data: null, + }; + + res.status(200).json(response); + } catch (error) { + logger.error("권한 그룹 삭제 실패", { error }); + res.status(500).json({ + success: false, + message: "권한 그룹 삭제 중 오류가 발생했습니다", + error: error instanceof Error ? error.message : "Unknown error", + }); + } +}; + +/** + * 권한 그룹 멤버 목록 조회 + */ +export const getRoleMembers = async ( + req: AuthenticatedRequest, + res: Response +): Promise => { + try { + const masterObjid = parseInt(req.params.id, 10); + + if (isNaN(masterObjid)) { + res.status(400).json({ + success: false, + message: "유효하지 않은 권한 그룹 ID입니다", + }); + return; + } + + // 기존 권한 그룹 조회 + const roleGroup = await RoleService.getRoleGroupById(masterObjid); + if (!roleGroup) { + res.status(404).json({ + success: false, + message: "권한 그룹을 찾을 수 없습니다", + }); + return; + } + + // 권한 체크 + if ( + !isSuperAdmin(req.user) && + !canAccessCompanyData(req.user, roleGroup.companyCode) + ) { + res.status(403).json({ + success: false, + message: "권한 그룹 멤버 조회 권한이 없습니다", + }); + return; + } + + const members = await RoleService.getRoleMembers(masterObjid); + + const response: ApiResponse = { + success: true, + message: "권한 그룹 멤버 조회 성공", + data: members, + }; + + res.status(200).json(response); + } catch (error) { + logger.error("권한 그룹 멤버 조회 실패", { error }); + res.status(500).json({ + success: false, + message: "권한 그룹 멤버 조회 중 오류가 발생했습니다", + error: error instanceof Error ? error.message : "Unknown error", + }); + } +}; + +/** + * 권한 그룹 멤버 추가 + */ +export const addRoleMembers = async ( + req: AuthenticatedRequest, + res: Response +): Promise => { + try { + const masterObjid = parseInt(req.params.id, 10); + const { userIds } = req.body; + + if (isNaN(masterObjid)) { + res.status(400).json({ + success: false, + message: "유효하지 않은 권한 그룹 ID입니다", + }); + return; + } + + if (!Array.isArray(userIds) || userIds.length === 0) { + res.status(400).json({ + success: false, + message: "추가할 사용자 ID 목록이 필요합니다", + }); + return; + } + + // 기존 권한 그룹 조회 + const roleGroup = await RoleService.getRoleGroupById(masterObjid); + if (!roleGroup) { + res.status(404).json({ + success: false, + message: "권한 그룹을 찾을 수 없습니다", + }); + return; + } + + // 권한 체크 + if ( + !isSuperAdmin(req.user) && + !canAccessCompanyData(req.user, roleGroup.companyCode) + ) { + res.status(403).json({ + success: false, + message: "권한 그룹 멤버 추가 권한이 없습니다", + }); + return; + } + + await RoleService.addRoleMembers( + masterObjid, + userIds, + req.user?.userId || "SYSTEM" + ); + + const response: ApiResponse = { + success: true, + message: "권한 그룹 멤버 추가 성공", + data: null, + }; + + res.status(200).json(response); + } catch (error) { + logger.error("권한 그룹 멤버 추가 실패", { error }); + res.status(500).json({ + success: false, + message: "권한 그룹 멤버 추가 중 오류가 발생했습니다", + error: error instanceof Error ? error.message : "Unknown error", + }); + } +}; + +/** + * 권한 그룹 멤버 일괄 업데이트 + */ +export const updateRoleMembers = async ( + req: AuthenticatedRequest, + res: Response +): Promise => { + try { + const masterObjid = parseInt(req.params.id, 10); + const { userIds } = req.body; + + if (isNaN(masterObjid)) { + res.status(400).json({ + success: false, + message: "유효하지 않은 권한 그룹 ID입니다", + }); + return; + } + + if (!Array.isArray(userIds)) { + res.status(400).json({ + success: false, + message: "사용자 ID 배열이 필요합니다", + }); + return; + } + + // 기존 권한 그룹 조회 + const roleGroup = await RoleService.getRoleGroupById(masterObjid); + if (!roleGroup) { + res.status(404).json({ + success: false, + message: "권한 그룹을 찾을 수 없습니다", + }); + return; + } + + // 권한 체크 + if ( + !isSuperAdmin(req.user) && + !canAccessCompanyData(req.user, roleGroup.companyCode) + ) { + res.status(403).json({ + success: false, + message: "권한 그룹 멤버 수정 권한이 없습니다", + }); + return; + } + + // 기존 멤버 조회 + const existingMembers = await RoleService.getRoleMembers(masterObjid); + const existingUserIds = existingMembers.map((m: any) => m.userId); + + // 추가할 멤버 (새로 추가된 것들) + const toAdd = userIds.filter((id: string) => !existingUserIds.includes(id)); + + // 제거할 멤버 (기존에 있었는데 없어진 것들) + const toRemove = existingUserIds.filter( + (id: string) => !userIds.includes(id) + ); + + // 추가 + if (toAdd.length > 0) { + await RoleService.addRoleMembers( + masterObjid, + toAdd, + req.user?.userId || "SYSTEM" + ); + } + + // 제거 + if (toRemove.length > 0) { + await RoleService.removeRoleMembers( + masterObjid, + toRemove, + req.user?.userId || "SYSTEM" + ); + } + + logger.info("권한 그룹 멤버 일괄 업데이트 성공", { + masterObjid, + added: toAdd.length, + removed: toRemove.length, + }); + + const response: ApiResponse = { + success: true, + message: "권한 그룹 멤버가 업데이트되었습니다", + data: null, + }; + + res.status(200).json(response); + } catch (error) { + logger.error("권한 그룹 멤버 업데이트 실패", { error }); + res.status(500).json({ + success: false, + message: "권한 그룹 멤버 업데이트 중 오류가 발생했습니다", + error: error instanceof Error ? error.message : "Unknown error", + }); + } +}; + +/** + * 권한 그룹 멤버 제거 + */ +export const removeRoleMembers = async ( + req: AuthenticatedRequest, + res: Response +): Promise => { + try { + const masterObjid = parseInt(req.params.id, 10); + const { userIds } = req.body; + + if (isNaN(masterObjid)) { + res.status(400).json({ + success: false, + message: "유효하지 않은 권한 그룹 ID입니다", + }); + return; + } + + if (!Array.isArray(userIds) || userIds.length === 0) { + res.status(400).json({ + success: false, + message: "제거할 사용자 ID 목록이 필요합니다", + }); + return; + } + + // 기존 권한 그룹 조회 + const roleGroup = await RoleService.getRoleGroupById(masterObjid); + if (!roleGroup) { + res.status(404).json({ + success: false, + message: "권한 그룹을 찾을 수 없습니다", + }); + return; + } + + // 권한 체크 + if ( + !isSuperAdmin(req.user) && + !canAccessCompanyData(req.user, roleGroup.companyCode) + ) { + res.status(403).json({ + success: false, + message: "권한 그룹 멤버 제거 권한이 없습니다", + }); + return; + } + + await RoleService.removeRoleMembers( + masterObjid, + userIds, + req.user?.userId || "SYSTEM" + ); + + const response: ApiResponse = { + success: true, + message: "권한 그룹 멤버 제거 성공", + data: null, + }; + + res.status(200).json(response); + } catch (error) { + logger.error("권한 그룹 멤버 제거 실패", { error }); + res.status(500).json({ + success: false, + message: "권한 그룹 멤버 제거 중 오류가 발생했습니다", + error: error instanceof Error ? error.message : "Unknown error", + }); + } +}; + +/** + * 메뉴 권한 목록 조회 + */ +export const getMenuPermissions = async ( + req: AuthenticatedRequest, + res: Response +): Promise => { + try { + const authObjid = parseInt(req.params.id, 10); + + if (isNaN(authObjid)) { + res.status(400).json({ + success: false, + message: "유효하지 않은 권한 그룹 ID입니다", + }); + return; + } + + // 기존 권한 그룹 조회 + const roleGroup = await RoleService.getRoleGroupById(authObjid); + if (!roleGroup) { + res.status(404).json({ + success: false, + message: "권한 그룹을 찾을 수 없습니다", + }); + return; + } + + // 권한 체크 + if ( + !isSuperAdmin(req.user) && + !canAccessCompanyData(req.user, roleGroup.companyCode) + ) { + res.status(403).json({ + success: false, + message: "메뉴 권한 조회 권한이 없습니다", + }); + return; + } + + const permissions = await RoleService.getMenuPermissions(authObjid); + + const response: ApiResponse = { + success: true, + message: "메뉴 권한 조회 성공", + data: permissions, + }; + + res.status(200).json(response); + } catch (error) { + logger.error("메뉴 권한 조회 실패", { error }); + res.status(500).json({ + success: false, + message: "메뉴 권한 조회 중 오류가 발생했습니다", + error: error instanceof Error ? error.message : "Unknown error", + }); + } +}; + +/** + * 메뉴 권한 설정 + */ +export const setMenuPermissions = async ( + req: AuthenticatedRequest, + res: Response +): Promise => { + try { + const authObjid = parseInt(req.params.id, 10); + const { permissions } = req.body; + + if (isNaN(authObjid)) { + res.status(400).json({ + success: false, + message: "유효하지 않은 권한 그룹 ID입니다", + }); + return; + } + + if (!Array.isArray(permissions)) { + res.status(400).json({ + success: false, + message: "권한 목록이 필요합니다", + }); + return; + } + + // 기존 권한 그룹 조회 + const roleGroup = await RoleService.getRoleGroupById(authObjid); + if (!roleGroup) { + res.status(404).json({ + success: false, + message: "권한 그룹을 찾을 수 없습니다", + }); + return; + } + + // 권한 체크 + if ( + !isSuperAdmin(req.user) && + !canAccessCompanyData(req.user, roleGroup.companyCode) + ) { + res.status(403).json({ + success: false, + message: "메뉴 권한 설정 권한이 없습니다", + }); + return; + } + + await RoleService.setMenuPermissions( + authObjid, + permissions, + req.user?.userId || "SYSTEM" + ); + + const response: ApiResponse = { + success: true, + message: "메뉴 권한 설정 성공", + data: null, + }; + + res.status(200).json(response); + } catch (error) { + logger.error("메뉴 권한 설정 실패", { error }); + res.status(500).json({ + success: false, + message: "메뉴 권한 설정 중 오류가 발생했습니다", + error: error instanceof Error ? error.message : "Unknown error", + }); + } +}; + +/** + * 사용자가 속한 권한 그룹 목록 조회 + */ +export const getUserRoleGroups = async ( + req: AuthenticatedRequest, + res: Response +): Promise => { + try { + const userId = req.params.userId || req.user?.userId; + const companyCode = req.user?.companyCode; + + if (!userId || !companyCode) { + res.status(400).json({ + success: false, + message: "사용자 ID 또는 회사 코드가 필요합니다", + }); + return; + } + + const roleGroups = await RoleService.getUserRoleGroups(userId, companyCode); + + const response: ApiResponse = { + success: true, + message: "사용자 권한 그룹 조회 성공", + data: roleGroups, + }; + + res.status(200).json(response); + } catch (error) { + logger.error("사용자 권한 그룹 조회 실패", { error }); + res.status(500).json({ + success: false, + message: "사용자 권한 그룹 조회 중 오류가 발생했습니다", + error: error instanceof Error ? error.message : "Unknown error", + }); + } +}; + +/** + * 전체 메뉴 목록 조회 (권한 설정용) + */ +export const getAllMenus = async ( + req: AuthenticatedRequest, + res: Response +): Promise => { + try { + const requestedCompanyCode = req.query.companyCode as string | undefined; + + logger.info("🔍 [getAllMenus] API 호출", { + userId: req.user?.userId, + userType: req.user?.userType, + userCompanyCode: req.user?.companyCode, + requestedCompanyCode, + }); + + // 권한 체크 + if (!isSuperAdmin(req.user) && !isCompanyAdmin(req.user)) { + logger.warn("❌ [getAllMenus] 권한 없음", { + userId: req.user?.userId, + userType: req.user?.userType, + }); + res.status(403).json({ + success: false, + message: "관리자 권한이 필요합니다", + }); + return; + } + + // 회사 코드 결정: 최고 관리자는 요청한 코드 사용, 회사 관리자는 자기 회사만 + let companyCode: string | undefined; + if (isSuperAdmin(req.user)) { + // 최고 관리자: 요청한 회사 코드 사용 (없으면 전체) + companyCode = requestedCompanyCode; + logger.info("✅ [getAllMenus] 최고 관리자 - 요청된 회사 코드 사용", { + companyCode: companyCode || "전체", + }); + } else { + // 회사 관리자: 자기 회사 코드만 사용 + companyCode = req.user?.companyCode; + logger.info("✅ [getAllMenus] 회사 관리자 - 자기 회사 코드 적용", { + companyCode, + }); + } + + logger.info("✅ [getAllMenus] 관리자 권한 확인 완료", { + isSuperAdmin: isSuperAdmin(req.user), + isCompanyAdmin: isCompanyAdmin(req.user), + finalCompanyCode: companyCode || "전체", + }); + + const menus = await RoleService.getAllMenus(companyCode); + + logger.info("✅ [getAllMenus] API 응답 준비", { + menuCount: menus.length, + companyCode: companyCode || "전체", + }); + + const response: ApiResponse = { + success: true, + message: "메뉴 목록 조회 성공", + data: menus, + }; + + res.status(200).json(response); + } catch (error) { + logger.error("❌ [getAllMenus] 메뉴 목록 조회 실패", { error }); + res.status(500).json({ + success: false, + message: "메뉴 목록 조회 중 오류가 발생했습니다", + error: error instanceof Error ? error.message : "Unknown error", + }); + } +}; diff --git a/backend-node/src/controllers/tableHistoryController.ts b/backend-node/src/controllers/tableHistoryController.ts new file mode 100644 index 00000000..a32f31ad --- /dev/null +++ b/backend-node/src/controllers/tableHistoryController.ts @@ -0,0 +1,406 @@ +/** + * 테이블 이력 조회 컨트롤러 + * 테이블 타입 관리의 {테이블명}_log 테이블과 연동 + */ + +import { Request, Response } from "express"; +import { query } from "../database/db"; +import { logger } from "../utils/logger"; + +export class TableHistoryController { + /** + * 특정 레코드의 변경 이력 조회 + */ + static async getRecordHistory(req: Request, res: Response): Promise { + try { + const { tableName, recordId } = req.params; + const { limit = 50, offset = 0, operationType, changedBy, startDate, endDate } = req.query; + + logger.info(`📜 테이블 이력 조회 요청:`, { + tableName, + recordId, + limit, + offset, + }); + + // 로그 테이블명 생성 + const logTableName = `${tableName}_log`; + + // 동적 WHERE 조건 생성 + const whereConditions: string[] = [`original_id = $1`]; + const queryParams: any[] = [recordId]; + let paramIndex = 2; + + // 작업 유형 필터 + if (operationType) { + whereConditions.push(`operation_type = $${paramIndex}`); + queryParams.push(operationType); + paramIndex++; + } + + // 변경자 필터 + if (changedBy) { + whereConditions.push(`changed_by ILIKE $${paramIndex}`); + queryParams.push(`%${changedBy}%`); + paramIndex++; + } + + // 날짜 범위 필터 + if (startDate) { + whereConditions.push(`changed_at >= $${paramIndex}`); + queryParams.push(startDate); + paramIndex++; + } + if (endDate) { + whereConditions.push(`changed_at <= $${paramIndex}`); + queryParams.push(endDate); + paramIndex++; + } + + // LIMIT과 OFFSET 파라미터 추가 + queryParams.push(limit); + const limitParam = `$${paramIndex}`; + paramIndex++; + + queryParams.push(offset); + const offsetParam = `$${paramIndex}`; + + const whereClause = whereConditions.join(" AND "); + + // 이력 조회 쿼리 + const historyQuery = ` + SELECT + log_id, + operation_type, + original_id, + changed_column, + old_value, + new_value, + changed_by, + changed_at, + ip_address, + user_agent, + full_row_before, + full_row_after + FROM ${logTableName} + WHERE ${whereClause} + ORDER BY changed_at DESC + LIMIT ${limitParam} OFFSET ${offsetParam} + `; + + // 전체 카운트 쿼리 + const countQuery = ` + SELECT COUNT(*) as total + FROM ${logTableName} + WHERE ${whereClause} + `; + + const [historyRecords, countResult] = await Promise.all([ + query(historyQuery, queryParams), + query(countQuery, queryParams.slice(0, -2)), // LIMIT, OFFSET 제외 + ]); + + const total = parseInt(countResult[0]?.total || "0", 10); + + logger.info(`✅ 이력 조회 완료: ${historyRecords.length}건 / 전체 ${total}건`); + + res.json({ + success: true, + data: { + records: historyRecords, + pagination: { + total, + limit: parseInt(limit as string, 10), + offset: parseInt(offset as string, 10), + hasMore: parseInt(offset as string, 10) + historyRecords.length < total, + }, + }, + message: "이력 조회 성공", + }); + } catch (error: any) { + logger.error(`❌ 테이블 이력 조회 실패:`, error); + + // 테이블이 존재하지 않는 경우 + if (error.code === "42P01") { + res.status(404).json({ + success: false, + message: "이력 테이블이 존재하지 않습니다. 테이블 타입 관리에서 이력 관리를 활성화해주세요.", + errorCode: "TABLE_NOT_FOUND", + }); + return; + } + + res.status(500).json({ + success: false, + message: "이력 조회 중 오류가 발생했습니다.", + error: error.message, + }); + } + } + + /** + * 전체 테이블 이력 조회 (레코드 ID 없이) + */ + static async getAllTableHistory(req: Request, res: Response): Promise { + try { + const { tableName } = req.params; + const { limit = 50, offset = 0, operationType, changedBy, startDate, endDate } = req.query; + + logger.info(`📜 전체 테이블 이력 조회 요청:`, { + tableName, + limit, + offset, + }); + + // 로그 테이블명 생성 + const logTableName = `${tableName}_log`; + + // 동적 WHERE 조건 생성 + const whereConditions: string[] = []; + const queryParams: any[] = []; + let paramIndex = 1; + + // 작업 유형 필터 + if (operationType) { + whereConditions.push(`operation_type = $${paramIndex}`); + queryParams.push(operationType); + paramIndex++; + } + + // 변경자 필터 + if (changedBy) { + whereConditions.push(`changed_by ILIKE $${paramIndex}`); + queryParams.push(`%${changedBy}%`); + paramIndex++; + } + + // 날짜 범위 필터 + if (startDate) { + whereConditions.push(`changed_at >= $${paramIndex}`); + queryParams.push(startDate); + paramIndex++; + } + if (endDate) { + whereConditions.push(`changed_at <= $${paramIndex}`); + queryParams.push(endDate); + paramIndex++; + } + + // LIMIT과 OFFSET 파라미터 추가 + queryParams.push(limit); + const limitParam = `$${paramIndex}`; + paramIndex++; + + queryParams.push(offset); + const offsetParam = `$${paramIndex}`; + + const whereClause = whereConditions.length > 0 ? `WHERE ${whereConditions.join(" AND ")}` : ""; + + // 이력 조회 쿼리 + const historyQuery = ` + SELECT + log_id, + operation_type, + original_id, + changed_column, + old_value, + new_value, + changed_by, + changed_at, + ip_address, + user_agent, + full_row_before, + full_row_after + FROM ${logTableName} + ${whereClause} + ORDER BY changed_at DESC + LIMIT ${limitParam} OFFSET ${offsetParam} + `; + + // 전체 카운트 쿼리 + const countQuery = ` + SELECT COUNT(*) as total + FROM ${logTableName} + ${whereClause} + `; + + const [historyRecords, countResult] = await Promise.all([ + query(historyQuery, queryParams), + query(countQuery, queryParams.slice(0, -2)), // LIMIT, OFFSET 제외 + ]); + + const total = parseInt(countResult[0]?.total || "0", 10); + + res.json({ + success: true, + data: { + records: historyRecords, + pagination: { + total, + limit: Number(limit), + offset: Number(offset), + hasMore: Number(offset) + Number(limit) < total, + }, + }, + message: "전체 테이블 이력 조회 성공", + }); + } catch (error: any) { + logger.error(`❌ 전체 테이블 이력 조회 실패:`, error); + + if (error.code === "42P01") { + res.status(404).json({ + success: false, + message: "이력 테이블이 존재하지 않습니다.", + errorCode: "TABLE_NOT_FOUND", + }); + return; + } + + res.status(500).json({ + success: false, + message: "이력 조회 중 오류가 발생했습니다.", + error: error.message, + }); + } + } + + /** + * 테이블 전체 이력 요약 조회 + */ + static async getTableHistorySummary(req: Request, res: Response): Promise { + try { + const { tableName } = req.params; + const logTableName = `${tableName}_log`; + + const summaryQuery = ` + SELECT + operation_type, + COUNT(*) as count, + COUNT(DISTINCT original_id) as affected_records, + COUNT(DISTINCT changed_by) as unique_users, + MIN(changed_at) as first_change, + MAX(changed_at) as last_change + FROM ${logTableName} + GROUP BY operation_type + `; + + const summary = await query(summaryQuery); + + res.json({ + success: true, + data: summary, + message: "이력 요약 조회 성공", + }); + } catch (error: any) { + logger.error(`❌ 테이블 이력 요약 조회 실패:`, error); + + if (error.code === "42P01") { + res.status(404).json({ + success: false, + message: "이력 테이블이 존재하지 않습니다.", + errorCode: "TABLE_NOT_FOUND", + }); + return; + } + + res.status(500).json({ + success: false, + message: "이력 요약 조회 중 오류가 발생했습니다.", + error: error.message, + }); + } + } + + /** + * 특정 레코드의 변경 타임라인 조회 (그룹화) + */ + static async getRecordTimeline(req: Request, res: Response): Promise { + try { + const { tableName, recordId } = req.params; + const logTableName = `${tableName}_log`; + + // 변경 이벤트별로 그룹화 (동일 시간대 변경을 하나의 이벤트로) + const timelineQuery = ` + WITH grouped_changes AS ( + SELECT + changed_at, + changed_by, + operation_type, + ip_address, + json_agg( + json_build_object( + 'column', changed_column, + 'oldValue', old_value, + 'newValue', new_value + ) ORDER BY changed_column + ) as changes, + full_row_before, + full_row_after + FROM ${logTableName} + WHERE original_id = $1 + GROUP BY changed_at, changed_by, operation_type, ip_address, full_row_before, full_row_after + ORDER BY changed_at DESC + LIMIT 100 + ) + SELECT * FROM grouped_changes + `; + + const timeline = await query(timelineQuery, [recordId]); + + res.json({ + success: true, + data: timeline, + message: "타임라인 조회 성공", + }); + } catch (error: any) { + logger.error(`❌ 레코드 타임라인 조회 실패:`, error); + + res.status(500).json({ + success: false, + message: "타임라인 조회 중 오류가 발생했습니다.", + error: error.message, + }); + } + } + + /** + * 이력 테이블 존재 여부 확인 + */ + static async checkHistoryTableExists(req: Request, res: Response): Promise { + try { + const { tableName } = req.params; + const logTableName = `${tableName}_log`; + + const checkQuery = ` + SELECT EXISTS ( + SELECT FROM information_schema.tables + WHERE table_schema = 'public' + AND table_name = $1 + ) as exists + `; + + const result = await query(checkQuery, [logTableName]); + const exists = result[0]?.exists || false; + + res.json({ + success: true, + data: { + tableName, + logTableName, + exists, + historyEnabled: exists, + }, + message: exists ? "이력 테이블이 존재합니다." : "이력 테이블이 존재하지 않습니다.", + }); + } catch (error: any) { + logger.error(`❌ 이력 테이블 존재 여부 확인 실패:`, error); + + res.status(500).json({ + success: false, + message: "이력 테이블 확인 중 오류가 발생했습니다.", + error: error.message, + }); + } + } +} + diff --git a/backend-node/src/database/runMigration.ts b/backend-node/src/database/runMigration.ts index 61b98241..a1bb2ec5 100644 --- a/backend-node/src/database/runMigration.ts +++ b/backend-node/src/database/runMigration.ts @@ -1,4 +1,6 @@ -import { PostgreSQLService } from './PostgreSQLService'; +import { PostgreSQLService } from "./PostgreSQLService"; +import fs from "fs"; +import path from "path"; /** * 데이터베이스 마이그레이션 실행 @@ -6,21 +8,21 @@ import { PostgreSQLService } from './PostgreSQLService'; */ export async function runDashboardMigration() { try { - console.log('🔄 대시보드 마이그레이션 시작...'); + console.log("🔄 대시보드 마이그레이션 시작..."); // custom_title 컬럼 추가 await PostgreSQLService.query(` ALTER TABLE dashboard_elements ADD COLUMN IF NOT EXISTS custom_title VARCHAR(255) `); - console.log('✅ custom_title 컬럼 추가 완료'); + console.log("✅ custom_title 컬럼 추가 완료"); // show_header 컬럼 추가 await PostgreSQLService.query(` ALTER TABLE dashboard_elements ADD COLUMN IF NOT EXISTS show_header BOOLEAN DEFAULT true `); - console.log('✅ show_header 컬럼 추가 완료'); + console.log("✅ show_header 컬럼 추가 완료"); // 기존 데이터 업데이트 await PostgreSQLService.query(` @@ -28,15 +30,83 @@ export async function runDashboardMigration() { SET show_header = true WHERE show_header IS NULL `); - console.log('✅ 기존 데이터 업데이트 완료'); + console.log("✅ 기존 데이터 업데이트 완료"); - console.log('✅ 대시보드 마이그레이션 완료!'); + console.log("✅ 대시보드 마이그레이션 완료!"); } catch (error) { - console.error('❌ 대시보드 마이그레이션 실패:', error); + console.error("❌ 대시보드 마이그레이션 실패:", error); // 이미 컬럼이 있는 경우는 무시 - if (error instanceof Error && error.message.includes('already exists')) { - console.log('ℹ️ 컬럼이 이미 존재합니다.'); + if (error instanceof Error && error.message.includes("already exists")) { + console.log("ℹ️ 컬럼이 이미 존재합니다."); } } } +/** + * 테이블 이력 보기 버튼 액션 마이그레이션 + */ +export async function runTableHistoryActionMigration() { + try { + console.log("🔄 테이블 이력 보기 액션 마이그레이션 시작..."); + + // SQL 파일 읽기 + const sqlFilePath = path.join( + __dirname, + "../../db/migrations/024_add_table_history_view_action.sql" + ); + + if (!fs.existsSync(sqlFilePath)) { + console.log("⚠️ 마이그레이션 파일이 없습니다:", sqlFilePath); + return; + } + + const sqlContent = fs.readFileSync(sqlFilePath, "utf8"); + + // SQL 실행 + await PostgreSQLService.query(sqlContent); + + console.log("✅ 테이블 이력 보기 액션 마이그레이션 완료!"); + } catch (error) { + console.error("❌ 테이블 이력 보기 액션 마이그레이션 실패:", error); + // 이미 액션이 있는 경우는 무시 + if ( + error instanceof Error && + error.message.includes("duplicate key value") + ) { + console.log("ℹ️ 액션이 이미 존재합니다."); + } + } +} + +/** + * DTG Management 테이블 이력 시스템 마이그레이션 + */ +export async function runDtgManagementLogMigration() { + try { + console.log("🔄 DTG Management 이력 테이블 마이그레이션 시작..."); + + // SQL 파일 읽기 + const sqlFilePath = path.join( + __dirname, + "../../db/migrations/025_create_dtg_management_log.sql" + ); + + if (!fs.existsSync(sqlFilePath)) { + console.log("⚠️ 마이그레이션 파일이 없습니다:", sqlFilePath); + return; + } + + const sqlContent = fs.readFileSync(sqlFilePath, "utf8"); + + // SQL 실행 + await PostgreSQLService.query(sqlContent); + + console.log("✅ DTG Management 이력 테이블 마이그레이션 완료!"); + } catch (error) { + console.error("❌ DTG Management 이력 테이블 마이그레이션 실패:", error); + // 이미 테이블이 있는 경우는 무시 + if (error instanceof Error && error.message.includes("already exists")) { + console.log("ℹ️ 이력 테이블이 이미 존재합니다."); + } + } +} diff --git a/backend-node/src/middleware/permissionMiddleware.ts b/backend-node/src/middleware/permissionMiddleware.ts new file mode 100644 index 00000000..34679bf6 --- /dev/null +++ b/backend-node/src/middleware/permissionMiddleware.ts @@ -0,0 +1,430 @@ +/** + * 권한 체크 미들웨어 + * 3단계 권한 체계 적용: SUPER_ADMIN / COMPANY_ADMIN / USER + */ + +import { Request, Response, NextFunction } from "express"; +import { PersonBean } from "../types/auth"; +import { + isSuperAdmin, + isCompanyAdmin, + isAdmin, + canExecuteDDL, + canManageUsers, + canManageCompanySettings, + canManageCompanies, + canAccessCompanyData, + PermissionLevel, + createPermissionError, +} from "../utils/permissionUtils"; +import { logger } from "../utils/logger"; + +/** + * 인증된 요청 타입 + */ +export interface AuthenticatedRequest extends Request { + user?: PersonBean; +} + +/** + * 슈퍼관리자 권한 필수 미들웨어 + */ +export const requireSuperAdmin = ( + req: AuthenticatedRequest, + res: Response, + next: NextFunction +): void => { + try { + if (!req.user) { + logger.warn("슈퍼관리자 권한 필요 - 인증되지 않은 사용자", { + ip: req.ip, + url: req.originalUrl, + }); + + res.status(401).json({ + success: false, + error: { + code: "AUTHENTICATION_REQUIRED", + details: "인증이 필요합니다.", + }, + }); + return; + } + + if (!isSuperAdmin(req.user)) { + logger.warn("슈퍼관리자 권한 부족", { + userId: req.user.userId, + companyCode: req.user.companyCode, + userType: req.user.userType, + ip: req.ip, + url: req.originalUrl, + }); + + res.status(403).json(createPermissionError(PermissionLevel.SUPER_ADMIN)); + return; + } + + logger.info("슈퍼관리자 권한 확인 완료", { + userId: req.user.userId, + url: req.originalUrl, + }); + + next(); + } catch (error) { + logger.error("슈퍼관리자 권한 확인 중 오류:", error); + res.status(500).json({ + success: false, + error: { + code: "AUTHORIZATION_ERROR", + details: "권한 확인 중 오류가 발생했습니다.", + }, + }); + } +}; + +/** + * 관리자 권한 필수 미들웨어 (슈퍼관리자 + 회사관리자) + */ +export const requireAdmin = ( + req: AuthenticatedRequest, + res: Response, + next: NextFunction +): void => { + try { + if (!req.user) { + res.status(401).json({ + success: false, + error: { + code: "AUTHENTICATION_REQUIRED", + details: "인증이 필요합니다.", + }, + }); + return; + } + + if (!isAdmin(req.user)) { + logger.warn("관리자 권한 부족", { + userId: req.user.userId, + userType: req.user.userType, + companyCode: req.user.companyCode, + ip: req.ip, + url: req.originalUrl, + }); + + res + .status(403) + .json(createPermissionError(PermissionLevel.COMPANY_ADMIN)); + return; + } + + logger.info("관리자 권한 확인 완료", { + userId: req.user.userId, + userType: req.user.userType, + url: req.originalUrl, + }); + + next(); + } catch (error) { + logger.error("관리자 권한 확인 중 오류:", error); + res.status(500).json({ + success: false, + error: { + code: "AUTHORIZATION_ERROR", + details: "권한 확인 중 오류가 발생했습니다.", + }, + }); + } +}; + +/** + * 회사 데이터 접근 권한 체크 미들웨어 + * req.params.companyCode 또는 req.query.companyCode 확인 + */ +export const requireCompanyAccess = ( + req: AuthenticatedRequest, + res: Response, + next: NextFunction +): void => { + try { + if (!req.user) { + res.status(401).json({ + success: false, + error: { + code: "AUTHENTICATION_REQUIRED", + details: "인증이 필요합니다.", + }, + }); + return; + } + + const targetCompanyCode = + (req.params.companyCode as string) || + (req.query.companyCode as string) || + (req.body.companyCode as string); + + if (!targetCompanyCode) { + res.status(400).json({ + success: false, + error: { + code: "COMPANY_CODE_REQUIRED", + details: "회사 코드가 필요합니다.", + }, + }); + return; + } + + if (!canAccessCompanyData(req.user, targetCompanyCode)) { + logger.warn("회사 데이터 접근 권한 없음", { + userId: req.user.userId, + userCompanyCode: req.user.companyCode, + targetCompanyCode, + ip: req.ip, + url: req.originalUrl, + }); + + res.status(403).json({ + success: false, + error: { + code: "COMPANY_ACCESS_DENIED", + details: "해당 회사의 데이터에 접근할 권한이 없습니다.", + }, + }); + return; + } + + next(); + } catch (error) { + logger.error("회사 데이터 접근 권한 확인 중 오류:", error); + res.status(500).json({ + success: false, + error: { + code: "AUTHORIZATION_ERROR", + details: "권한 확인 중 오류가 발생했습니다.", + }, + }); + } +}; + +/** + * 사용자 관리 권한 체크 미들웨어 + */ +export const requireUserManagement = ( + req: AuthenticatedRequest, + res: Response, + next: NextFunction +): void => { + try { + if (!req.user) { + res.status(401).json({ + success: false, + error: { + code: "AUTHENTICATION_REQUIRED", + details: "인증이 필요합니다.", + }, + }); + return; + } + + const targetCompanyCode = + (req.params.companyCode as string) || + (req.query.companyCode as string) || + (req.body.companyCode as string); + + if (!canManageUsers(req.user, targetCompanyCode)) { + logger.warn("사용자 관리 권한 없음", { + userId: req.user.userId, + userCompanyCode: req.user.companyCode, + targetCompanyCode, + ip: req.ip, + url: req.originalUrl, + }); + + res.status(403).json({ + success: false, + error: { + code: "USER_MANAGEMENT_DENIED", + details: "사용자 관리 권한이 없습니다.", + }, + }); + return; + } + + next(); + } catch (error) { + logger.error("사용자 관리 권한 확인 중 오류:", error); + res.status(500).json({ + success: false, + error: { + code: "AUTHORIZATION_ERROR", + details: "권한 확인 중 오류가 발생했습니다.", + }, + }); + } +}; + +/** + * 회사 설정 변경 권한 체크 미들웨어 + */ +export const requireCompanySettingsManagement = ( + req: AuthenticatedRequest, + res: Response, + next: NextFunction +): void => { + try { + if (!req.user) { + res.status(401).json({ + success: false, + error: { + code: "AUTHENTICATION_REQUIRED", + details: "인증이 필요합니다.", + }, + }); + return; + } + + const targetCompanyCode = + (req.params.companyCode as string) || + (req.query.companyCode as string) || + (req.body.companyCode as string); + + if (!canManageCompanySettings(req.user, targetCompanyCode)) { + logger.warn("회사 설정 변경 권한 없음", { + userId: req.user.userId, + userCompanyCode: req.user.companyCode, + targetCompanyCode, + ip: req.ip, + url: req.originalUrl, + }); + + res.status(403).json({ + success: false, + error: { + code: "COMPANY_SETTINGS_DENIED", + details: "회사 설정 변경 권한이 없습니다.", + }, + }); + return; + } + + next(); + } catch (error) { + logger.error("회사 설정 변경 권한 확인 중 오류:", error); + res.status(500).json({ + success: false, + error: { + code: "AUTHORIZATION_ERROR", + details: "권한 확인 중 오류가 발생했습니다.", + }, + }); + } +}; + +/** + * 회사 생성/삭제 권한 체크 미들웨어 (슈퍼관리자 전용) + */ +export const requireCompanyManagement = ( + req: AuthenticatedRequest, + res: Response, + next: NextFunction +): void => { + try { + if (!req.user) { + res.status(401).json({ + success: false, + error: { + code: "AUTHENTICATION_REQUIRED", + details: "인증이 필요합니다.", + }, + }); + return; + } + + if (!canManageCompanies(req.user)) { + logger.warn("회사 관리 권한 없음", { + userId: req.user.userId, + userType: req.user.userType, + companyCode: req.user.companyCode, + ip: req.ip, + url: req.originalUrl, + }); + + res.status(403).json({ + success: false, + error: { + code: "COMPANY_MANAGEMENT_DENIED", + details: "회사 생성/삭제는 최고 관리자만 가능합니다.", + }, + }); + return; + } + + next(); + } catch (error) { + logger.error("회사 관리 권한 확인 중 오류:", error); + res.status(500).json({ + success: false, + error: { + code: "AUTHORIZATION_ERROR", + details: "권한 확인 중 오류가 발생했습니다.", + }, + }); + } +}; + +/** + * DDL 실행 권한 체크 미들웨어 (슈퍼관리자 전용) + */ +export const requireDDLPermission = ( + req: AuthenticatedRequest, + res: Response, + next: NextFunction +): void => { + try { + if (!req.user) { + res.status(401).json({ + success: false, + error: { + code: "AUTHENTICATION_REQUIRED", + details: "인증이 필요합니다.", + }, + }); + return; + } + + if (!canExecuteDDL(req.user)) { + logger.warn("DDL 실행 권한 없음", { + userId: req.user.userId, + userType: req.user.userType, + companyCode: req.user.companyCode, + ip: req.ip, + url: req.originalUrl, + }); + + res.status(403).json({ + success: false, + error: { + code: "DDL_EXECUTION_DENIED", + details: + "DDL 실행은 최고 관리자만 가능합니다. 데이터베이스 스키마 변경은 company_code가 '*'이고 user_type이 'SUPER_ADMIN'인 사용자만 수행할 수 있습니다.", + }, + }); + return; + } + + logger.info("DDL 실행 권한 확인 완료", { + userId: req.user.userId, + url: req.originalUrl, + }); + + next(); + } catch (error) { + logger.error("DDL 실행 권한 확인 중 오류:", error); + res.status(500).json({ + success: false, + error: { + code: "AUTHORIZATION_ERROR", + details: "권한 확인 중 오류가 발생했습니다.", + }, + }); + } +}; diff --git a/backend-node/src/routes/adminRoutes.ts b/backend-node/src/routes/adminRoutes.ts index 895b96e9..c6ae0bfc 100644 --- a/backend-node/src/routes/adminRoutes.ts +++ b/backend-node/src/routes/adminRoutes.ts @@ -45,7 +45,8 @@ router.get("/users", getUserList); router.get("/users/:userId", getUserInfo); // 사용자 상세 조회 router.get("/users/:userId/history", getUserHistory); // 사용자 변경이력 조회 router.patch("/users/:userId/status", changeUserStatus); // 사용자 상태 변경 -router.post("/users", saveUser); // 사용자 등록/수정 +router.post("/users", saveUser); // 사용자 등록/수정 (기존) +router.put("/users/:userId", saveUser); // 사용자 수정 (REST API) router.put("/profile", updateProfile); // 프로필 수정 router.post("/users/check-duplicate", checkDuplicateUserId); // 사용자 ID 중복 체크 router.post("/users/reset-password", resetUserPassword); // 사용자 비밀번호 초기화 diff --git a/backend-node/src/routes/dataflow/node-flows.ts b/backend-node/src/routes/dataflow/node-flows.ts index 0e9a2d3e..7ede970a 100644 --- a/backend-node/src/routes/dataflow/node-flows.ts +++ b/backend-node/src/routes/dataflow/node-flows.ts @@ -6,27 +6,39 @@ import { Router, Request, Response } from "express"; import { query, queryOne } from "../../database/db"; import { logger } from "../../utils/logger"; import { NodeFlowExecutionService } from "../../services/nodeFlowExecutionService"; +import { AuthenticatedRequest } from "../../types/auth"; const router = Router(); /** * 플로우 목록 조회 */ -router.get("/", async (req: Request, res: Response) => { +router.get("/", async (req: AuthenticatedRequest, res: Response) => { try { - const flows = await query( - ` + const userCompanyCode = req.user?.companyCode; + + let sqlQuery = ` SELECT flow_id as "flowId", flow_name as "flowName", flow_description as "flowDescription", + company_code as "companyCode", created_at as "createdAt", updated_at as "updatedAt" FROM node_flows - ORDER BY updated_at DESC - `, - [] - ); + `; + + const params: any[] = []; + + // 슈퍼 관리자가 아니면 회사별 필터링 + if (userCompanyCode && userCompanyCode !== "*") { + sqlQuery += ` WHERE company_code = $1`; + params.push(userCompanyCode); + } + + sqlQuery += ` ORDER BY updated_at DESC`; + + const flows = await query(sqlQuery, params); return res.json({ success: true, @@ -86,9 +98,10 @@ router.get("/:flowId", async (req: Request, res: Response) => { /** * 플로우 저장 (신규) */ -router.post("/", async (req: Request, res: Response) => { +router.post("/", async (req: AuthenticatedRequest, res: Response) => { try { const { flowName, flowDescription, flowData } = req.body; + const userCompanyCode = req.user?.companyCode || "*"; if (!flowName || !flowData) { return res.status(400).json({ @@ -99,14 +112,16 @@ router.post("/", async (req: Request, res: Response) => { const result = await queryOne( ` - INSERT INTO node_flows (flow_name, flow_description, flow_data) - VALUES ($1, $2, $3) + INSERT INTO node_flows (flow_name, flow_description, flow_data, company_code) + VALUES ($1, $2, $3, $4) RETURNING flow_id as "flowId" `, - [flowName, flowDescription || "", flowData] + [flowName, flowDescription || "", flowData, userCompanyCode] ); - logger.info(`플로우 저장 성공: ${result.flowId}`); + logger.info( + `플로우 저장 성공: ${result.flowId} (회사: ${userCompanyCode})` + ); return res.json({ success: true, diff --git a/backend-node/src/routes/externalDbConnectionRoutes.ts b/backend-node/src/routes/externalDbConnectionRoutes.ts index ca7d1600..5ad87dab 100644 --- a/backend-node/src/routes/externalDbConnectionRoutes.ts +++ b/backend-node/src/routes/externalDbConnectionRoutes.ts @@ -9,6 +9,7 @@ import { } from "../types/externalDbTypes"; import { authenticateToken } from "../middleware/authMiddleware"; import { AuthenticatedRequest } from "../types/auth"; +import logger from "../utils/logger"; const router = Router(); @@ -53,10 +54,22 @@ router.get( authenticateToken, async (req: AuthenticatedRequest, res: Response) => { try { + const userCompanyCode = req.user?.companyCode; + + // 슈퍼 관리자는 쿼리 파라미터로 회사 지정 가능, 일반/회사 관리자는 자신의 회사만 + let companyCodeFilter: string | undefined; + if (userCompanyCode === "*") { + // 슈퍼 관리자: 쿼리 파라미터 사용 또는 전체 + companyCodeFilter = req.query.company_code as string; + } else { + // 회사 관리자/일반 사용자: 강제로 자신의 회사 코드 적용 + companyCodeFilter = userCompanyCode; + } + const filter: ExternalDbConnectionFilter = { db_type: req.query.db_type as string, is_active: req.query.is_active as string, - company_code: req.query.company_code as string, + company_code: companyCodeFilter, search: req.query.search as string, }; @@ -67,6 +80,13 @@ router.get( } }); + logger.info("외부 DB 연결 목록 조회", { + userId: req.user?.userId, + userCompanyCode, + filterCompanyCode: companyCodeFilter, + filter, + }); + const result = await ExternalDbConnectionService.getConnections(filter); if (result.success) { @@ -470,12 +490,32 @@ router.get( authenticateToken, async (req: AuthenticatedRequest, res: Response) => { try { + // 로그인한 사용자의 회사 코드 가져오기 + const userCompanyCode = req.user?.companyCode; + + // 슈퍼 관리자는 쿼리 파라미터로 지정한 회사 또는 전체(*) 조회 가능 + // 일반 사용자/회사 관리자는 자신의 회사만 조회 가능 + let companyCodeFilter: string; + if (userCompanyCode === "*") { + // 슈퍼 관리자 + companyCodeFilter = (req.query.company_code as string) || "*"; + } else { + // 회사 관리자 또는 일반 사용자 + companyCodeFilter = userCompanyCode || "*"; + } + // 활성 상태의 외부 커넥션 조회 const filter: ExternalDbConnectionFilter = { is_active: "Y", - company_code: (req.query.company_code as string) || "*", + company_code: companyCodeFilter, }; + logger.info("제어관리용 활성 커넥션 조회", { + userId: req.user?.userId, + userCompanyCode, + filterCompanyCode: companyCodeFilter, + }); + const externalConnections = await ExternalDbConnectionService.getConnections(filter); diff --git a/backend-node/src/routes/flowRoutes.ts b/backend-node/src/routes/flowRoutes.ts index 06c6795b..08d1ac5f 100644 --- a/backend-node/src/routes/flowRoutes.ts +++ b/backend-node/src/routes/flowRoutes.ts @@ -9,6 +9,9 @@ import { authenticateToken } from "../middleware/authMiddleware"; const router = Router(); const flowController = new FlowController(); +// 모든 플로우 라우트에 인증 미들웨어 적용 +router.use(authenticateToken); + // ==================== 플로우 정의 ==================== router.post("/definitions", flowController.createFlowDefinition); router.get("/definitions", flowController.getFlowDefinitions); @@ -33,8 +36,8 @@ router.get("/:flowId/step/:stepId/list", flowController.getStepDataList); router.get("/:flowId/steps/counts", flowController.getAllStepCounts); // ==================== 데이터 이동 ==================== -router.post("/move", authenticateToken, flowController.moveData); -router.post("/move-batch", authenticateToken, flowController.moveBatchData); +router.post("/move", flowController.moveData); +router.post("/move-batch", flowController.moveBatchData); // ==================== 오딧 로그 ==================== router.get("/audit/:flowId/:recordId", flowController.getAuditLogs); diff --git a/backend-node/src/routes/roleRoutes.ts b/backend-node/src/routes/roleRoutes.ts new file mode 100644 index 00000000..21c17ecb --- /dev/null +++ b/backend-node/src/routes/roleRoutes.ts @@ -0,0 +1,79 @@ +import { Router } from "express"; +import { + getRoleGroups, + getRoleGroupById, + createRoleGroup, + updateRoleGroup, + deleteRoleGroup, + getRoleMembers, + addRoleMembers, + updateRoleMembers, + removeRoleMembers, + getMenuPermissions, + setMenuPermissions, + getUserRoleGroups, + getAllMenus, +} from "../controllers/roleController"; +import { authenticateToken } from "../middleware/authMiddleware"; +import { requireAdmin } from "../middleware/permissionMiddleware"; + +const router = Router(); + +// 모든 role 라우트에 인증 미들웨어 적용 +router.use(authenticateToken); + +/** + * 권한 그룹 CRUD + */ +// 권한 그룹 목록 조회 (회사별) +router.get("/", requireAdmin, getRoleGroups); + +// 권한 그룹 상세 조회 +router.get("/:id", requireAdmin, getRoleGroupById); + +// 권한 그룹 생성 (회사 관리자 이상) +router.post("/", requireAdmin, createRoleGroup); + +// 권한 그룹 수정 (회사 관리자 이상) +router.put("/:id", requireAdmin, updateRoleGroup); + +// 권한 그룹 삭제 (회사 관리자 이상) +router.delete("/:id", requireAdmin, deleteRoleGroup); + +/** + * 권한 그룹 멤버 관리 + */ +// 권한 그룹 멤버 목록 조회 +router.get("/:id/members", requireAdmin, getRoleMembers); + +// 권한 그룹 멤버 일괄 업데이트 (전체 교체) +router.put("/:id/members", requireAdmin, updateRoleMembers); + +// 권한 그룹 멤버 추가 (여러 명) +router.post("/:id/members", requireAdmin, addRoleMembers); + +// 권한 그룹 멤버 제거 (여러 명) +router.delete("/:id/members", requireAdmin, removeRoleMembers); + +/** + * 메뉴 권한 관리 + */ +// 전체 메뉴 목록 조회 (권한 설정용) +router.get("/menus/all", requireAdmin, getAllMenus); + +// 메뉴 권한 목록 조회 +router.get("/:id/menu-permissions", requireAdmin, getMenuPermissions); + +// 메뉴 권한 설정 +router.put("/:id/menu-permissions", requireAdmin, setMenuPermissions); + +/** + * 사용자 권한 그룹 조회 + */ +// 현재 사용자가 속한 권한 그룹 조회 +router.get("/user/my-groups", getUserRoleGroups); + +// 특정 사용자가 속한 권한 그룹 조회 +router.get("/user/:userId/groups", requireAdmin, getUserRoleGroups); + +export default router; diff --git a/backend-node/src/routes/tableHistoryRoutes.ts b/backend-node/src/routes/tableHistoryRoutes.ts new file mode 100644 index 00000000..c218ba37 --- /dev/null +++ b/backend-node/src/routes/tableHistoryRoutes.ts @@ -0,0 +1,35 @@ +/** + * 테이블 이력 조회 라우트 + */ + +import { Router } from "express"; +import { authenticateToken } from "../middleware/authMiddleware"; +import { TableHistoryController } from "../controllers/tableHistoryController"; + +const router = Router(); + +// 모든 라우트에 인증 미들웨어 적용 +router.use(authenticateToken); + +// 이력 테이블 존재 여부 확인 +router.get("/:tableName/check", TableHistoryController.checkHistoryTableExists); + +// 테이블 전체 이력 요약 +router.get( + "/:tableName/summary", + TableHistoryController.getTableHistorySummary +); + +// 전체 테이블 이력 조회 (레코드 ID 없이) +router.get("/:tableName/all", TableHistoryController.getAllTableHistory); + +// 특정 레코드의 타임라인 +router.get( + "/:tableName/:recordId/timeline", + TableHistoryController.getRecordTimeline +); + +// 특정 레코드의 변경 이력 (상세) +router.get("/:tableName/:recordId", TableHistoryController.getRecordHistory); + +export default router; diff --git a/backend-node/src/services/RoleService_backup.ts b/backend-node/src/services/RoleService_backup.ts new file mode 100644 index 00000000..2932a2cc --- /dev/null +++ b/backend-node/src/services/RoleService_backup.ts @@ -0,0 +1,554 @@ +import { query } from "../database/db"; +import { logger } from "../utils/logger"; + +/** + * 권한 그룹 인터페이스 + */ +export interface RoleGroup { + objid: number; + authName: string; + authCode: string; + companyCode: string; + status: string; + writer: string; + regdate: Date; + memberCount?: number; + menuCount?: number; + memberNames?: string; +} + +/** + * 권한 그룹 멤버 인터페이스 + */ +export interface RoleMember { + objid: number; + masterObjid: number; + userId: string; + userName?: string; + deptName?: string; + positionName?: string; + writer: string; + regdate: Date; +} + +/** + * 메뉴 권한 인터페이스 + */ +export interface MenuPermission { + objid: number; + menuObjid: number; + authObjid: number; + menuName?: string; + createYn: string; + readYn: string; + updateYn: string; + deleteYn: string; + writer: string; + regdate: Date; +} + +/** + * 권한 그룹 서비스 + */ +export class RoleService { + /** + * 회사별 권한 그룹 목록 조회 + * @param companyCode - 회사 코드 (undefined 시 전체 조회) + * @param search - 검색어 + */ + static async getRoleGroups( + companyCode?: string, + search?: string + ): Promise { + try { + let sql = ` + SELECT + objid, + auth_name AS "authName", + auth_code AS "authCode", + company_code AS "companyCode", + status, + writer, + regdate, + (SELECT COUNT(*) FROM authority_sub_user asu WHERE asu.master_objid = am.objid) AS "memberCount", + (SELECT COUNT(*) FROM rel_menu_auth rma WHERE rma.auth_objid = am.objid) AS "menuCount", + (SELECT STRING_AGG(ui.user_name, ', ' ORDER BY ui.user_name) + FROM authority_sub_user asu + JOIN user_info ui ON asu.user_id = ui.user_id + WHERE asu.master_objid = am.objid) AS "memberNames" + FROM authority_master am + WHERE 1=1 + `; + + const params: any[] = []; + let paramIndex = 1; + + // 회사 코드 필터 (companyCode가 undefined면 전체 조회) + if (companyCode) { + sql += ` AND company_code = $${paramIndex}`; + params.push(companyCode); + paramIndex++; + } + + // 검색어 필터 + if (search && search.trim()) { + sql += ` AND (auth_name ILIKE $${paramIndex} OR auth_code ILIKE $${paramIndex})`; + params.push(`%${search.trim()}%`); + paramIndex++; + } + + sql += ` ORDER BY regdate DESC`; + + logger.info("권한 그룹 조회 SQL", { sql, params }); + const result = await query(sql, params); + logger.info("권한 그룹 조회 결과", { count: result.length }); + return result; + } catch (error) { + logger.error("권한 그룹 목록 조회 실패", { error, companyCode, search }); + throw error; + } + } + + /** + * 권한 그룹 상세 조회 + */ + static async getRoleGroupById(objid: number): Promise { + try { + const sql = ` + SELECT + objid, + auth_name AS "authName", + auth_code AS "authCode", + company_code AS "companyCode", + status, + writer, + regdate + FROM authority_master + WHERE objid = $1 + `; + + const result = await query(sql, [objid]); + return result.length > 0 ? result[0] : null; + } catch (error) { + logger.error("권한 그룹 상세 조회 실패", { error, objid }); + throw error; + } + } + + /** + * 권한 그룹 생성 + */ + static async createRoleGroup(data: { + authName: string; + authCode: string; + companyCode: string; + writer: string; + }): Promise { + try { + const sql = ` + INSERT INTO authority_master (objid, auth_name, auth_code, company_code, status, writer, regdate) + VALUES (nextval('seq_authority_master'), $1, $2, $3, 'active', $4, NOW()) + RETURNING objid, auth_name AS "authName", auth_code AS "authCode", + company_code AS "companyCode", status, writer, regdate + `; + + const result = await query(sql, [ + data.authName, + data.authCode, + data.companyCode, + data.writer, + ]); + + logger.info("권한 그룹 생성 성공", { + objid: result[0].objid, + authName: data.authName, + }); + return result[0]; + } catch (error) { + logger.error("권한 그룹 생성 실패", { error, data }); + throw error; + } + } + + /** + * 권한 그룹 수정 + */ + static async updateRoleGroup( + objid: number, + data: { + authName?: string; + authCode?: string; + status?: string; + } + ): Promise { + try { + const updates: string[] = []; + const params: any[] = []; + let paramIndex = 1; + + if (data.authName !== undefined) { + updates.push(`auth_name = $${paramIndex}`); + params.push(data.authName); + paramIndex++; + } + + if (data.authCode !== undefined) { + updates.push(`auth_code = $${paramIndex}`); + params.push(data.authCode); + paramIndex++; + } + + if (data.status !== undefined) { + updates.push(`status = $${paramIndex}`); + params.push(data.status); + paramIndex++; + } + + if (updates.length === 0) { + throw new Error("수정할 데이터가 없습니다"); + } + + params.push(objid); + + const sql = ` + UPDATE authority_master + SET ${updates.join(", ")} + WHERE objid = $${paramIndex} + RETURNING objid, auth_name AS "authName", auth_code AS "authCode", + company_code AS "companyCode", status, writer, regdate + `; + + const result = await query(sql, params); + + if (result.length === 0) { + throw new Error("권한 그룹을 찾을 수 없습니다"); + } + + logger.info("권한 그룹 수정 성공", { objid, updates }); + return result[0]; + } catch (error) { + logger.error("권한 그룹 수정 실패", { error, objid, data }); + throw error; + } + } + + /** + * 권한 그룹 삭제 + */ + static async deleteRoleGroup(objid: number): Promise { + try { + // CASCADE로 연결된 데이터도 함께 삭제됨 (authority_sub_user, rel_menu_auth) + await query("DELETE FROM authority_master WHERE objid = $1", [objid]); + logger.info("권한 그룹 삭제 성공", { objid }); + } catch (error) { + logger.error("권한 그룹 삭제 실패", { error, objid }); + throw error; + } + } + + /** + * 권한 그룹 멤버 목록 조회 + */ + static async getRoleMembers(masterObjid: number): Promise { + try { + const sql = ` + SELECT + asu.objid, + asu.master_objid AS "masterObjid", + asu.user_id AS "userId", + ui.user_name AS "userName", + ui.dept_name AS "deptName", + ui.position_name AS "positionName", + asu.writer, + asu.regdate + FROM authority_sub_user asu + JOIN user_info ui ON asu.user_id = ui.user_id + WHERE asu.master_objid = $1 + ORDER BY ui.user_name + `; + + const result = await query(sql, [masterObjid]); + return result; + } catch (error) { + logger.error("권한 그룹 멤버 조회 실패", { error, masterObjid }); + throw error; + } + } + + /** + * 권한 그룹 멤버 추가 (여러 명) + */ + static async addRoleMembers( + masterObjid: number, + userIds: string[], + writer: string + ): Promise { + try { + // 이미 존재하는 멤버 제외 + const existingSql = ` + SELECT user_id + FROM authority_sub_user + WHERE master_objid = $1 AND user_id = ANY($2) + `; + const existing = await query<{ user_id: string }>(existingSql, [ + masterObjid, + userIds, + ]); + const existingIds = new Set( + existing.map((row: { user_id: string }) => row.user_id) + ); + + const newUserIds = userIds.filter((userId) => !existingIds.has(userId)); + + if (newUserIds.length === 0) { + logger.info("추가할 신규 멤버가 없습니다", { masterObjid, userIds }); + return; + } + + // 배치 삽입 + const values = newUserIds + .map( + (_, index) => + `(nextval('seq_authority_sub_user'), $1, $${index + 2}, $${newUserIds.length + 2}, NOW())` + ) + .join(", "); + + const sql = ` + INSERT INTO authority_sub_user (objid, master_objid, user_id, writer, regdate) + VALUES ${values} + `; + + await query(sql, [masterObjid, ...newUserIds, writer]); + + // 히스토리 기록 + for (const userId of newUserIds) { + await this.insertAuthorityHistory(masterObjid, userId, "ADD", writer); + } + + logger.info("권한 그룹 멤버 추가 성공", { + masterObjid, + count: newUserIds.length, + }); + } catch (error) { + logger.error("권한 그룹 멤버 추가 실패", { error, masterObjid, userIds }); + throw error; + } + } + + /** + * 권한 그룹 멤버 제거 (여러 명) + */ + static async removeRoleMembers( + masterObjid: number, + userIds: string[], + writer: string + ): Promise { + try { + await query( + "DELETE FROM authority_sub_user WHERE master_objid = $1 AND user_id = ANY($2)", + [masterObjid, userIds] + ); + + // 히스토리 기록 + for (const userId of userIds) { + await this.insertAuthorityHistory( + masterObjid, + userId, + "REMOVE", + writer + ); + } + + logger.info("권한 그룹 멤버 제거 성공", { + masterObjid, + count: userIds.length, + }); + } catch (error) { + logger.error("권한 그룹 멤버 제거 실패", { error, masterObjid, userIds }); + throw error; + } + } + + /** + * 권한 히스토리 기록 + */ + private static async insertAuthorityHistory( + masterObjid: number, + userId: string, + historyType: "ADD" | "REMOVE", + writer: string + ): Promise { + try { + const sql = ` + INSERT INTO authority_master_history + (objid, parent_objid, parent_name, parent_code, user_id, active, history_type, writer, reg_date) + SELECT + nextval('seq_authority_master'), + $1, + am.auth_name, + am.auth_code, + $2, + am.status, + $3, + $4, + NOW() + FROM authority_master am + WHERE am.objid = $1 + `; + + await query(sql, [masterObjid, userId, historyType, writer]); + } catch (error) { + logger.error("권한 히스토리 기록 실패", { + error, + masterObjid, + userId, + historyType, + }); + // 히스토리 기록 실패는 메인 작업을 중단하지 않음 + } + } + + /** + * 메뉴 권한 목록 조회 + */ + static async getMenuPermissions( + authObjid: number + ): Promise { + try { + const sql = ` + SELECT + rma.objid, + rma.menu_objid AS "menuObjid", + rma.auth_objid AS "authObjid", + mi.menu_name_kor AS "menuName", + mi.menu_code AS "menuCode", + mi.menu_url AS "menuUrl", + rma.create_yn AS "createYn", + rma.read_yn AS "readYn", + rma.update_yn AS "updateYn", + rma.delete_yn AS "deleteYn", + rma.execute_yn AS "executeYn", + rma.export_yn AS "exportYn", + rma.writer, + rma.regdate + FROM rel_menu_auth rma + LEFT JOIN menu_info mi ON rma.menu_objid = mi.objid + WHERE rma.auth_objid = $1 + ORDER BY mi.menu_name_kor + `; + + const result = await query(sql, [authObjid]); + return result; + } catch (error) { + logger.error("메뉴 권한 조회 실패", { error, authObjid }); + throw error; + } + } + + /** + * 메뉴 권한 설정 (여러 메뉴) + */ + static async setMenuPermissions( + authObjid: number, + permissions: Array<{ + menuObjid: number; + createYn: string; + readYn: string; + updateYn: string; + deleteYn: string; + }>, + writer: string + ): Promise { + try { + // 기존 권한 삭제 + await query("DELETE FROM rel_menu_auth WHERE auth_objid = $1", [ + authObjid, + ]); + + // 새로운 권한 삽입 + if (permissions.length > 0) { + const values = permissions + .map( + (_, index) => + `(nextval('seq_rel_menu_auth'), $${index * 5 + 2}, $1, $${index * 5 + 3}, $${index * 5 + 4}, $${index * 5 + 5}, $${index * 5 + 6}, $${permissions.length * 5 + 2}, NOW())` + ) + .join(", "); + + const params = permissions.flatMap((p) => [ + p.menuObjid, + p.createYn, + p.readYn, + p.updateYn, + p.deleteYn, + ]); + + const sql = ` + INSERT INTO rel_menu_auth (objid, menu_objid, auth_objid, create_yn, read_yn, update_yn, delete_yn, writer, regdate) + VALUES ${values} + `; + + await query(sql, [authObjid, ...params, writer]); + } + + logger.info("메뉴 권한 설정 성공", { + authObjid, + count: permissions.length, + }); + } catch (error) { + logger.error("메뉴 권한 설정 실패", { error, authObjid, permissions }); + throw error; + } + } + + /** + * 사용자가 속한 권한 그룹 목록 조회 + */ + static async getUserRoleGroups( + userId: string, + companyCode: string + ): Promise { + try { + const sql = ` + SELECT + am.objid, + am.auth_name AS "authName", + am.auth_code AS "authCode", + am.company_code AS "companyCode", + am.status, + am.writer, + am.regdate + FROM authority_master am + JOIN authority_sub_user asu ON am.objid = asu.master_objid + WHERE asu.user_id = $1 + AND am.company_code = $2 + AND am.status = 'active' + ORDER BY am.auth_name + `; + + const result = await query(sql, [userId, companyCode]); + return result; + } catch (error) { + logger.error("사용자 권한 그룹 조회 실패", { + error, + userId, + companyCode, + }); + throw error; + } + } + + /** + * 전체 메뉴 목록 조회 (권한 설정용) + */ + /** + * 전체 메뉴 목록 조회 (권한 설정용) + */ + static async getAllMenus(companyCode?: string): Promise { + try { + logger.info("🔍 전체 메뉴 목록 조회 시작", { companyCode }); + + let whereConditions: string[] = ["status = 'active'"]; + const params: any[] = []; + let paramIndex = 1; + + // 회사 코드 필터 (선택적) diff --git a/backend-node/src/services/RoleService_getAllMenus_fixed.ts b/backend-node/src/services/RoleService_getAllMenus_fixed.ts new file mode 100644 index 00000000..9dd1689d --- /dev/null +++ b/backend-node/src/services/RoleService_getAllMenus_fixed.ts @@ -0,0 +1,66 @@ + /** + * 전체 메뉴 목록 조회 (권한 설정용) + */ + static async getAllMenus(companyCode?: string): Promise { + try { + logger.info("🔍 전체 메뉴 목록 조회 시작", { companyCode }); + + let whereConditions: string[] = ["status = 'active'"]; + const params: any[] = []; + let paramIndex = 1; + + // 회사 코드 필터 (선택적) + // 공통 메뉴(*)와 특정 회사 메뉴를 모두 조회 + if (companyCode) { + whereConditions.push(`(company_code = \$${paramIndex} OR company_code = '*')`); + params.push(companyCode); + paramIndex++; + logger.info("📋 회사 코드 필터 적용", { companyCode }); + } else { + logger.info("📋 회사 코드 필터 없음 (전체 조회)"); + } + + const whereClause = whereConditions.join(" AND "); + + const sql = ` + SELECT + objid, + menu_name_kor AS "menuName", + menu_name_eng AS "menuNameEng", + menu_code AS "menuCode", + menu_url AS "menuUrl", + menu_type AS "menuType", + parent_obj_id AS "parentObjid", + seq AS "sortOrder", + company_code AS "companyCode" + FROM menu_info + WHERE ${whereClause} + ORDER BY seq, menu_name_kor + `; + + logger.info("🔍 SQL 쿼리 실행", { + whereClause, + params, + sql: sql.substring(0, 200) + "...", + }); + + const result = await query(sql, params); + + logger.info("✅ 메뉴 목록 조회 성공", { + count: result.length, + companyCode, + menus: result.map((m) => ({ + objid: m.objid, + name: m.menuName, + code: m.menuCode, + companyCode: m.companyCode, + })), + }); + + return result; + } catch (error) { + logger.error("❌ 메뉴 목록 조회 실패", { error, companyCode }); + throw error; + } + } +} diff --git a/backend-node/src/services/adminService.ts b/backend-node/src/services/adminService.ts index ebddba3f..d1a7fdd2 100644 --- a/backend-node/src/services/adminService.ts +++ b/backend-node/src/services/adminService.ts @@ -3,18 +3,128 @@ import { query, queryOne } from "../database/db"; export class AdminService { /** - * 관리자 메뉴 목록 조회 + * 관리자 메뉴 목록 조회 (회사별 필터링 적용) */ static async getAdminMenuList(paramMap: any): Promise { try { logger.info("AdminService.getAdminMenuList 시작 - 파라미터:", paramMap); - const { userLang = "ko", menuType } = paramMap; + const { + userId, + userCompanyCode, + userType, + userLang = "ko", + menuType, + } = paramMap; // menuType에 따른 WHERE 조건 생성 const menuTypeCondition = menuType !== undefined ? `MENU_TYPE = ${parseInt(menuType)}` : "1 = 1"; + // 1. 권한 그룹 기반 필터링 (좌측 사이드바인 경우만) + let authFilter = ""; + let queryParams: any[] = [userLang]; + let paramIndex = 2; + + if (menuType !== undefined && userType !== "SUPER_ADMIN") { + // 좌측 사이드바 + SUPER_ADMIN이 아닌 경우 + const userRoleGroups = await query( + ` + SELECT DISTINCT am.objid AS role_objid, am.auth_name + FROM authority_master am + JOIN authority_sub_user asu ON am.objid = asu.master_objid + WHERE asu.user_id = $1 + AND am.status = 'active' + `, + [userId] + ); + + logger.info( + `✅ 사용자 ${userId}가 속한 권한 그룹: ${userRoleGroups.length}개`, + { + roleGroups: userRoleGroups.map((rg: any) => rg.auth_name), + } + ); + + if (userType === "COMPANY_ADMIN") { + // 회사 관리자: 자기 회사 메뉴는 모두, 공통 메뉴는 권한 있는 것만 + if (userRoleGroups.length > 0) { + const roleObjids = userRoleGroups.map((rg: any) => rg.role_objid); + // 루트 메뉴: 회사 코드만 체크 (권한 체크 X) + // 하위 메뉴: 회사 메뉴는 모두, 공통 메뉴는 권한 체크 + authFilter = `AND MENU.COMPANY_CODE IN ($${paramIndex}, '*')`; + queryParams.push(userCompanyCode); + queryParams.push(roleObjids); + paramIndex += 2; + logger.info( + `✅ 회사 관리자: 회사 ${userCompanyCode} 메뉴 전체 + 권한 있는 공통 메뉴` + ); + } else { + // 권한 그룹이 없는 회사 관리자: 자기 회사 메뉴만 + authFilter = `AND MENU.COMPANY_CODE = $${paramIndex}`; + queryParams.push(userCompanyCode); + paramIndex++; + logger.info( + `✅ 회사 관리자 (권한 그룹 없음): 회사 ${userCompanyCode} 메뉴만` + ); + } + } else { + // 일반 사용자: 권한 그룹 필수 + if (userRoleGroups.length > 0) { + const roleObjids = userRoleGroups.map((rg: any) => rg.role_objid); + authFilter = ` + AND EXISTS ( + SELECT 1 + FROM rel_menu_auth rma + WHERE rma.menu_objid = MENU.OBJID + AND rma.auth_objid = ANY($${paramIndex}) + AND rma.read_yn = 'Y' + ) + `; + queryParams.push(roleObjids); + paramIndex++; + logger.info( + `✅ 일반 사용자: 권한 있는 메뉴만 (${roleObjids.length}개 그룹)` + ); + } else { + // 권한 그룹이 없는 일반 사용자: 메뉴 없음 + logger.warn( + `⚠️ 사용자 ${userId}는 권한 그룹이 없어 메뉴가 표시되지 않습니다.` + ); + return []; + } + } + } else if (menuType !== undefined && userType === "SUPER_ADMIN") { + // 좌측 사이드바 + SUPER_ADMIN: 권한 그룹 체크 없이 모든 공통 메뉴 표시 + logger.info(`✅ 최고 관리자는 권한 그룹 체크 없이 모든 공통 메뉴 표시`); + } + + // 2. 회사별 필터링 조건 생성 + let companyFilter = ""; + + // SUPER_ADMIN과 COMPANY_ADMIN 구분 + if (userType === "SUPER_ADMIN" && userCompanyCode === "*") { + // SUPER_ADMIN + if (menuType === undefined) { + // 메뉴 관리 화면: 모든 메뉴 + logger.info("✅ 메뉴 관리 화면 (SUPER_ADMIN): 모든 메뉴 표시"); + companyFilter = ""; + } else { + // 좌측 사이드바: 공통 메뉴만 (company_code = '*') + logger.info("✅ 좌측 사이드바 (SUPER_ADMIN): 공통 메뉴만 표시"); + companyFilter = `AND MENU.COMPANY_CODE = '*'`; + } + } else if (menuType === undefined) { + // 메뉴 관리 화면: 자기 회사 + 공통 메뉴 + logger.info( + `✅ 메뉴 관리 화면: 회사 ${userCompanyCode} + 공통 메뉴 표시` + ); + companyFilter = `AND (MENU.COMPANY_CODE = $${paramIndex} OR MENU.COMPANY_CODE = '*')`; + queryParams.push(userCompanyCode); + paramIndex++; + } + // menuType이 정의된 경우 (좌측 사이드바)는 authFilter에서 이미 회사 필터링 포함 + // 기존 Java의 selectAdminMenuList 쿼리를 Raw Query로 포팅 // WITH RECURSIVE 쿼리 구현 const menuList = await query( @@ -96,6 +206,9 @@ export class AdminService { ) FROM MENU_INFO MENU WHERE ${menuTypeCondition} + AND STATUS = 'active' + ${companyFilter} + ${authFilter} AND NOT EXISTS ( SELECT 1 FROM MENU_INFO parent_menu WHERE parent_menu.OBJID = MENU.PARENT_OBJ_ID @@ -160,6 +273,20 @@ export class AdminService { FROM MENU_INFO MENU_SUB JOIN V_MENU ON MENU_SUB.PARENT_OBJ_ID = V_MENU.OBJID WHERE MENU_SUB.OBJID != ANY(V_MENU.PATH) + AND MENU_SUB.STATUS = 'active' + AND ( + MENU_SUB.COMPANY_CODE = $2 + OR ( + MENU_SUB.COMPANY_CODE = '*' + AND EXISTS ( + SELECT 1 + FROM rel_menu_auth rma + WHERE rma.menu_objid = MENU_SUB.OBJID + AND rma.auth_objid = ANY($3) + AND rma.read_yn = 'Y' + ) + ) + ) ) SELECT LEVEL AS LEV, @@ -190,14 +317,18 @@ export class AdminService { WHERE 1 = 1 ORDER BY PATH, SEQ `, - [userLang] + queryParams ); logger.info( - `메뉴 목록 조회 결과: ${menuList.length}개 (menuType: ${menuType || "전체"})` + `관리자 메뉴 목록 조회 결과: ${menuList.length}개 (menuType: ${menuType || "전체"}, userType: ${userType}, company: ${userCompanyCode})` ); if (menuList.length > 0) { - logger.info("첫 번째 메뉴:", menuList[0]); + logger.info("첫 번째 메뉴:", { + objid: menuList[0].objid, + name: menuList[0].menu_name_kor, + companyCode: menuList[0].company_code, + }); } return menuList; @@ -208,15 +339,81 @@ export class AdminService { } /** - * 사용자 메뉴 목록 조회 + * 사용자 메뉴 목록 조회 (권한 그룹 기반 필터링) */ static async getUserMenuList(paramMap: any): Promise { try { logger.info("AdminService.getUserMenuList 시작 - 파라미터:", paramMap); - const { userLang = "ko" } = paramMap; + const { userId, userCompanyCode, userType, userLang = "ko" } = paramMap; - // 기존 Java의 selectUserMenuList 쿼리를 Raw Query로 포팅 + // 1. 사용자가 속한 권한 그룹 조회 + const userRoleGroups = await query( + ` + SELECT DISTINCT am.objid AS role_objid, am.auth_name + FROM authority_master am + JOIN authority_sub_user asu ON am.objid = asu.master_objid + WHERE asu.user_id = $1 + AND am.status = 'active' + `, + [userId] + ); + + logger.info( + `✅ 사용자 ${userId}가 속한 권한 그룹: ${userRoleGroups.length}개`, + { + roleGroups: userRoleGroups.map((rg: any) => rg.auth_name), + } + ); + + // 2. 권한 그룹 기반 메뉴 필터 조건 생성 + let authFilter = ""; + let queryParams: any[] = [userLang]; + let paramIndex = 2; + + if (userRoleGroups.length > 0) { + // 권한 그룹이 있는 경우: read_yn = 'Y'인 메뉴만 필터링 + const roleObjids = userRoleGroups.map((rg: any) => rg.role_objid); + authFilter = ` + AND EXISTS ( + SELECT 1 + FROM rel_menu_auth rma + WHERE rma.menu_objid = MENU.OBJID + AND rma.auth_objid = ANY($${paramIndex}) + AND rma.read_yn = 'Y' + ) + `; + queryParams.push(roleObjids); + paramIndex++; + logger.info( + `✅ 권한 그룹 기반 메뉴 필터링 적용: ${roleObjids.length}개 그룹` + ); + } else { + // 권한 그룹이 없는 경우: 메뉴 없음 + logger.warn( + `⚠️ 사용자 ${userId}는 권한 그룹이 없어 메뉴가 표시되지 않습니다.` + ); + return []; + } + + // 3. 회사별 필터링 조건 생성 + let companyFilter = ""; + + if (userType === "SUPER_ADMIN" && userCompanyCode === "*") { + // SUPER_ADMIN: 공통 메뉴만 (company_code = '*') + logger.info("✅ 좌측 사이드바 (SUPER_ADMIN): 공통 메뉴만 표시"); + companyFilter = `AND MENU.COMPANY_CODE = '*'`; + } else { + // COMPANY_ADMIN/USER: 자기 회사 메뉴만 + logger.info( + `✅ 좌측 사이드바 (COMPANY_ADMIN): 회사 ${userCompanyCode} 메뉴만 표시` + ); + companyFilter = `AND MENU.COMPANY_CODE = $${paramIndex}`; + queryParams.push(userCompanyCode); + paramIndex++; + } + + // 기존 Java의 selectUserMenuList 쿼리를 Raw Query로 포팅 + 회사별 필터링 const menuList = await query( ` WITH RECURSIVE v_menu( @@ -257,6 +454,9 @@ export class AdminService { FROM MENU_INFO MENU WHERE PARENT_OBJ_ID = 0 AND MENU_TYPE = 1 + AND STATUS = 'active' + ${companyFilter} + ${authFilter} UNION ALL @@ -279,7 +479,8 @@ export class AdminService { MENU_SUB.OBJID = ANY(PATH) FROM MENU_INFO MENU_SUB JOIN V_MENU ON MENU_SUB.PARENT_OBJ_ID = V_MENU.OBJID - WHERE 1 = 1 + WHERE MENU_SUB.STATUS = 'active' + ${authFilter.replace(/MENU\.OBJID/g, "MENU_SUB.OBJID")} ) SELECT LEVEL AS LEV, @@ -320,12 +521,18 @@ export class AdminService { WHERE 1 = 1 ORDER BY PATH, SEQ `, - [userLang] + queryParams ); - logger.info(`사용자 메뉴 목록 조회 결과: ${menuList.length}개`); + logger.info( + `사용자 메뉴 목록 조회 결과: ${menuList.length}개 (userType: ${userType}, company: ${userCompanyCode})` + ); if (menuList.length > 0) { - logger.info("첫 번째 메뉴:", menuList[0]); + logger.info("첫 번째 메뉴:", { + objid: menuList[0].objid, + name: menuList[0].menu_name_kor, + companyCode: menuList[0].company_code, + }); } return menuList; diff --git a/backend-node/src/services/authService.ts b/backend-node/src/services/authService.ts index fee93775..7c4f4c8d 100644 --- a/backend-node/src/services/authService.ts +++ b/backend-node/src/services/authService.ts @@ -185,6 +185,9 @@ export class AuthService { //}); // PersonBean 형태로 변환 (null 값을 undefined로 변환) + const companyCode = userInfo.company_code || "ILSHIN"; + const userType = userInfo.user_type || "USER"; + const personBean: PersonBean = { userId: userInfo.user_id, userName: userInfo.user_name || "", @@ -197,15 +200,21 @@ export class AuthService { email: userInfo.email || undefined, tel: userInfo.tel || undefined, cellPhone: userInfo.cell_phone || undefined, - userType: userInfo.user_type || undefined, + userType: userType, userTypeName: userInfo.user_type_name || undefined, partnerObjid: userInfo.partner_objid || undefined, authName: authNames || undefined, - companyCode: userInfo.company_code || "ILSHIN", + companyCode: companyCode, photo: userInfo.photo ? `data:image/jpeg;base64,${Buffer.from(userInfo.photo).toString("base64")}` : undefined, locale: userInfo.locale || "KR", + // 권한 레벨 정보 추가 (3단계 체계) + isSuperAdmin: companyCode === "*" && userType === "SUPER_ADMIN", + isCompanyAdmin: userType === "COMPANY_ADMIN" && companyCode !== "*", + isAdmin: + (companyCode === "*" && userType === "SUPER_ADMIN") || + userType === "COMPANY_ADMIN", }; //console.log("📦 AuthService - 최종 PersonBean:", { diff --git a/backend-node/src/services/flowConnectionService.ts b/backend-node/src/services/flowConnectionService.ts index 5b1f3d40..f9918cd4 100644 --- a/backend-node/src/services/flowConnectionService.ts +++ b/backend-node/src/services/flowConnectionService.ts @@ -1,3 +1,4 @@ +// @ts-nocheck /** * 플로우 연결 서비스 */ diff --git a/backend-node/src/services/flowDefinitionService.ts b/backend-node/src/services/flowDefinitionService.ts index f08f934d..759178c1 100644 --- a/backend-node/src/services/flowDefinitionService.ts +++ b/backend-node/src/services/flowDefinitionService.ts @@ -1,3 +1,4 @@ +// @ts-nocheck /** * 플로우 정의 서비스 */ @@ -15,20 +16,24 @@ export class FlowDefinitionService { */ async create( request: CreateFlowDefinitionRequest, - userId: string + userId: string, + userCompanyCode?: string ): Promise { + const companyCode = request.companyCode || userCompanyCode || "*"; + console.log("🔥 flowDefinitionService.create called with:", { name: request.name, description: request.description, tableName: request.tableName, dbSourceType: request.dbSourceType, dbConnectionId: request.dbConnectionId, + companyCode, userId, }); const query = ` - INSERT INTO flow_definition (name, description, table_name, db_source_type, db_connection_id, created_by) - VALUES ($1, $2, $3, $4, $5, $6) + INSERT INTO flow_definition (name, description, table_name, db_source_type, db_connection_id, company_code, created_by) + VALUES ($1, $2, $3, $4, $5, $6, $7) RETURNING * `; @@ -38,6 +43,7 @@ export class FlowDefinitionService { request.tableName || null, request.dbSourceType || "internal", request.dbConnectionId || null, + companyCode, userId, ]; @@ -53,12 +59,29 @@ export class FlowDefinitionService { */ async findAll( tableName?: string, - isActive?: boolean + isActive?: boolean, + companyCode?: string ): Promise { + console.log("🔍 flowDefinitionService.findAll called with:", { + tableName, + isActive, + companyCode, + }); + let query = "SELECT * FROM flow_definition WHERE 1=1"; const params: any[] = []; let paramIndex = 1; + // 회사 코드 필터링 + if (companyCode && companyCode !== "*") { + query += ` AND company_code = $${paramIndex}`; + params.push(companyCode); + paramIndex++; + console.log(`✅ Company filter applied: company_code = ${companyCode}`); + } else { + console.log(`⚠️ No company filter (companyCode: ${companyCode})`); + } + if (tableName) { query += ` AND table_name = $${paramIndex}`; params.push(tableName); @@ -73,7 +96,11 @@ export class FlowDefinitionService { query += " ORDER BY created_at DESC"; + console.log("📋 Final query:", query); + console.log("📋 Query params:", params); + const result = await db.query(query, params); + console.log(`📊 Found ${result.length} flow definitions`); return result.map(this.mapToFlowDefinition); } @@ -179,6 +206,7 @@ export class FlowDefinitionService { tableName: row.table_name, dbSourceType: row.db_source_type || "internal", dbConnectionId: row.db_connection_id, + companyCode: row.company_code || "*", isActive: row.is_active, createdBy: row.created_by, createdAt: row.created_at, diff --git a/backend-node/src/services/flowExecutionService.ts b/backend-node/src/services/flowExecutionService.ts index 9d9eb9c4..966842b8 100644 --- a/backend-node/src/services/flowExecutionService.ts +++ b/backend-node/src/services/flowExecutionService.ts @@ -1,3 +1,4 @@ +// @ts-nocheck /** * 플로우 실행 서비스 * 단계별 데이터 카운트 및 리스트 조회 diff --git a/backend-node/src/services/flowStepService.ts b/backend-node/src/services/flowStepService.ts index e8cf1fb9..67d342ac 100644 --- a/backend-node/src/services/flowStepService.ts +++ b/backend-node/src/services/flowStepService.ts @@ -1,3 +1,4 @@ +// @ts-nocheck /** * 플로우 단계 서비스 */ @@ -26,9 +27,9 @@ export class FlowStepService { flow_definition_id, step_name, step_order, table_name, condition_json, color, position_x, position_y, move_type, status_column, status_value, target_table, field_mappings, required_fields, - integration_type, integration_config + integration_type, integration_config, display_config ) - VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17) RETURNING * `; @@ -51,6 +52,7 @@ export class FlowStepService { request.integrationConfig ? JSON.stringify(request.integrationConfig) : null, + request.displayConfig ? JSON.stringify(request.displayConfig) : null, ]); return this.mapToFlowStep(result[0]); @@ -209,6 +211,15 @@ export class FlowStepService { paramIndex++; } + // 표시 설정 (displayConfig) + if (request.displayConfig !== undefined) { + fields.push(`display_config = $${paramIndex}`); + params.push( + request.displayConfig ? JSON.stringify(request.displayConfig) : null + ); + paramIndex++; + } + if (fields.length === 0) { return this.findById(id); } @@ -262,6 +273,17 @@ export class FlowStepService { * DB 행을 FlowStep 객체로 변환 */ private mapToFlowStep(row: any): FlowStep { + // JSONB 필드는 pg 라이브러리가 자동으로 파싱해줌 + const displayConfig = row.display_config; + + // 디버깅 로그 (개발 환경에서만) + if (displayConfig && process.env.NODE_ENV === "development") { + console.log(`🔍 [FlowStep ${row.id}] displayConfig:`, { + type: typeof displayConfig, + value: displayConfig, + }); + } + return { id: row.id, flowDefinitionId: row.flow_definition_id, @@ -282,6 +304,8 @@ export class FlowStepService { // 외부 연동 필드 integrationType: row.integration_type || "internal", integrationConfig: row.integration_config || undefined, + // 표시 설정 + displayConfig: displayConfig || undefined, createdAt: row.created_at, updatedAt: row.updated_at, }; diff --git a/backend-node/src/services/roleService.ts b/backend-node/src/services/roleService.ts new file mode 100644 index 00000000..403a1e46 --- /dev/null +++ b/backend-node/src/services/roleService.ts @@ -0,0 +1,610 @@ +import { query } from "../database/db"; +import { logger } from "../utils/logger"; + +/** + * 권한 그룹 인터페이스 + */ +export interface RoleGroup { + objid: number; + authName: string; + authCode: string; + companyCode: string; + status: string; + writer: string; + regdate: Date; + memberCount?: number; + menuCount?: number; + memberNames?: string; +} + +/** + * 권한 그룹 멤버 인터페이스 + */ +export interface RoleMember { + objid: number; + masterObjid: number; + userId: string; + userName?: string; + deptName?: string; + positionName?: string; + writer: string; + regdate: Date; +} + +/** + * 메뉴 권한 인터페이스 + */ +export interface MenuPermission { + objid: number; + menuObjid: number; + authObjid: number; + menuName?: string; + createYn: string; + readYn: string; + updateYn: string; + deleteYn: string; + writer: string; + regdate: Date; +} + +/** + * 권한 그룹 서비스 + */ +export class RoleService { + /** + * 회사별 권한 그룹 목록 조회 + * @param companyCode - 회사 코드 (undefined 시 전체 조회) + * @param search - 검색어 + */ + static async getRoleGroups( + companyCode?: string, + search?: string + ): Promise { + try { + let sql = ` + SELECT + objid, + auth_name AS "authName", + auth_code AS "authCode", + company_code AS "companyCode", + status, + writer, + regdate, + (SELECT COUNT(*) FROM authority_sub_user asu WHERE asu.master_objid = am.objid) AS "memberCount", + (SELECT COUNT(*) FROM rel_menu_auth rma WHERE rma.auth_objid = am.objid) AS "menuCount", + (SELECT STRING_AGG(ui.user_name, ', ' ORDER BY ui.user_name) + FROM authority_sub_user asu + JOIN user_info ui ON asu.user_id = ui.user_id + WHERE asu.master_objid = am.objid) AS "memberNames" + FROM authority_master am + WHERE 1=1 + `; + + const params: any[] = []; + let paramIndex = 1; + + // 회사 코드 필터 (companyCode가 undefined면 전체 조회) + if (companyCode) { + sql += ` AND company_code = $${paramIndex}`; + params.push(companyCode); + paramIndex++; + } + + // 검색어 필터 + if (search && search.trim()) { + sql += ` AND (auth_name ILIKE $${paramIndex} OR auth_code ILIKE $${paramIndex})`; + params.push(`%${search.trim()}%`); + paramIndex++; + } + + sql += ` ORDER BY regdate DESC`; + + logger.info("권한 그룹 조회 SQL", { sql, params }); + const result = await query(sql, params); + logger.info("권한 그룹 조회 결과", { count: result.length }); + return result; + } catch (error) { + logger.error("권한 그룹 목록 조회 실패", { error, companyCode, search }); + throw error; + } + } + + /** + * 권한 그룹 상세 조회 + */ + static async getRoleGroupById(objid: number): Promise { + try { + const sql = ` + SELECT + objid, + auth_name AS "authName", + auth_code AS "authCode", + company_code AS "companyCode", + status, + writer, + regdate + FROM authority_master + WHERE objid = $1 + `; + + const result = await query(sql, [objid]); + return result.length > 0 ? result[0] : null; + } catch (error) { + logger.error("권한 그룹 상세 조회 실패", { error, objid }); + throw error; + } + } + + /** + * 권한 그룹 생성 + */ + static async createRoleGroup(data: { + authName: string; + authCode: string; + companyCode: string; + writer: string; + }): Promise { + try { + const sql = ` + INSERT INTO authority_master (objid, auth_name, auth_code, company_code, status, writer, regdate) + VALUES (nextval('seq_authority_master'), $1, $2, $3, 'active', $4, NOW()) + RETURNING objid, auth_name AS "authName", auth_code AS "authCode", + company_code AS "companyCode", status, writer, regdate + `; + + const result = await query(sql, [ + data.authName, + data.authCode, + data.companyCode, + data.writer, + ]); + + logger.info("권한 그룹 생성 성공", { + objid: result[0].objid, + authName: data.authName, + }); + return result[0]; + } catch (error) { + logger.error("권한 그룹 생성 실패", { error, data }); + throw error; + } + } + + /** + * 권한 그룹 수정 + */ + static async updateRoleGroup( + objid: number, + data: { + authName?: string; + authCode?: string; + status?: string; + } + ): Promise { + try { + const updates: string[] = []; + const params: any[] = []; + let paramIndex = 1; + + if (data.authName !== undefined) { + updates.push(`auth_name = $${paramIndex}`); + params.push(data.authName); + paramIndex++; + } + + if (data.authCode !== undefined) { + updates.push(`auth_code = $${paramIndex}`); + params.push(data.authCode); + paramIndex++; + } + + if (data.status !== undefined) { + updates.push(`status = $${paramIndex}`); + params.push(data.status); + paramIndex++; + } + + if (updates.length === 0) { + throw new Error("수정할 데이터가 없습니다"); + } + + params.push(objid); + + const sql = ` + UPDATE authority_master + SET ${updates.join(", ")} + WHERE objid = $${paramIndex} + RETURNING objid, auth_name AS "authName", auth_code AS "authCode", + company_code AS "companyCode", status, writer, regdate + `; + + const result = await query(sql, params); + + if (result.length === 0) { + throw new Error("권한 그룹을 찾을 수 없습니다"); + } + + logger.info("권한 그룹 수정 성공", { objid, updates }); + return result[0]; + } catch (error) { + logger.error("권한 그룹 수정 실패", { error, objid, data }); + throw error; + } + } + + /** + * 권한 그룹 삭제 + */ + static async deleteRoleGroup(objid: number): Promise { + try { + // CASCADE로 연결된 데이터도 함께 삭제됨 (authority_sub_user, rel_menu_auth) + await query("DELETE FROM authority_master WHERE objid = $1", [objid]); + logger.info("권한 그룹 삭제 성공", { objid }); + } catch (error) { + logger.error("권한 그룹 삭제 실패", { error, objid }); + throw error; + } + } + + /** + * 권한 그룹 멤버 목록 조회 + */ + static async getRoleMembers(masterObjid: number): Promise { + try { + const sql = ` + SELECT + asu.objid, + asu.master_objid AS "masterObjid", + asu.user_id AS "userId", + ui.user_name AS "userName", + ui.dept_name AS "deptName", + ui.position_name AS "positionName", + asu.writer, + asu.regdate + FROM authority_sub_user asu + JOIN user_info ui ON asu.user_id = ui.user_id + WHERE asu.master_objid = $1 + ORDER BY ui.user_name + `; + + const result = await query(sql, [masterObjid]); + return result; + } catch (error) { + logger.error("권한 그룹 멤버 조회 실패", { error, masterObjid }); + throw error; + } + } + + /** + * 권한 그룹 멤버 추가 (여러 명) + */ + static async addRoleMembers( + masterObjid: number, + userIds: string[], + writer: string + ): Promise { + try { + // 이미 존재하는 멤버 제외 + const existingSql = ` + SELECT user_id + FROM authority_sub_user + WHERE master_objid = $1 AND user_id = ANY($2) + `; + const existing = await query<{ user_id: string }>(existingSql, [ + masterObjid, + userIds, + ]); + const existingIds = new Set( + existing.map((row: { user_id: string }) => row.user_id) + ); + + const newUserIds = userIds.filter((userId) => !existingIds.has(userId)); + + if (newUserIds.length === 0) { + logger.info("추가할 신규 멤버가 없습니다", { masterObjid, userIds }); + return; + } + + // 배치 삽입 + const values = newUserIds + .map( + (_, index) => + `(nextval('seq_authority_sub_user'), $1, $${index + 2}, $${newUserIds.length + 2}, NOW())` + ) + .join(", "); + + const sql = ` + INSERT INTO authority_sub_user (objid, master_objid, user_id, writer, regdate) + VALUES ${values} + `; + + await query(sql, [masterObjid, ...newUserIds, writer]); + + // 히스토리 기록 + for (const userId of newUserIds) { + await this.insertAuthorityHistory(masterObjid, userId, "ADD", writer); + } + + logger.info("권한 그룹 멤버 추가 성공", { + masterObjid, + count: newUserIds.length, + }); + } catch (error) { + logger.error("권한 그룹 멤버 추가 실패", { error, masterObjid, userIds }); + throw error; + } + } + + /** + * 권한 그룹 멤버 제거 (여러 명) + */ + static async removeRoleMembers( + masterObjid: number, + userIds: string[], + writer: string + ): Promise { + try { + await query( + "DELETE FROM authority_sub_user WHERE master_objid = $1 AND user_id = ANY($2)", + [masterObjid, userIds] + ); + + // 히스토리 기록 + for (const userId of userIds) { + await this.insertAuthorityHistory( + masterObjid, + userId, + "REMOVE", + writer + ); + } + + logger.info("권한 그룹 멤버 제거 성공", { + masterObjid, + count: userIds.length, + }); + } catch (error) { + logger.error("권한 그룹 멤버 제거 실패", { error, masterObjid, userIds }); + throw error; + } + } + + /** + * 권한 히스토리 기록 + */ + private static async insertAuthorityHistory( + masterObjid: number, + userId: string, + historyType: "ADD" | "REMOVE", + writer: string + ): Promise { + try { + const sql = ` + INSERT INTO authority_master_history + (objid, parent_objid, parent_name, parent_code, user_id, active, history_type, writer, reg_date) + SELECT + nextval('seq_authority_master'), + $1, + am.auth_name, + am.auth_code, + $2, + am.status, + $3, + $4, + NOW() + FROM authority_master am + WHERE am.objid = $1 + `; + + await query(sql, [masterObjid, userId, historyType, writer]); + } catch (error) { + logger.error("권한 히스토리 기록 실패", { + error, + masterObjid, + userId, + historyType, + }); + // 히스토리 기록 실패는 메인 작업을 중단하지 않음 + } + } + + /** + * 메뉴 권한 목록 조회 + */ + static async getMenuPermissions( + authObjid: number + ): Promise { + try { + const sql = ` + SELECT + rma.objid, + rma.menu_objid AS "menuObjid", + rma.auth_objid AS "authObjid", + mi.menu_name_kor AS "menuName", + mi.menu_code AS "menuCode", + mi.menu_url AS "menuUrl", + rma.create_yn AS "createYn", + rma.read_yn AS "readYn", + rma.update_yn AS "updateYn", + rma.delete_yn AS "deleteYn", + rma.execute_yn AS "executeYn", + rma.export_yn AS "exportYn", + rma.writer, + rma.regdate + FROM rel_menu_auth rma + LEFT JOIN menu_info mi ON rma.menu_objid = mi.objid + WHERE rma.auth_objid = $1 + ORDER BY mi.menu_name_kor + `; + + const result = await query(sql, [authObjid]); + return result; + } catch (error) { + logger.error("메뉴 권한 조회 실패", { error, authObjid }); + throw error; + } + } + + /** + * 메뉴 권한 설정 (여러 메뉴) + */ + static async setMenuPermissions( + authObjid: number, + permissions: Array<{ + menuObjid: number; + createYn: string; + readYn: string; + updateYn: string; + deleteYn: string; + }>, + writer: string + ): Promise { + try { + // 기존 권한 삭제 + await query("DELETE FROM rel_menu_auth WHERE auth_objid = $1", [ + authObjid, + ]); + + // 새로운 권한 삽입 + if (permissions.length > 0) { + const values = permissions + .map( + (_, index) => + `(nextval('seq_rel_menu_auth'), $${index * 5 + 2}, $1, $${index * 5 + 3}, $${index * 5 + 4}, $${index * 5 + 5}, $${index * 5 + 6}, $${permissions.length * 5 + 2}, NOW())` + ) + .join(", "); + + const params = permissions.flatMap((p) => [ + p.menuObjid, + p.createYn, + p.readYn, + p.updateYn, + p.deleteYn, + ]); + + const sql = ` + INSERT INTO rel_menu_auth (objid, menu_objid, auth_objid, create_yn, read_yn, update_yn, delete_yn, writer, regdate) + VALUES ${values} + `; + + await query(sql, [authObjid, ...params, writer]); + } + + logger.info("메뉴 권한 설정 성공", { + authObjid, + count: permissions.length, + }); + } catch (error) { + logger.error("메뉴 권한 설정 실패", { error, authObjid, permissions }); + throw error; + } + } + + /** + * 사용자가 속한 권한 그룹 목록 조회 + */ + static async getUserRoleGroups( + userId: string, + companyCode: string + ): Promise { + try { + const sql = ` + SELECT + am.objid, + am.auth_name AS "authName", + am.auth_code AS "authCode", + am.company_code AS "companyCode", + am.status, + am.writer, + am.regdate + FROM authority_master am + JOIN authority_sub_user asu ON am.objid = asu.master_objid + WHERE asu.user_id = $1 + AND am.company_code = $2 + AND am.status = 'active' + ORDER BY am.auth_name + `; + + const result = await query(sql, [userId, companyCode]); + return result; + } catch (error) { + logger.error("사용자 권한 그룹 조회 실패", { + error, + userId, + companyCode, + }); + throw error; + } + } + + /** + * 전체 메뉴 목록 조회 (권한 설정용) + */ + /** + * 전체 메뉴 목록 조회 (권한 설정용) + */ + static async getAllMenus(companyCode?: string): Promise { + try { + logger.info("🔍 전체 메뉴 목록 조회 시작", { companyCode }); + + let whereConditions: string[] = ["status = 'active'"]; + const params: any[] = []; + let paramIndex = 1; + + // 회사 코드 필터 (선택적) + // 공통 메뉴(*)와 특정 회사 메뉴를 모두 조회 + // 회사 코드 필터 (선택적) + if (companyCode) { + // 특정 회사 메뉴만 조회 (공통 메뉴 제외) + whereConditions.push(`company_code = $${paramIndex}`); + params.push(companyCode); + paramIndex++; + logger.info("📋 회사 코드 필터 적용 (공통 메뉴 제외)", { companyCode }); + } else { + logger.info("📋 회사 코드 필터 없음 (전체 조회)"); + } + + const whereClause = whereConditions.join(" AND "); + + const sql = ` + SELECT + objid, + menu_name_kor AS "menuName", + menu_name_eng AS "menuNameEng", + menu_code AS "menuCode", + menu_url AS "menuUrl", + menu_type AS "menuType", + parent_obj_id AS "parentObjid", + seq AS "sortOrder", + company_code AS "companyCode" + FROM menu_info + WHERE ${whereClause} + ORDER BY seq, menu_name_kor + `; + + logger.info("🔍 SQL 쿼리 실행", { + whereClause, + params, + sql: sql.substring(0, 200) + "...", + }); + + const result = await query(sql, params); + + logger.info("✅ 메뉴 목록 조회 성공", { + count: result.length, + companyCode, + menus: result.map((m) => ({ + objid: m.objid, + name: m.menuName, + code: m.menuCode, + companyCode: m.companyCode, + })), + }); + + return result; + } catch (error) { + logger.error("❌ 메뉴 목록 조회 실패", { error, companyCode }); + throw error; + } + } +} diff --git a/backend-node/src/types/auth.ts b/backend-node/src/types/auth.ts index c1384b51..35a2c0f5 100644 --- a/backend-node/src/types/auth.ts +++ b/backend-node/src/types/auth.ts @@ -7,6 +7,15 @@ export interface LoginRequest { password: string; } +// 사용자 권한 레벨 (3단계 체계) +export enum UserRole { + SUPER_ADMIN = "SUPER_ADMIN", // 최고 관리자 (전체 시스템) + COMPANY_ADMIN = "COMPANY_ADMIN", // 회사 관리자 (자기 회사만) + USER = "USER", // 일반 사용자 + GUEST = "GUEST", // 게스트 + PARTNER = "PARTNER", // 협력업체 +} + // 기존 ApiLoginController.UserInfo 클래스 포팅 export interface UserInfo { userId: string; @@ -18,7 +27,9 @@ export interface UserInfo { email?: string; photo?: string; locale?: string; - isAdmin?: boolean; + isAdmin?: boolean; // 하위 호환성 유지 + isSuperAdmin?: boolean; // 슈퍼관리자 여부 (company_code === '*' && userType === 'SUPER_ADMIN') + isCompanyAdmin?: boolean; // 회사 관리자 여부 (userType === 'COMPANY_ADMIN') } // 기존 ApiLoginController.ApiResponse 클래스 포팅 @@ -52,6 +63,10 @@ export interface PersonBean { companyCode?: string; photo?: string; locale?: string; + // 권한 레벨 정보 (3단계 체계) + isSuperAdmin?: boolean; // 최고 관리자 (company_code === '*' && userType === 'SUPER_ADMIN') + isCompanyAdmin?: boolean; // 회사 관리자 (userType === 'COMPANY_ADMIN') + isAdmin?: boolean; // 관리자 (슈퍼관리자 + 회사관리자) } // 로그인 결과 타입 (기존 LoginService.loginPwdCheck 반환값) diff --git a/backend-node/src/types/flow.ts b/backend-node/src/types/flow.ts index 4368ae1a..c127eccc 100644 --- a/backend-node/src/types/flow.ts +++ b/backend-node/src/types/flow.ts @@ -10,6 +10,7 @@ export interface FlowDefinition { tableName: string; dbSourceType?: "internal" | "external"; // 데이터베이스 소스 타입 dbConnectionId?: number; // 외부 DB 연결 ID (external인 경우) + companyCode: string; // 회사 코드 (* = 공통) isActive: boolean; createdBy?: string; createdAt: Date; @@ -23,6 +24,7 @@ export interface CreateFlowDefinitionRequest { tableName: string; dbSourceType?: "internal" | "external"; // 데이터베이스 소스 타입 dbConnectionId?: number; // 외부 DB 연결 ID + companyCode?: string; // 회사 코드 (미제공 시 사용자의 company_code 사용) } // 플로우 정의 수정 요청 @@ -66,6 +68,14 @@ export interface FlowConditionGroup { conditions: FlowCondition[]; } +// 플로우 단계 표시 설정 +export interface FlowStepDisplayConfig { + visibleColumns?: string[]; // 표시할 컬럼 목록 + columnOrder?: string[]; // 컬럼 순서 (선택사항) + columnLabels?: Record; // 컬럼별 커스텀 라벨 (선택사항) + columnWidths?: Record; // 컬럼별 너비 설정 (px, 선택사항) +} + // 플로우 단계 export interface FlowStep { id: number; @@ -87,6 +97,8 @@ export interface FlowStep { // 외부 연동 필드 integrationType?: FlowIntegrationType; // 연동 타입 (기본값: internal) integrationConfig?: FlowIntegrationConfig; // 연동 설정 (JSONB) + // 🆕 표시 설정 (플로우 위젯에서 사용) + displayConfig?: FlowStepDisplayConfig; // 단계별 컬럼 표시 설정 createdAt: Date; updatedAt: Date; } @@ -111,6 +123,8 @@ export interface CreateFlowStepRequest { // 외부 연동 필드 integrationType?: FlowIntegrationType; integrationConfig?: FlowIntegrationConfig; + // 🆕 표시 설정 + displayConfig?: FlowStepDisplayConfig; } // 플로우 단계 수정 요청 @@ -132,6 +146,8 @@ export interface UpdateFlowStepRequest { // 외부 연동 필드 integrationType?: FlowIntegrationType; integrationConfig?: FlowIntegrationConfig; + // 🆕 표시 설정 + displayConfig?: FlowStepDisplayConfig; } // 플로우 단계 연결 diff --git a/backend-node/src/types/oracledb.d.ts b/backend-node/src/types/oracledb.d.ts deleted file mode 100644 index 818b6a6f..00000000 --- a/backend-node/src/types/oracledb.d.ts +++ /dev/null @@ -1,18 +0,0 @@ -declare module 'oracledb' { - export interface Connection { - execute(sql: string, bindParams?: any, options?: any): Promise; - close(): Promise; - } - - export interface ConnectionConfig { - user: string; - password: string; - connectString: string; - } - - export function getConnection(config: ConnectionConfig): Promise; - export function createPool(config: any): Promise; - export function getPool(): any; - export function close(): Promise; -} - diff --git a/backend-node/src/utils/permissionUtils.ts b/backend-node/src/utils/permissionUtils.ts new file mode 100644 index 00000000..bbc85398 --- /dev/null +++ b/backend-node/src/utils/permissionUtils.ts @@ -0,0 +1,230 @@ +/** + * 권한 체크 유틸리티 + * 3단계 권한 체계: SUPER_ADMIN / COMPANY_ADMIN / USER + */ + +import { PersonBean } from "../types/auth"; + +/** + * 권한 레벨 Enum + */ +export enum PermissionLevel { + SUPER_ADMIN = "SUPER_ADMIN", // 최고 관리자 (전체 시스템) + COMPANY_ADMIN = "COMPANY_ADMIN", // 회사 관리자 (자기 회사만) + USER = "USER", // 일반 사용자 +} + +/** + * 사용자가 슈퍼관리자인지 확인 + * @param user 사용자 정보 + * @returns 슈퍼관리자 여부 + */ +export function isSuperAdmin(user?: PersonBean | null): boolean { + if (!user) return false; + return user.companyCode === "*" && user.userType === "SUPER_ADMIN"; +} + +/** + * 사용자가 회사 관리자인지 확인 (슈퍼관리자 제외) + * @param user 사용자 정보 + * @returns 회사 관리자 여부 + */ +export function isCompanyAdmin(user?: PersonBean | null): boolean { + if (!user) return false; + return user.userType === "COMPANY_ADMIN" && user.companyCode !== "*"; +} + +/** + * 사용자가 관리자인지 확인 (슈퍼관리자 + 회사관리자) + * @param user 사용자 정보 + * @returns 관리자 여부 + */ +export function isAdmin(user?: PersonBean | null): boolean { + return isSuperAdmin(user) || isCompanyAdmin(user); +} + +/** + * 사용자가 일반 사용자인지 확인 + * @param user 사용자 정보 + * @returns 일반 사용자 여부 + */ +export function isRegularUser(user?: PersonBean | null): boolean { + if (!user) return false; + return ( + user.userType === "USER" || + user.userType === "GUEST" || + user.userType === "PARTNER" + ); +} + +/** + * 사용자의 권한 레벨 반환 + * @param user 사용자 정보 + * @returns 권한 레벨 + */ +export function getUserPermissionLevel( + user?: PersonBean | null +): PermissionLevel | null { + if (!user) return null; + + if (isSuperAdmin(user)) { + return PermissionLevel.SUPER_ADMIN; + } + + if (isCompanyAdmin(user)) { + return PermissionLevel.COMPANY_ADMIN; + } + + return PermissionLevel.USER; +} + +/** + * DDL 실행 권한 확인 (슈퍼관리자만) + * @param user 사용자 정보 + * @returns DDL 실행 가능 여부 + */ +export function canExecuteDDL(user?: PersonBean | null): boolean { + return isSuperAdmin(user); +} + +/** + * 회사 데이터 접근 권한 확인 + * @param user 사용자 정보 + * @param targetCompanyCode 접근하려는 회사 코드 + * @returns 접근 가능 여부 + */ +export function canAccessCompanyData( + user?: PersonBean | null, + targetCompanyCode?: string +): boolean { + if (!user) return false; + + // 슈퍼관리자는 모든 회사 데이터 접근 가능 + if (isSuperAdmin(user)) { + return true; + } + + // 자기 회사 데이터만 접근 가능 + return user.companyCode === targetCompanyCode; +} + +/** + * 사용자 관리 권한 확인 (관리자만) + * @param user 사용자 정보 + * @param targetCompanyCode 관리하려는 회사 코드 + * @returns 사용자 관리 가능 여부 + */ +export function canManageUsers( + user?: PersonBean | null, + targetCompanyCode?: string +): boolean { + if (!user) return false; + + // 슈퍼관리자는 모든 회사 사용자 관리 가능 + if (isSuperAdmin(user)) { + return true; + } + + // 회사 관리자는 자기 회사 사용자만 관리 가능 + if (isCompanyAdmin(user)) { + return user.companyCode === targetCompanyCode; + } + + return false; +} + +/** + * 회사 설정 변경 권한 확인 (관리자만) + * @param user 사용자 정보 + * @param targetCompanyCode 설정 변경하려는 회사 코드 + * @returns 설정 변경 가능 여부 + */ +export function canManageCompanySettings( + user?: PersonBean | null, + targetCompanyCode?: string +): boolean { + return canManageUsers(user, targetCompanyCode); +} + +/** + * 회사 생성/삭제 권한 확인 (슈퍼관리자만) + * @param user 사용자 정보 + * @returns 회사 생성/삭제 가능 여부 + */ +export function canManageCompanies(user?: PersonBean | null): boolean { + return isSuperAdmin(user); +} + +/** + * 시스템 설정 변경 권한 확인 (슈퍼관리자만) + * @param user 사용자 정보 + * @returns 시스템 설정 변경 가능 여부 + */ +export function canManageSystemSettings(user?: PersonBean | null): boolean { + return isSuperAdmin(user); +} + +/** + * 권한 에러 메시지 생성 + * @param requiredLevel 필요한 권한 레벨 + * @returns 에러 메시지 + */ +export function getPermissionErrorMessage( + requiredLevel: PermissionLevel +): string { + const messages: Record = { + [PermissionLevel.SUPER_ADMIN]: + "최고 관리자 권한이 필요합니다. 전체 시스템을 관리할 수 있는 권한이 없습니다.", + [PermissionLevel.COMPANY_ADMIN]: + "관리자 권한이 필요합니다. 회사 관리자 이상의 권한이 필요합니다.", + [PermissionLevel.USER]: "인증된 사용자 권한이 필요합니다.", + }; + + return messages[requiredLevel] || "권한이 부족합니다."; +} + +/** + * 권한 부족 에러 객체 생성 + * @param requiredLevel 필요한 권한 레벨 + * @returns 에러 응답 객체 + */ +export function createPermissionError(requiredLevel: PermissionLevel) { + return { + success: false, + error: { + code: "INSUFFICIENT_PERMISSION", + details: getPermissionErrorMessage(requiredLevel), + }, + }; +} + +/** + * 사용자 권한 정보 요약 + * @param user 사용자 정보 + * @returns 권한 정보 객체 + */ +export function getUserPermissionSummary(user?: PersonBean | null) { + if (!user) { + return { + level: null, + isSuperAdmin: false, + isCompanyAdmin: false, + isAdmin: false, + canExecuteDDL: false, + canManageUsers: false, + canManageCompanies: false, + canManageSystemSettings: false, + }; + } + + return { + level: getUserPermissionLevel(user), + isSuperAdmin: isSuperAdmin(user), + isCompanyAdmin: isCompanyAdmin(user), + isAdmin: isAdmin(user), + canExecuteDDL: canExecuteDDL(user), + canManageUsers: isAdmin(user), + canManageCompanies: canManageCompanies(user), + canManageSystemSettings: canManageSystemSettings(user), + }; +} diff --git a/backend-node/tsconfig.json b/backend-node/tsconfig.json index 848784d8..1dd27608 100644 --- a/backend-node/tsconfig.json +++ b/backend-node/tsconfig.json @@ -1,38 +1,29 @@ { "compilerOptions": { - "target": "ES2022", + "target": "ES2020", "module": "commonjs", - "lib": ["ES2022"], + "lib": ["ES2020"], "outDir": "./dist", "rootDir": "./src", - "strict": true, + "strict": false, "esModuleInterop": true, "skipLibCheck": true, "forceConsistentCasingInFileNames": true, "resolveJsonModule": true, - "declaration": true, - "declarationMap": true, - "sourceMap": true, - "removeComments": true, - "noImplicitAny": true, - "strictNullChecks": true, - "strictFunctionTypes": true, - "noImplicitReturns": true, - "noFallthroughCasesInSwitch": true, "moduleResolution": "node", - "baseUrl": "./", - "paths": { - "@/*": ["src/*"], - "@/config/*": ["src/config/*"], - "@/controllers/*": ["src/controllers/*"], - "@/services/*": ["src/services/*"], - "@/models/*": ["src/models/*"], - "@/middleware/*": ["src/middleware/*"], - "@/utils/*": ["src/utils/*"], - "@/types/*": ["src/types/*"], - "@/validators/*": ["src/validators/*"] - } + "allowSyntheticDefaultImports": true, + "noImplicitReturns": false, + "noUnusedLocals": false, + "noUnusedParameters": false, + "noEmitOnError": false, + "noImplicitAny": false }, - "include": ["src/**/*", "src/types/**/*.d.ts"], - "exclude": ["node_modules", "dist", "**/*.test.ts"] + "include": ["src/**/*"], + "exclude": ["node_modules", "dist"], + "ts-node": { + "transpileOnly": true, + "compilerOptions": { + "module": "commonjs" + } + } } diff --git a/db/migrations/RUN_027_MIGRATION.md b/db/migrations/RUN_027_MIGRATION.md new file mode 100644 index 00000000..8871bd8b --- /dev/null +++ b/db/migrations/RUN_027_MIGRATION.md @@ -0,0 +1,104 @@ +# 027 마이그레이션 실행 가이드 + +## 개요 + +`dept_info` 테이블에 `company_code` 컬럼을 추가하는 마이그레이션입니다. + +## 실행 방법 + +### 방법 1: Docker Compose를 통한 실행 (권장) + +```bash +# 1. 현재 사용 중인 Docker Compose 파일 확인 +cd /Users/kimjuseok/ERP-node + +# 2. DB 컨테이너 이름 확인 +docker ps | grep postgres + +# 3. 마이그레이션 실행 +docker exec -i psql -U postgres -d ilshin < db/migrations/027_add_company_code_to_dept_info.sql + +# 예시 (컨테이너 이름이 'erp-node-db-1'인 경우): +docker exec -i erp-node-db-1 psql -U postgres -d ilshin < db/migrations/027_add_company_code_to_dept_info.sql +``` + +### 방법 2: pgAdmin 또는 DBeaver를 통한 실행 + +1. pgAdmin 또는 DBeaver 실행 +2. PostgreSQL 서버 연결: + - Host: `39.117.244.52` + - Port: `11132` + - Database: `plm` + - Username: `postgres` + - Password: `ph0909!!` +3. `db/migrations/027_add_company_code_to_dept_info.sql` 파일 내용을 복사 +4. SQL 쿼리 창에 붙여넣기 +5. 실행 (F5 또는 Execute 버튼) + +### 방법 3: psql CLI를 통한 직접 연결 + +```bash +# 1. psql 설치 확인 +psql --version + +# 2. 직접 연결하여 마이그레이션 실행 +psql -h 39.117.244.52 -p 11132 -U postgres -d plm -f db/migrations/027_add_company_code_to_dept_info.sql +``` + +## 마이그레이션 검증 + +마이그레이션이 성공적으로 실행되었는지 확인: + +```sql +-- 1. company_code 컬럼 추가 확인 +SELECT column_name, data_type, is_nullable, column_default +FROM information_schema.columns +WHERE table_name = 'dept_info' AND column_name = 'company_code'; + +-- 2. 인덱스 생성 확인 +SELECT indexname, indexdef +FROM pg_indexes +WHERE tablename = 'dept_info' AND indexname = 'idx_dept_info_company_code'; + +-- 3. 데이터 마이그레이션 확인 (company_code가 모두 채워졌는지) +SELECT company_code, COUNT(*) as dept_count +FROM dept_info +GROUP BY company_code +ORDER BY company_code; + +-- 4. NULL 값이 있는지 확인 (없어야 정상) +SELECT COUNT(*) as null_count +FROM dept_info +WHERE company_code IS NULL; +``` + +## 롤백 방법 (문제 발생 시) + +```sql +-- 1. 인덱스 제거 +DROP INDEX IF EXISTS idx_dept_info_company_code; + +-- 2. company_code 컬럼 제거 +ALTER TABLE dept_info DROP COLUMN IF EXISTS company_code; +``` + +## 주의사항 + +1. **백업 필수**: 마이그레이션 실행 전 반드시 데이터베이스 백업 +2. **운영 환경**: 운영 환경에서는 점검 시간에 실행 권장 +3. **트랜잭션**: 마이그레이션은 하나의 트랜잭션으로 실행됨 (실패 시 자동 롤백) +4. **성능**: `dept_info` 테이블 크기에 따라 실행 시간이 다를 수 있음 + +## 마이그레이션 내용 요약 + +1. `company_code` 컬럼 추가 (VARCHAR(20)) +2. `company_code` 인덱스 생성 +3. 기존 데이터 마이그레이션 (`hq_name` → `company_code`) +4. `company_code`를 NOT NULL로 변경 +5. 기본값 'ILSHIN' 설정 + +## 관련 파일 + +- 마이그레이션 파일: `db/migrations/027_add_company_code_to_dept_info.sql` +- 백엔드 API 수정: `backend-node/src/controllers/adminController.ts` +- 프론트엔드 API: `frontend/lib/api/user.ts` diff --git a/docs/권한_그룹_관리_상세_가이드.md b/docs/권한_그룹_관리_상세_가이드.md new file mode 100644 index 00000000..fd157fb6 --- /dev/null +++ b/docs/권한_그룹_관리_상세_가이드.md @@ -0,0 +1,814 @@ +# 권한 그룹 관리 시스템 상세 가이드 + +> 작성일: 2025-01-27 +> 파일 위치: `backend-node/src/services/roleService.ts`, `backend-node/src/controllers/roleController.ts` + +--- + +## 📋 목차 + +1. [권한 그룹 관리 구조](#권한-그룹-관리-구조) +2. [최고 관리자 권한](#최고-관리자-권한) +3. [회사 관리자 권한](#회사-관리자-권한) +4. [메뉴 권한 설정](#메뉴-권한-설정) +5. [멤버 관리](#멤버-관리) +6. [권한 체크 로직](#권한-체크-로직) + +--- + +## 권한 그룹 관리 구조 + +### 데이터베이스 구조 + +```sql +-- 권한 그룹 마스터 테이블 +authority_master ( + objid SERIAL PRIMARY KEY, -- 권한 그룹 ID + auth_name VARCHAR(200), -- 권한 그룹명 + auth_code VARCHAR(100), -- 권한 그룹 코드 + company_code VARCHAR(50), -- 회사 코드 ⭐ + status VARCHAR(20), -- 상태 (active/inactive) + writer VARCHAR(50), -- 작성자 + regdate TIMESTAMP -- 등록일 +) + +-- 권한 그룹 멤버 테이블 +authority_sub_user ( + objid SERIAL PRIMARY KEY, -- 멤버 ID + master_objid INTEGER, -- 권한 그룹 ID (FK) + user_id VARCHAR(50), -- 사용자 ID + writer VARCHAR(50), -- 작성자 + regdate TIMESTAMP -- 등록일 +) + +-- 메뉴 권한 테이블 +rel_menu_auth ( + objid SERIAL PRIMARY KEY, -- 권한 ID + menu_objid INTEGER, -- 메뉴 ID (FK) + auth_objid INTEGER, -- 권한 그룹 ID (FK) + create_yn VARCHAR(1), -- 생성 권한 (Y/N) + read_yn VARCHAR(1), -- 조회 권한 (Y/N) + update_yn VARCHAR(1), -- 수정 권한 (Y/N) + delete_yn VARCHAR(1), -- 삭제 권한 (Y/N) + execute_yn VARCHAR(1), -- 실행 권한 (Y/N) ⭐ + export_yn VARCHAR(1), -- 내보내기 권한 (Y/N) ⭐ + writer VARCHAR(50), -- 작성자 + regdate TIMESTAMP -- 등록일 +) + +-- 메뉴 정보 테이블 +menu_info ( + objid SERIAL PRIMARY KEY, -- 메뉴 ID + menu_name_kor VARCHAR(200), -- 메뉴명 (한글) + menu_name_eng VARCHAR(200), -- 메뉴명 (영문) + menu_code VARCHAR(100), -- 메뉴 코드 + menu_url VARCHAR(500), -- 메뉴 URL + menu_type INTEGER, -- 메뉴 타입 + parent_obj_id INTEGER, -- 부모 메뉴 ID + seq INTEGER, -- 정렬 순서 + company_code VARCHAR(50), -- 회사 코드 ⭐ + status VARCHAR(20) -- 상태 (active/inactive) +) +``` + +### 권한 계층 구조 + +``` +최고 관리자 (SUPER_ADMIN, company_code = "*") + ├─ 모든 회사의 권한 그룹 조회/생성/수정/삭제 + ├─ 모든 회사의 메뉴에 대해 권한 부여 가능 + └─ 다른 회사 사용자를 권한 그룹에 추가 가능 + +회사 관리자 (COMPANY_ADMIN, company_code = "20", "30", etc.) + ├─ 자기 회사의 권한 그룹만 조회/생성/수정/삭제 + ├─ 자기 회사의 메뉴에 대해서만 권한 부여 가능 + └─ 자기 회사 사용자만 권한 그룹에 추가 가능 + +일반 사용자 (USER) + └─ 권한 그룹 관리 불가 (읽기 전용) +``` + +--- + +## 최고 관리자 권한 + +### 1. 권한 그룹 목록 조회 + +**API**: `GET /api/roles` + +**로직**: + +```typescript +export const getRoleGroups = async (req, res) => { + const companyCode = req.query.companyCode as string | undefined; + + if (isSuperAdmin(req.user)) { + // 최고 관리자: companyCode 파라미터가 있으면 해당 회사만, 없으면 전체 조회 + targetCompanyCode = companyCode; // undefined면 전체 조회 + } else { + // 회사 관리자: 자기 회사만 조회 + targetCompanyCode = req.user?.companyCode; + } + + const roleGroups = await RoleService.getRoleGroups(targetCompanyCode, search); +}; +``` + +**데이터베이스 쿼리**: + +```typescript +// RoleService.getRoleGroups() +let sql = `SELECT * FROM authority_master WHERE 1=1`; + +if (companyCode) { + sql += ` AND company_code = $1`; // 특정 회사만 +} else { + // 조건 없음 -> 전체 조회 +} +``` + +**결과**: + +- ✅ `companyCode` 파라미터 없음 → **모든 회사의 권한 그룹 조회** +- ✅ `companyCode = "20"` → **회사 "20"의 권한 그룹만 조회** +- ✅ `companyCode = "*"` → **공통 권한 그룹만 조회** (있다면) + +### 2. 권한 그룹 생성 + +**API**: `POST /api/roles` + +**로직**: + +```typescript +export const createRoleGroup = async (req, res) => { + const { authName, authCode, companyCode } = req.body; + + // 최고 관리자가 아닌 경우, 자기 회사에만 생성 가능 + if (!isSuperAdmin(req.user) && companyCode !== req.user?.companyCode) { + return res.status(403).json({ + message: "자신의 회사에만 권한 그룹을 생성할 수 있습니다", + }); + } + + await RoleService.createRoleGroup({ + authName, + authCode, + companyCode, + writer, + }); +}; +``` + +**결과**: + +- ✅ 최고 관리자는 **어떤 회사에도** 권한 그룹 생성 가능 +- ✅ `companyCode = "*"` 로 공통 권한 그룹도 생성 가능 +- ❌ 회사 관리자는 자기 회사에만 생성 가능 + +### 3. 메뉴 목록 조회 (권한 부여용) + +**API**: `GET /api/roles/menus/all?companyCode=20` + +**로직**: + +```typescript +export const getAllMenus = async (req, res) => { + const requestedCompanyCode = req.query.companyCode as string | undefined; + + let companyCode: string | undefined; + if (isSuperAdmin(req.user)) { + // 최고 관리자: 요청한 회사 코드 사용 (없으면 전체) + companyCode = requestedCompanyCode; + } else { + // 회사 관리자: 자기 회사 코드만 사용 + companyCode = req.user?.companyCode; + } + + const menus = await RoleService.getAllMenus(companyCode); +}; +``` + +**데이터베이스 쿼리**: + +```typescript +// RoleService.getAllMenus() +let whereConditions = ["status = 'active'"]; + +if (companyCode) { + // 특정 회사 메뉴만 조회 (공통 메뉴 제외) + whereConditions.push(`company_code = $1`); +} else { + // 조건 없음 -> 전체 메뉴 조회 +} + +const sql = ` + SELECT * FROM menu_info + WHERE ${whereConditions.join(" AND ")} + ORDER BY seq, menu_name_kor +`; +``` + +**결과**: + +- ✅ `companyCode` 없음 → **모든 회사의 모든 메뉴 조회** +- ✅ `companyCode = "20"` → **회사 "20"의 메뉴만 조회** +- ✅ `companyCode = "*"` → **공통 메뉴만 조회** + +**⚠️ 프론트엔드 구현 주의사항:** + +프론트엔드에서 권한 그룹 상세 화면 (메뉴 권한 설정)에서는 **최고 관리자가 모든 메뉴를 볼 수 있도록** `companyCode` 파라미터를 전달하지 않아야 합니다: + +```typescript +// MenuPermissionsTable.tsx +const { user: currentUser } = useAuth(); +const isSuperAdmin = + currentUser?.companyCode === "*" && currentUser?.userType === "SUPER_ADMIN"; + +// 최고 관리자: companyCode 없이 모든 메뉴 조회 +// 회사 관리자: 자기 회사 메뉴만 조회 +const targetCompanyCode = isSuperAdmin ? undefined : roleGroup.companyCode; + +const response = await roleAPI.getAllMenus(targetCompanyCode); +``` + +**이렇게 하지 않으면:** + +- 최고 관리자가 회사 "20"의 권한 그룹에 들어가도 +- `roleGroup.companyCode` (= "20")를 API로 전달하면 +- 회사 "20"의 메뉴만 보이게 됨 ❌ + +**올바른 동작:** + +- 최고 관리자가 회사 "20"의 권한 그룹에 들어가면 +- `undefined`를 API로 전달하여 +- **모든 회사의 모든 메뉴**를 볼 수 있어야 함 ✅ + +### 4. 메뉴 권한 설정 + +**API**: `POST /api/roles/:id/menu-permissions` + +**Request Body**: + +```json +{ + "permissions": [ + { + "menuObjid": 101, + "createYn": "Y", + "readYn": "Y", + "updateYn": "Y", + "deleteYn": "Y", + "executeYn": "Y", + "exportYn": "Y" + }, + { + "menuObjid": 102, + "createYn": "N", + "readYn": "Y", + "updateYn": "N", + "deleteYn": "N", + "executeYn": "N", + "exportYn": "N" + } + ] +} +``` + +**로직**: + +```typescript +export const setMenuPermissions = async (req, res) => { + const authObjid = parseInt(req.params.id, 10); + const { permissions } = req.body; + + // 권한 그룹 조회 + const roleGroup = await RoleService.getRoleGroupById(authObjid); + + // 권한 체크 + if ( + !isSuperAdmin(req.user) && + !canAccessCompanyData(req.user, roleGroup.companyCode) + ) { + return res.status(403).json({ message: "메뉴 권한 설정 권한이 없습니다" }); + } + + await RoleService.setMenuPermissions(authObjid, permissions, writer); +}; +``` + +**데이터베이스 쿼리**: + +```typescript +// RoleService.setMenuPermissions() + +// 1. 기존 권한 삭제 +await query("DELETE FROM rel_menu_auth WHERE auth_objid = $1", [authObjid]); + +// 2. 새로운 권한 삽입 +const sql = ` + INSERT INTO rel_menu_auth + (objid, menu_objid, auth_objid, create_yn, read_yn, update_yn, delete_yn, execute_yn, export_yn, writer, regdate) + VALUES + ${values} -- (nextval('seq'), $1, $2, $3, $4, $5, $6, $7, $8, $9, NOW()), ... +`; +``` + +**결과**: + +- ✅ 최고 관리자는 **어떤 권한 그룹에도** 메뉴 권한 설정 가능 +- ✅ **모든 회사의 메뉴**에 대해 권한 부여 가능 +- ✅ CRUD + Execute + Export 총 6가지 권한 설정 + +### 5. 멤버 관리 + +**API**: `PUT /api/roles/:id/members` + +**Request Body**: + +```json +{ + "userIds": ["user001", "user002", "user003"] +} +``` + +**로직**: + +```typescript +export const updateRoleMembers = async (req, res) => { + const masterObjid = parseInt(req.params.id, 10); + const { userIds } = req.body; + + // 권한 그룹 조회 + const roleGroup = await RoleService.getRoleGroupById(masterObjid); + + // 권한 체크 + if ( + !isSuperAdmin(req.user) && + !canAccessCompanyData(req.user, roleGroup.companyCode) + ) { + return res + .status(403) + .json({ message: "권한 그룹 멤버 수정 권한이 없습니다" }); + } + + // 기존 멤버 조회 + const existingMembers = await RoleService.getRoleMembers(masterObjid); + const existingUserIds = existingMembers.map((m) => m.userId); + + // 추가할 멤버 (새로 추가된 것들) + const toAdd = userIds.filter((id) => !existingUserIds.includes(id)); + + // 제거할 멤버 (기존에 있었는데 없어진 것들) + const toRemove = existingUserIds.filter((id) => !userIds.includes(id)); + + // 추가 + if (toAdd.length > 0) { + await RoleService.addRoleMembers(masterObjid, toAdd, writer); + } + + // 제거 + if (toRemove.length > 0) { + await RoleService.removeRoleMembers(masterObjid, toRemove, writer); + } +}; +``` + +**결과**: + +- ✅ 최고 관리자는 **어떤 회사 사용자도** 권한 그룹에 추가 가능 +- ✅ 다른 회사 권한 그룹에도 멤버 추가 가능 +- ⚠️ 단, 사용자 목록 API에서 최고 관리자는 필터링되므로 추가 불가 + +--- + +## 회사 관리자 권한 + +### 1. 권한 그룹 목록 조회 + +**로직**: + +```typescript +if (!isSuperAdmin(req.user)) { + // 회사 관리자: 자기 회사만 조회 + targetCompanyCode = req.user?.companyCode; // 강제로 자기 회사 +} +``` + +**결과**: + +- ✅ 자기 회사 (`company_code = "20"`) 권한 그룹만 조회 +- ❌ 다른 회사 권한 그룹은 **절대 볼 수 없음** + +### 2. 권한 그룹 생성 + +**로직**: + +```typescript +if (!isSuperAdmin(req.user) && companyCode !== req.user?.companyCode) { + return res.status(403).json({ + message: "자신의 회사에만 권한 그룹을 생성할 수 있습니다", + }); +} +``` + +**결과**: + +- ✅ 자기 회사에만 권한 그룹 생성 가능 +- ❌ 다른 회사나 공통(`*`)에는 생성 불가 + +### 3. 메뉴 목록 조회 + +**로직**: + +```typescript +if (!isSuperAdmin(req.user)) { + // 회사 관리자: 자기 회사 코드만 사용 + companyCode = req.user?.companyCode; // 강제로 자기 회사 +} +``` + +**데이터베이스 쿼리**: + +```sql +SELECT * FROM menu_info +WHERE status = 'active' + AND company_code = '20' -- 자기 회사만 +ORDER BY seq, menu_name_kor +``` + +**결과**: + +- ✅ 자기 회사 메뉴만 조회 +- ❌ 공통 메뉴(`company_code = "*"`)는 **조회되지 않음** +- ❌ 다른 회사 메뉴는 **절대 볼 수 없음** + +### 4. 메뉴 권한 설정 + +**로직**: + +```typescript +// 권한 그룹의 회사 코드 체크 +if ( + !isSuperAdmin(req.user) && + !canAccessCompanyData(req.user, roleGroup.companyCode) +) { + return res.status(403).json({ message: "메뉴 권한 설정 권한이 없습니다" }); +} +``` + +**`canAccessCompanyData` 함수**: + +```typescript +export function canAccessCompanyData( + user: UserInfo | undefined, + targetCompanyCode: string +): boolean { + if (!user) return false; + if (isSuperAdmin(user)) return true; // 슈퍼관리자는 모든 회사 접근 가능 + return user.companyCode === targetCompanyCode; // 자기 회사만 접근 가능 +} +``` + +**결과**: + +- ✅ 자기 회사 권한 그룹에만 메뉴 권한 설정 가능 +- ✅ 자기 회사 메뉴에 대해서만 권한 부여 가능 +- ❌ 다른 회사 권한 그룹이나 메뉴는 접근 불가 + +### 5. 멤버 관리 + +**로직**: + +```typescript +// 권한 그룹의 회사 코드 체크 +if ( + !isSuperAdmin(req.user) && + !canAccessCompanyData(req.user, roleGroup.companyCode) +) { + return res + .status(403) + .json({ message: "권한 그룹 멤버 수정 권한이 없습니다" }); +} +``` + +**사용자 목록 조회 (Dual List Box)**: + +```typescript +// adminController.getUserList() +if (req.user && req.user.companyCode !== "*") { + whereConditions.push(`company_code != '*'`); // 최고 관리자 필터링 + whereConditions.push(`company_code = $1`); // 자기 회사만 +} +``` + +**결과**: + +- ✅ 자기 회사 권한 그룹에만 멤버 추가/제거 가능 +- ✅ 자기 회사 사용자만 멤버로 추가 가능 +- ❌ 최고 관리자는 목록에서 **절대 보이지 않음** +- ❌ 다른 회사 사용자는 **절대 볼 수 없음** + +--- + +## 메뉴 권한 설정 + +### 권한 종류 (6가지) + +| 권한 | 컬럼명 | 설명 | 예시 | +| ------------ | ------------ | ------------------------- | ------------------------ | +| **생성** | `create_yn` | 데이터 생성 가능 여부 | 사용자 추가, 주문 생성 | +| **조회** | `read_yn` | 데이터 조회 가능 여부 | 목록 보기, 상세 보기 | +| **수정** | `update_yn` | 데이터 수정 가능 여부 | 정보 변경, 상태 변경 | +| **삭제** | `delete_yn` | 데이터 삭제 가능 여부 | 항목 삭제, 데이터 제거 | +| **실행** | `execute_yn` | 특수 기능 실행 가능 여부 | 플로우 실행, 배치 실행 | +| **내보내기** | `export_yn` | 데이터 내보내기 가능 여부 | Excel 다운로드, PDF 출력 | + +### 권한 설정 예시 + +#### 1. 읽기 전용 권한 + +```json +{ + "createYn": "N", + "readYn": "Y", + "updateYn": "N", + "deleteYn": "N", + "executeYn": "N", + "exportYn": "N" +} +``` + +→ 조회만 가능, 수정/삭제 불가 + +#### 2. 전체 권한 + +```json +{ + "createYn": "Y", + "readYn": "Y", + "updateYn": "Y", + "deleteYn": "Y", + "executeYn": "Y", + "exportYn": "Y" +} +``` + +→ 모든 작업 가능 + +#### 3. 운영자 권한 + +```json +{ + "createYn": "Y", + "readYn": "Y", + "updateYn": "Y", + "deleteYn": "N", + "executeYn": "Y", + "exportYn": "Y" +} +``` + +→ 삭제 제외 모든 작업 가능 + +### 메뉴 권한 설정 프로세스 + +```mermaid +graph TD + A[권한 그룹 선택] --> B[메뉴 목록 조회] + B --> C{최고 관리자?} + C -->|Yes| D[모든 회사 메뉴 조회] + C -->|No| E[자기 회사 메뉴만 조회] + D --> F[메뉴별 권한 설정] + E --> F + F --> G[기존 권한 삭제] + G --> H[새 권한 일괄 삽입] + H --> I[완료] +``` + +--- + +## 멤버 관리 + +### Dual List Box 구조 + +``` +┌─────────────────────┐ ┌─────────────────────┐ +│ 사용 가능한 사용자 │ → │ 권한 그룹 멤버 │ +│ │ ← │ │ +│ [ ] user001 │ │ [x] user005 │ +│ [ ] user002 │ │ [x] user006 │ +│ [ ] user003 │ │ [x] user007 │ +│ [ ] user004 │ │ │ +└─────────────────────┘ └─────────────────────┘ +``` + +### 멤버 추가/제거 로직 + +```typescript +// 프론트엔드에서 전송 +PUT /api/roles/123/members +{ + "userIds": ["user005", "user006", "user008"] // 최종 멤버 목록 +} + +// 백엔드 처리 +const existingUserIds = ["user005", "user006", "user007"]; // 기존 멤버 +const newUserIds = ["user005", "user006", "user008"]; // 요청된 멤버 + +const toAdd = ["user008"]; // 새로 추가 (기존에 없던 것) +const toRemove = ["user007"]; // 제거 (기존에 있었는데 없어진 것) + +// 추가 +INSERT INTO authority_sub_user (master_objid, user_id, writer, regdate) +VALUES (123, 'user008', 'admin', NOW()) + +// 제거 +DELETE FROM authority_sub_user +WHERE master_objid = 123 AND user_id = 'user007' +``` + +### 사용자 목록 필터링 + +**API**: `GET /api/admin/users?companyCode=20&size=1000` + +**로직**: + +```typescript +// 회사 코드 필터 +if (companyCode && companyCode.trim()) { + whereConditions.push(`company_code = $1`); + queryParams.push(companyCode.trim()); +} + +// 최고 관리자 필터링 (중요!) +if (req.user && req.user.companyCode !== "*") { + whereConditions.push(`company_code != '*'`); // 최고 관리자 숨김 +} + +// 검색 조건 +if (search && search.trim()) { + whereConditions.push(`( + user_id ILIKE $2 OR + user_name ILIKE $2 OR + dept_name ILIKE $2 + )`); + queryParams.push(`%${search.trim()}%`); +} +``` + +**결과**: + +- ✅ 최고 관리자: 요청한 회사의 사용자 목록 +- ✅ 회사 관리자: 자기 회사 사용자 목록 +- ✅ 최고 관리자(`company_code = "*"`)는 **절대 목록에 표시되지 않음** + +--- + +## 권한 체크 로직 + +### 1. `isSuperAdmin()` + +**파일**: `backend-node/src/utils/permissionUtils.ts` + +```typescript +export function isSuperAdmin(user: UserInfo | undefined): boolean { + if (!user) return false; + return user.companyCode === "*"; +} +``` + +**사용**: + +- 권한 그룹 목록 전체 조회 여부 +- 다른 회사 데이터 접근 여부 +- 모든 메뉴 조회 여부 + +### 2. `isCompanyAdmin()` + +```typescript +export function isCompanyAdmin(user: UserInfo | undefined): boolean { + if (!user) return false; + return user.userType === "COMPANY_ADMIN"; +} +``` + +**사용**: + +- 권한 그룹 관리 접근 여부 +- 메뉴 권한 설정 접근 여부 + +### 3. `canAccessCompanyData()` + +```typescript +export function canAccessCompanyData( + user: UserInfo | undefined, + targetCompanyCode: string +): boolean { + if (!user) return false; + if (isSuperAdmin(user)) return true; // 슈퍼관리자는 모든 회사 접근 가능 + return user.companyCode === targetCompanyCode; // 자기 회사만 접근 가능 +} +``` + +**사용**: + +- 권한 그룹 상세 조회 접근 여부 +- 권한 그룹 수정/삭제 접근 여부 +- 멤버 관리 접근 여부 +- 메뉴 권한 설정 접근 여부 + +### 권한 체크 플로우 + +```mermaid +graph TD + A[API 요청] --> B{로그인 확인} + B -->|No| C[401 Unauthorized] + B -->|Yes| D{최고 관리자?} + D -->|Yes| E[모든 데이터 접근 허용] + D -->|No| F{회사 관리자?} + F -->|No| G[403 Forbidden] + F -->|Yes| H{자기 회사 데이터?} + H -->|No| I[403 Forbidden] + H -->|Yes| J[접근 허용] +``` + +--- + +## 💡 핵심 정리 + +### ✅ 최고 관리자가 할 수 있는 것 + +1. **권한 그룹 관리** + + - ✅ 모든 회사의 권한 그룹 조회 + - ✅ 어떤 회사에도 권한 그룹 생성 가능 + - ✅ 다른 회사 권한 그룹 수정/삭제 가능 + +2. **메뉴 권한 설정** + + - ✅ 모든 회사의 메뉴 조회 가능 + - ✅ 어떤 권한 그룹에도 메뉴 권한 부여 가능 + - ✅ 모든 메뉴에 대해 CRUD + Execute + Export 권한 설정 + +3. **멤버 관리** + - ✅ 어떤 회사 권한 그룹에도 멤버 추가 가능 + - ✅ 다른 회사 사용자도 멤버로 추가 가능 + - ⚠️ 단, 최고 관리자는 사용자 목록에서 필터링되므로 추가 불가 + +### ✅ 회사 관리자가 할 수 있는 것 + +1. **권한 그룹 관리** + + - ✅ 자기 회사 권한 그룹만 조회 + - ✅ 자기 회사에만 권한 그룹 생성 가능 + - ✅ 자기 회사 권한 그룹만 수정/삭제 가능 + +2. **메뉴 권한 설정** + + - ✅ 자기 회사 메뉴만 조회 가능 + - ✅ 자기 회사 권한 그룹에만 메뉴 권한 부여 가능 + - ✅ 자기 회사 메뉴에 대해서만 CRUD + Execute + Export 권한 설정 + +3. **멤버 관리** + - ✅ 자기 회사 권한 그룹에만 멤버 추가 가능 + - ✅ 자기 회사 사용자만 멤버로 추가 가능 + - ✅ 최고 관리자는 목록에서 절대 보이지 않음 + +### ❌ 제한 사항 + +1. **회사 관리자는 절대 할 수 없는 것** + + - ❌ 다른 회사 권한 그룹 조회/수정/삭제 + - ❌ 공통 메뉴(`company_code = "*"`) 조회 + - ❌ 최고 관리자를 멤버로 추가 + - ❌ 다른 회사 사용자를 멤버로 추가 + +2. **최고 관리자도 할 수 없는 것** + - ❌ 다른 최고 관리자를 권한 그룹 멤버로 추가 (사용자 목록 필터링으로 인해) + +--- + +## 📊 권한 매트릭스 + +| 작업 | 최고 관리자 | 회사 관리자 | 일반 사용자 | +| ------------------ | ------------------- | ------------------------ | ----------- | +| **권한 그룹 조회** | ✅ 모든 회사 | ✅ 자기 회사만 | ❌ | +| **권한 그룹 생성** | ✅ 모든 회사 | ✅ 자기 회사만 | ❌ | +| **권한 그룹 수정** | ✅ 모든 회사 | ✅ 자기 회사만 | ❌ | +| **권한 그룹 삭제** | ✅ 모든 회사 | ✅ 자기 회사만 | ❌ | +| **메뉴 목록 조회** | ✅ 모든 회사 메뉴 | ✅ 자기 회사 메뉴만 | ❌ | +| **메뉴 권한 설정** | ✅ 모든 권한 그룹 | ✅ 자기 회사 권한 그룹만 | ❌ | +| **멤버 추가** | ✅ 모든 회사 사용자 | ✅ 자기 회사 사용자만 | ❌ | +| **멤버 제거** | ✅ 모든 회사 멤버 | ✅ 자기 회사 멤버만 | ❌ | + +--- + +## 📝 작성자 + +- 작성: AI Assistant (Claude Sonnet 4.5) +- 검토 필요: 백엔드 개발자, 시스템 아키텍트 +- 관련 파일: + - `backend-node/src/services/roleService.ts` + - `backend-node/src/controllers/roleController.ts` + - `backend-node/src/utils/permissionUtils.ts` + - `frontend/components/admin/RoleDetailManagement.tsx` diff --git a/docs/권한_그룹_메뉴_필터링_가이드.md b/docs/권한_그룹_메뉴_필터링_가이드.md new file mode 100644 index 00000000..85ef27b8 --- /dev/null +++ b/docs/권한_그룹_메뉴_필터링_가이드.md @@ -0,0 +1,367 @@ +# 권한 그룹 기반 메뉴 필터링 가이드 + +> 작성일: 2025-01-27 +> 파일 위치: `backend-node/src/services/adminService.ts` + +--- + +## 📋 목차 + +1. [개요](#개요) +2. [메뉴 필터링 로직](#메뉴-필터링-로직) +3. [데이터베이스 구조](#데이터베이스-구조) +4. [구현 상세](#구현-상세) +5. [테스트 시나리오](#테스트-시나리오) + +--- + +## 개요 + +### ✅ 구현 완료 (2025-01-27) + +사용자가 좌측 사이드바에서 볼 수 있는 메뉴는 **권한 그룹 기반**으로 필터링됩니다: + +1. 사용자가 속한 권한 그룹 조회 (`authority_sub_user`) +2. 해당 권한 그룹의 메뉴 권한 확인 (`rel_menu_auth`) +3. **`read_yn = 'Y'`인 메뉴만 사이드바에 표시** + +--- + +## 메뉴 필터링 로직 + +### 흐름도 + +```mermaid +graph TD + A[사용자 로그인] --> B{권한 그룹 조회} + B -->|권한 그룹 있음| C[rel_menu_auth 조회] + B -->|권한 그룹 없음| D[메뉴 없음] + C --> E{read_yn = 'Y'?} + E -->|Yes| F[메뉴 표시] + E -->|No| G[메뉴 숨김] +``` + +### 주요 단계 + +1. **권한 그룹 조회** + + ```sql + SELECT DISTINCT am.objid AS role_objid, am.auth_name + FROM authority_master am + JOIN authority_sub_user asu ON am.objid = asu.master_objid + WHERE asu.user_id = $1 + AND am.status = 'active' + ``` + +2. **메뉴 권한 필터링** + + ```sql + AND EXISTS ( + SELECT 1 + FROM rel_menu_auth rma + WHERE rma.menu_objid = MENU.OBJID + AND rma.auth_objid = ANY($2) -- 사용자의 권한 그룹 배열 + AND rma.read_yn = 'Y' -- 읽기 권한이 있어야 함 + ) + ``` + +3. **회사별 필터링** (기존 로직 유지) + - 최고 관리자: 공통 메뉴 (`company_code = '*'`) + - 회사 관리자/일반 사용자: 자기 회사 메뉴만 + +--- + +## 데이터베이스 구조 + +### 관련 테이블 + +```sql +-- 1. 권한 그룹 마스터 +authority_master ( + objid SERIAL PRIMARY KEY, + auth_name VARCHAR(200), + auth_code VARCHAR(100), + company_code VARCHAR(50), + status VARCHAR(20) +) + +-- 2. 권한 그룹 멤버 +authority_sub_user ( + objid SERIAL PRIMARY KEY, + master_objid INTEGER, -- FK to authority_master + user_id VARCHAR(50) -- 사용자 ID +) + +-- 3. 메뉴 권한 +rel_menu_auth ( + objid SERIAL PRIMARY KEY, + menu_objid INTEGER, -- FK to menu_info + auth_objid INTEGER, -- FK to authority_master + create_yn VARCHAR(1), -- 생성 권한 + read_yn VARCHAR(1), -- 조회 권한 ⭐ 사이드바 표시 기준 + update_yn VARCHAR(1), -- 수정 권한 + delete_yn VARCHAR(1), -- 삭제 권한 + execute_yn VARCHAR(1), -- 실행 권한 + export_yn VARCHAR(1) -- 내보내기 권한 +) + +-- 4. 메뉴 정보 +menu_info ( + objid SERIAL PRIMARY KEY, + menu_name_kor VARCHAR(200), + menu_url VARCHAR(500), + parent_obj_id INTEGER, + company_code VARCHAR(50), + menu_type INTEGER, -- 0: 관리자, 1: 사용자 + status VARCHAR(20) +) +``` + +### 관계도 + +``` +user_info + └─ authority_sub_user (user_id) + └─ authority_master (master_objid) + └─ rel_menu_auth (auth_objid) + └─ menu_info (menu_objid) +``` + +--- + +## 구현 상세 + +### AdminService.getUserMenuList() + +**파일**: `backend-node/src/services/adminService.ts` + +**로직**: + +```typescript +static async getUserMenuList(paramMap: any): Promise { + const { userId, userCompanyCode, userType, userLang = "ko" } = paramMap; + + // 1. 사용자가 속한 권한 그룹 조회 + const userRoleGroups = await query( + ` + SELECT DISTINCT am.objid AS role_objid, am.auth_name + FROM authority_master am + JOIN authority_sub_user asu ON am.objid = asu.master_objid + WHERE asu.user_id = $1 + AND am.status = 'active' + `, + [userId] + ); + + logger.info(`✅ 사용자 ${userId}가 속한 권한 그룹: ${userRoleGroups.length}개`); + + // 2. 권한 그룹 기반 메뉴 필터 조건 생성 + let authFilter = ""; + let queryParams: any[] = [userLang]; + let paramIndex = 2; + + if (userRoleGroups.length > 0) { + // 권한 그룹이 있는 경우: read_yn = 'Y'인 메뉴만 필터링 + const roleObjids = userRoleGroups.map((rg: any) => rg.role_objid); + authFilter = ` + AND EXISTS ( + SELECT 1 + FROM rel_menu_auth rma + WHERE rma.menu_objid = MENU.OBJID + AND rma.auth_objid = ANY($${paramIndex}) + AND rma.read_yn = 'Y' + ) + `; + queryParams.push(roleObjids); + paramIndex++; + } else { + // 권한 그룹이 없는 경우: 메뉴 없음 + logger.warn(`⚠️ 사용자 ${userId}는 권한 그룹이 없어 메뉴가 표시되지 않습니다.`); + return []; + } + + // 3. 회사별 필터링 조건 + let companyFilter = ""; + if (userType === "SUPER_ADMIN" && userCompanyCode === "*") { + companyFilter = `AND MENU.COMPANY_CODE = '*'`; + } else { + companyFilter = `AND MENU.COMPANY_CODE = $${paramIndex}`; + queryParams.push(userCompanyCode); + paramIndex++; + } + + // 4. 메뉴 조회 쿼리 (WITH RECURSIVE) + const menuList = await query( + ` + WITH RECURSIVE v_menu(...) AS ( + SELECT ... + FROM MENU_INFO MENU + WHERE PARENT_OBJ_ID = 0 + AND MENU_TYPE = 1 + AND STATUS = 'active' + ${companyFilter} + ${authFilter} -- ⭐ 권한 그룹 필터 적용 + + UNION ALL + + SELECT ... + FROM MENU_INFO MENU_SUB + JOIN V_MENU ON MENU_SUB.PARENT_OBJ_ID = V_MENU.OBJID + WHERE MENU_SUB.STATUS = 'active' + ${authFilter.replace(/MENU\\.OBJID/g, 'MENU_SUB.OBJID')} -- ⭐ 자식 메뉴에도 적용 + ) + SELECT ... + FROM v_menu A + ... + ORDER BY PATH, SEQ + `, + queryParams + ); + + return menuList; +} +``` + +--- + +## 테스트 시나리오 + +### 시나리오 1: 최고 관리자가 권한 부여 + +**단계**: + +1. 최고 관리자가 "테스트회사 2번"의 권한 그룹에 접속 +2. "대시보드" 메뉴에 대해 `read_yn = 'Y'` 설정 +3. 권한 저장 + +**결과**: + +- ✅ 해당 권한 그룹에 속한 사용자들에게 "대시보드" 메뉴가 사이드바에 표시됨 +- ✅ `read_yn = 'N'`인 다른 메뉴는 표시되지 않음 + +**로그 확인**: + +``` +✅ 사용자 user001가 속한 권한 그룹: 1개 + - 권한 그룹: ["테스트회사2 관리자"] +✅ 권한 그룹 기반 메뉴 필터링 적용: 1개 그룹 +✅ 좌측 사이드바 (COMPANY_ADMIN): 회사 20 메뉴만 표시 +사용자 메뉴 목록 조회 결과: 5개 +``` + +### 시나리오 2: 권한 그룹이 없는 사용자 + +**단계**: + +1. 새로운 사용자 생성 (`user002`) +2. 권한 그룹에 추가하지 않음 +3. 로그인 + +**결과**: + +- ✅ 사이드바에 메뉴가 하나도 표시되지 않음 + +**로그 확인**: + +``` +✅ 사용자 user002가 속한 권한 그룹: 0개 +⚠️ 사용자 user002는 권한 그룹이 없어 메뉴가 표시되지 않습니다. +사용자 메뉴 목록 조회 결과: 0개 +``` + +### 시나리오 3: 여러 권한 그룹에 속한 사용자 + +**단계**: + +1. 사용자 `user003`을 두 개의 권한 그룹에 추가 + - 그룹 A: "대시보드" 메뉴 (`read_yn = 'Y'`) + - 그룹 B: "사용자 관리" 메뉴 (`read_yn = 'Y'`) +2. 로그인 + +**결과**: + +- ✅ "대시보드"와 "사용자 관리" 메뉴가 모두 표시됨 +- ✅ 두 그룹의 권한이 **OR 조건**으로 합쳐짐 + +**SQL 로직**: + +```sql +AND EXISTS ( + SELECT 1 + FROM rel_menu_auth rma + WHERE rma.menu_objid = MENU.OBJID + AND rma.auth_objid = ANY(ARRAY[그룹A_ID, 그룹B_ID]) -- OR 조건 + AND rma.read_yn = 'Y' +) +``` + +### 시나리오 4: 회사 관리자 vs 일반 사용자 + +**공통점**: + +- 둘 다 자기 회사 메뉴만 조회 +- 권한 그룹 기반 필터링 적용 + +**차이점**: + +- **회사 관리자 (COMPANY_ADMIN)**: 권한 그룹 관리 가능 +- **일반 사용자 (USER)**: 권한 그룹 관리 불가 (읽기 전용) + +--- + +## 주의사항 + +### 1. 메뉴 계층 구조 + +- 부모 메뉴에 `read_yn = 'Y'`가 있어야 자식 메뉴도 표시됨 +- 자식 메뉴만 권한이 있어도 부모가 없으면 접근 불가 + +**예시**: + +``` +📁 시스템 관리 (read_yn = 'N') ← 권한 없음 + └─ 📄 사용자 관리 (read_yn = 'Y') ← 권한 있지만 부모가 없어서 접근 불가 +``` + +**해결**: + +- 부모 메뉴에도 `read_yn = 'Y'` 설정 필요 + +### 2. 권한 그룹 상태 + +- `authority_master.status = 'active'`인 그룹만 적용 +- 비활성화된 그룹은 멤버가 있어도 권한 없음 + +### 3. 최고 관리자 예외 + +- 최고 관리자는 **공통 메뉴만** 조회 +- 다른 회사 메뉴는 보이지 않음 +- 최고 관리자도 권한 그룹에 속해야 메뉴가 보임 (일관성 유지) + +### 4. 성능 고려사항 + +- `ANY($1)`: PostgreSQL 배열 연산자 사용으로 성능 최적화 +- `EXISTS` 서브쿼리: 메뉴마다 권한 확인 +- 인덱스 권장: + ```sql + CREATE INDEX idx_rel_menu_auth_menu ON rel_menu_auth(menu_objid); + CREATE INDEX idx_rel_menu_auth_auth ON rel_menu_auth(auth_objid); + CREATE INDEX idx_authority_sub_user_user ON authority_sub_user(user_id); + ``` + +--- + +## 관련 파일 + +- `backend-node/src/services/adminService.ts` - `getUserMenuList()` 메서드 +- `backend-node/src/services/roleService.ts` - 권한 그룹 관리 +- `backend-node/src/controllers/adminController.ts` - API 엔드포인트 +- `frontend/contexts/MenuContext.tsx` - 프론트엔드 메뉴 Context +- `frontend/lib/api/menu.ts` - 메뉴 API 클라이언트 + +--- + +## 📝 작성자 + +- 작성: AI Assistant (Claude Sonnet 4.5) +- 검토 필요: 백엔드 개발자, 시스템 아키텍트 diff --git a/docs/권한_그룹_시스템_설계.md b/docs/권한_그룹_시스템_설계.md new file mode 100644 index 00000000..0709a872 --- /dev/null +++ b/docs/권한_그룹_시스템_설계.md @@ -0,0 +1,317 @@ +# 권한 그룹 시스템 설계 (RBAC) + +## 개요 + +회사 내에서 **역할 기반 접근 제어(RBAC - Role-Based Access Control)**를 통해 세밀한 권한 관리를 제공합니다. + +## 기존 시스템 분석 + +### 현재 테이블 구조 + +#### 1. `authority_master` - 권한 그룹 마스터 + +```sql +CREATE TABLE authority_master ( + objid NUMERIC PRIMARY KEY, + auth_name VARCHAR, -- 권한 그룹 이름 (예: "영업팀 권한", "개발팀 권한") + auth_code VARCHAR, -- 권한 코드 (예: "SALES_TEAM", "DEV_TEAM") + writer VARCHAR, + regdate TIMESTAMP, + status VARCHAR +); +``` + +#### 2. `authority_sub_user` - 권한 그룹 멤버 + +```sql +CREATE TABLE authority_sub_user ( + objid NUMERIC PRIMARY KEY, + master_objid NUMERIC, -- authority_master.objid 참조 + user_id VARCHAR, -- user_info.user_id 참조 + writer VARCHAR, + regdate TIMESTAMP +); +``` + +#### 3. `rel_menu_auth` - 메뉴 권한 매핑 + +```sql +CREATE TABLE rel_menu_auth ( + objid NUMERIC, + menu_objid NUMERIC, -- menu_info.objid 참조 + auth_objid NUMERIC, -- authority_master.objid 참조 + writer VARCHAR, + regdate TIMESTAMP, + create_yn VARCHAR, -- 생성 권한 (Y/N) + read_yn VARCHAR, -- 조회 권한 (Y/N) + update_yn VARCHAR, -- 수정 권한 (Y/N) + delete_yn VARCHAR -- 삭제 권한 (Y/N) +); +``` + +## 개선 사항 + +### 1. 회사별 권한 그룹 지원 + +**현재 문제점:** + +- `authority_master` 테이블에 `company_code` 컬럼이 없음 +- 모든 회사가 권한 그룹을 공유하게 됨 + +**해결 방안:** + +```sql +-- 마이그레이션 028 +ALTER TABLE authority_master ADD COLUMN company_code VARCHAR(20); +CREATE INDEX idx_authority_master_company ON authority_master(company_code); + +-- 기존 데이터 마이그레이션 (기본값 설정) +UPDATE authority_master SET company_code = 'ILSHIN' WHERE company_code IS NULL; +``` + +### 2. 권한 레벨과 권한 그룹의 차이 + +| 구분 | 권한 레벨 (userType) | 권한 그룹 (authority_master) | +| ---------- | -------------------------------- | ------------------------------ | +| **목적** | 시스템 레벨 권한 | 메뉴별 세부 권한 | +| **범위** | 전역 (시스템 전체) | 회사별 (회사 내부) | +| **관리자** | 최고 관리자 (SUPER_ADMIN) | 회사 관리자 (COMPANY_ADMIN) | +| **예시** | SUPER_ADMIN, COMPANY_ADMIN, USER | "영업팀", "개발팀", "관리자팀" | + +### 3. 2단계 권한 체계 + +``` +┌─────────────────────────────────────────────────────────────┐ +│ 1단계: 권한 레벨 (userType) │ +│ - SUPER_ADMIN: 모든 회사 관리, DDL 실행 │ +│ - COMPANY_ADMIN: 자기 회사 관리, 권한 그룹 생성 │ +│ - USER: 자기 회사 데이터 조회/수정 │ +└─────────────────────────────────────────────────────────────┘ + ↓ +┌─────────────────────────────────────────────────────────────┐ +│ 2단계: 권한 그룹 (authority_master) │ +│ - 회사 내부에서 메뉴별 세부 권한 설정 │ +│ - 생성(C), 조회(R), 수정(U), 삭제(D) 권한 제어 │ +└─────────────────────────────────────────────────────────────┘ +``` + +## 사용 시나리오 + +### 시나리오 1: 영업팀 권한 그룹 + +**요구사항:** + +- 영업팀은 고객 관리, 계약 관리 메뉴만 접근 가능 +- 고객 정보는 조회/수정 가능하지만 삭제 불가 +- 계약은 생성/조회/수정 가능 + +**구현:** + +```sql +-- 1. 권한 그룹 생성 +INSERT INTO authority_master (objid, auth_name, auth_code, company_code, status) +VALUES (nextval('seq_authority'), '영업팀 권한', 'SALES_TEAM', 'COMPANY_1', 'active'); + +-- 2. 사용자 추가 +INSERT INTO authority_sub_user (objid, master_objid, user_id) +VALUES + (nextval('seq_auth_sub'), 1, 'user1'), + (nextval('seq_auth_sub'), 1, 'user2'); + +-- 3. 메뉴 권한 설정 +-- 고객 관리 메뉴 +INSERT INTO rel_menu_auth (menu_objid, auth_objid, create_yn, read_yn, update_yn, delete_yn) +VALUES (100, 1, 'N', 'Y', 'Y', 'N'); + +-- 계약 관리 메뉴 +INSERT INTO rel_menu_auth (menu_objid, auth_objid, create_yn, read_yn, update_yn, delete_yn) +VALUES (101, 1, 'Y', 'Y', 'Y', 'N'); +``` + +### 시나리오 2: 개발팀 권한 그룹 + +**요구사항:** + +- 개발팀은 모든 기술 메뉴 접근 가능 +- 프로젝트, 코드 관리 메뉴는 모든 권한 보유 +- 시스템 설정은 조회만 가능 + +**구현:** + +```sql +-- 1. 권한 그룹 생성 +INSERT INTO authority_master (objid, auth_name, auth_code, company_code, status) +VALUES (nextval('seq_authority'), '개발팀 권한', 'DEV_TEAM', 'COMPANY_1', 'active'); + +-- 2. 메뉴 권한 설정 +INSERT INTO rel_menu_auth (menu_objid, auth_objid, create_yn, read_yn, update_yn, delete_yn) +VALUES + (200, 2, 'Y', 'Y', 'Y', 'Y'), -- 프로젝트 관리 (모든 권한) + (201, 2, 'Y', 'Y', 'Y', 'Y'), -- 코드 관리 (모든 권한) + (202, 2, 'N', 'Y', 'N', 'N'); -- 시스템 설정 (조회만) +``` + +## 구현 단계 + +### Phase 1: 데이터베이스 마이그레이션 + +- [ ] `authority_master`에 `company_code` 추가 +- [ ] 기존 데이터 마이그레이션 +- [ ] 인덱스 생성 + +### Phase 2: 백엔드 API + +- [ ] 권한 그룹 CRUD API + - `GET /api/admin/roles` - 회사별 권한 그룹 목록 + - `POST /api/admin/roles` - 권한 그룹 생성 + - `PUT /api/admin/roles/:id` - 권한 그룹 수정 + - `DELETE /api/admin/roles/:id` - 권한 그룹 삭제 +- [ ] 권한 그룹 멤버 관리 API + - `GET /api/admin/roles/:id/members` - 멤버 목록 + - `POST /api/admin/roles/:id/members` - 멤버 추가 + - `DELETE /api/admin/roles/:id/members/:userId` - 멤버 제거 +- [ ] 메뉴 권한 매핑 API + - `GET /api/admin/roles/:id/menu-permissions` - 메뉴 권한 목록 + - `PUT /api/admin/roles/:id/menu-permissions` - 메뉴 권한 설정 + +### Phase 3: 프론트엔드 UI + +- [ ] 권한 그룹 관리 페이지 (`/admin/roles`) + - 권한 그룹 목록 (회사별 필터링) + - 권한 그룹 생성/수정/삭제 +- [ ] 권한 그룹 상세 페이지 (`/admin/roles/:id`) + - 멤버 관리 (사용자 추가/제거) + - 메뉴 권한 설정 (CRUD 권한 토글) +- [ ] 사용자 관리 페이지 연동 + - 사용자별 권한 그룹 할당 + +### Phase 4: 권한 체크 로직 + +- [ ] 미들웨어 개선 + - 권한 레벨 체크 (기존) + - 권한 그룹 체크 (신규) + - 메뉴별 CRUD 권한 체크 (신규) +- [ ] 프론트엔드 가드 + - 메뉴 표시/숨김 + - 버튼 활성화/비활성화 + +## 권한 체크 플로우 + +``` +사용자 요청 + ↓ +1. 인증 체크 (로그인 여부) + ↓ +2. 권한 레벨 체크 (userType) + - SUPER_ADMIN: 모든 접근 허용 + - COMPANY_ADMIN: 자기 회사만 + - USER: 권한 그룹 체크로 이동 + ↓ +3. 권한 그룹 체크 (authority_sub_user) + - 사용자가 속한 권한 그룹 조회 + ↓ +4. 메뉴 권한 체크 (rel_menu_auth) + - 요청한 메뉴에 대한 권한 확인 + - CRUD 권한 체크 + ↓ +5. 접근 허용/거부 +``` + +## 예상 UI 구조 + +### 권한 그룹 관리 페이지 + +``` +┌─────────────────────────────────────────────────────────┐ +│ 권한 그룹 관리 │ +├─────────────────────────────────────────────────────────┤ +│ [회사 선택: COMPANY_1 ▼] [검색: ____] [+ 그룹 생성] │ +├─────────────────────────────────────────────────────────┤ +│ ┌───────────────┬──────────┬──────────┬────────┐ │ +│ │ 권한 그룹명 │ 코드 │ 멤버 수 │ 액션 │ │ +│ ├───────────────┼──────────┼──────────┼────────┤ │ +│ │ 영업팀 권한 │ SALES │ 5명 │ [수정] │ │ +│ │ 개발팀 권한 │ DEV │ 8명 │ [수정] │ │ +│ │ 관리자팀 │ ADMIN │ 2명 │ [수정] │ │ +│ └───────────────┴──────────┴──────────┴────────┘ │ +└─────────────────────────────────────────────────────────┘ +``` + +### 권한 그룹 상세 페이지 + +``` +┌─────────────────────────────────────────────────────────┐ +│ 영업팀 권한 (SALES_TEAM) │ +├─────────────────────────────────────────────────────────┤ +│ 【 멤버 관리 】 │ +│ [+ 멤버 추가] │ +│ ┌──────────┬──────────┬────────┐ │ +│ │ 사용자 ID │ 이름 │ 액션 │ │ +│ ├──────────┼──────────┼────────┤ │ +│ │ user1 │ 김철수 │ [제거] │ │ +│ │ user2 │ 이영희 │ [제거] │ │ +│ └──────────┴──────────┴────────┘ │ +├─────────────────────────────────────────────────────────┤ +│ 【 메뉴 권한 설정 】 │ +│ ┌─────────────┬────┬────┬────┬────┐ │ +│ │ 메뉴 │ 생성│ 조회│ 수정│ 삭제│ │ +│ ├─────────────┼────┼────┼────┼────┤ │ +│ │ 고객 관리 │ □ │ ☑ │ ☑ │ □ │ │ +│ │ 계약 관리 │ ☑ │ ☑ │ ☑ │ □ │ │ +│ │ 매출 분석 │ □ │ ☑ │ □ │ □ │ │ +│ └─────────────┴────┴────┴────┴────┘ │ +│ [저장] [취소] │ +└─────────────────────────────────────────────────────────┘ +``` + +## 마이그레이션 계획 + +### 028_add_company_code_to_authority_master.sql + +```sql +-- 권한 그룹 테이블에 회사 코드 추가 +ALTER TABLE authority_master ADD COLUMN IF NOT EXISTS company_code VARCHAR(20); + +-- 인덱스 생성 +CREATE INDEX IF NOT EXISTS idx_authority_master_company ON authority_master(company_code); + +-- 기존 데이터 마이그레이션 +UPDATE authority_master +SET company_code = 'ILSHIN' +WHERE company_code IS NULL; + +-- NOT NULL 제약 조건 추가 +ALTER TABLE authority_master ALTER COLUMN company_code SET NOT NULL; +ALTER TABLE authority_master ALTER COLUMN company_code SET DEFAULT 'ILSHIN'; + +-- 주석 추가 +COMMENT ON COLUMN authority_master.company_code IS '회사 코드 (회사별 권한 그룹 격리)'; +``` + +## 참고 사항 + +### 권한 우선순위 + +1. **SUPER_ADMIN**: 모든 권한 (권한 그룹 체크 생략) +2. **COMPANY_ADMIN**: 회사 내 모든 권한 (권한 그룹 체크 생략) +3. **USER**: 권한 그룹에 따른 메뉴별 권한 + +### 권한 그룹 vs 권한 레벨 + +- **권한 레벨**: 사용자 등록 시 최초 1회 설정 (최고 관리자가 변경) +- **권한 그룹**: 회사 관리자가 자유롭게 생성/관리, 사용자는 여러 그룹에 속할 수 있음 + +### 보안 고려사항 + +- 회사 관리자는 자기 회사의 권한 그룹만 관리 가능 +- 최고 관리자는 모든 회사의 권한 그룹 관리 가능 +- 권한 그룹 삭제 시 연결된 사용자/메뉴 권한도 함께 삭제 (CASCADE) + +## 다음 단계 + +1. **마이그레이션 028 실행** → `company_code` 추가 +2. **백엔드 API 개발** → 권한 그룹 CRUD +3. **프론트엔드 UI 개발** → 권한 그룹 관리 페이지 +4. **권한 체크 로직 통합** → 미들웨어 개선 + +이 설계를 구현하시겠습니까? diff --git a/docs/권한_시스템_마이그레이션_완료.md b/docs/권한_시스템_마이그레이션_완료.md new file mode 100644 index 00000000..cc1547c8 --- /dev/null +++ b/docs/권한_시스템_마이그레이션_완료.md @@ -0,0 +1,307 @@ +# 권한 시스템 마이그레이션 완료 보고서 + +## 실행 완료 ✅ + +날짜: 2025-10-27 +대상 데이터베이스: `plm` (39.117.244.52:11132) + +--- + +## 실행된 마이그레이션 + +### 1. **028_add_company_code_to_authority_master.sql** ✅ + +**목적**: 권한 그룹 시스템 개선 (회사별 격리) + +**주요 변경사항**: + +- `authority_master.company_code` 컬럼 추가 (회사별 권한 그룹 격리) +- 외래 키 제약 조건 추가 (`authority_sub_user` ↔ `authority_master`, `user_info`) +- 권한 요약 뷰 생성 (`v_authority_group_summary`) +- 유틸리티 함수 생성 (`get_user_authority_groups`) + +### 2. **031_add_menu_auth_columns.sql** ✅ + +**목적**: 메뉴 기반 권한 시스템 개선 (동적 화면 대응) + +**주요 변경사항**: + +- `menu_info.screen_code`, `menu_info.menu_code` 컬럼 추가 +- `rel_menu_auth.execute_yn`, `rel_menu_auth.export_yn` 컬럼 추가 +- 화면 생성 시 자동 메뉴 추가 트리거 (`auto_create_menu_for_screen`) +- 화면 삭제 시 자동 메뉴 비활성화 트리거 (`auto_deactivate_menu_for_screen`) +- 권한 체크 함수 (`check_menu_crud_permission`) +- 사용자 메뉴 조회 함수 (`get_user_menus_with_permissions`) +- 권한 요약 뷰 (`v_menu_permission_summary`) + +--- + +## 현재 데이터베이스 구조 + +### 1. 권한 그룹 시스템 + +#### `authority_master` (권한 그룹) + +``` +objid | NUMERIC | 권한 그룹 ID (PK) +auth_name | VARCHAR(50) | 권한 그룹 이름 +auth_code | VARCHAR(50) | 권한 그룹 코드 +company_code | VARCHAR(20) | 회사 코드 ⭐ (회사별 격리) +status | VARCHAR(20) | 활성/비활성 +``` + +#### `authority_sub_user` (권한 그룹 멤버) + +``` +master_objid | NUMERIC | 권한 그룹 ID (FK) +user_id | VARCHAR(50) | 사용자 ID (FK) +``` + +#### 현재 권한 그룹 현황 + +- COMPANY_1: 2개 그룹 +- COMPANY_2: 2개 그룹 +- COMPANY_3: 7개 그룹 +- COMPANY_4: 2개 그룹 +- ILSHIN: 3개 그룹 + +### 2. 메뉴 권한 시스템 + +#### `menu_info` (메뉴 정보) + +``` +objid | NUMERIC | 메뉴 ID (PK) +menu_name_kor | VARCHAR(64) | 메뉴 이름 (한글) +menu_name_eng | VARCHAR(64) | 메뉴 이름 (영어) +menu_code | VARCHAR(50) | 메뉴 코드 ⭐ (신규) +menu_url | VARCHAR(256) | 메뉴 URL +menu_type | NUMERIC | 메뉴 타입 (0=일반, 1=시스템, 2=동적생성 ⭐) +screen_code | VARCHAR(50) | 화면 코드 ⭐ (동적 메뉴 연동) +company_code | VARCHAR(50) | 회사 코드 +parent_obj_id | NUMERIC | 부모 메뉴 ID +seq | NUMERIC | 정렬 순서 +status | VARCHAR(32) | 상태 +``` + +#### `rel_menu_auth` (메뉴별 권한) + +``` +menu_objid | NUMERIC | 메뉴 ID (FK) +auth_objid | NUMERIC | 권한 그룹 ID (FK) +create_yn | VARCHAR(50) | 생성 권한 +read_yn | VARCHAR(50) | 읽기 권한 +update_yn | VARCHAR(50) | 수정 권한 +delete_yn | VARCHAR(50) | 삭제 권한 +execute_yn | CHAR(1) | 실행 권한 ⭐ (신규) +export_yn | CHAR(1) | 내보내기 권한 ⭐ (신규) +``` + +--- + +## 자동화 기능 + +### 1. 화면 생성 시 자동 메뉴 추가 🤖 + +```sql +-- 사용자가 화면 생성 +INSERT INTO screen_definitions (screen_name, screen_code, company_code, ...) +VALUES ('계약 관리', 'SCR_CONTRACT', 'ILSHIN', ...); + +-- ↓ 트리거 자동 실행 ↓ + +-- menu_info에 자동 추가됨! +-- menu_type = 2 (동적 생성) +-- screen_code = 'SCR_CONTRACT' +-- menu_url = '/screen/SCR_CONTRACT' +``` + +### 2. 화면 삭제 시 자동 메뉴 비활성화 🤖 + +```sql +-- 화면 삭제 +UPDATE screen_definitions +SET is_active = 'D' +WHERE screen_code = 'SCR_CONTRACT'; + +-- ↓ 트리거 자동 실행 ↓ + +-- 메뉴 비활성화됨! +UPDATE menu_info +SET status = 'inactive' +WHERE screen_code = 'SCR_CONTRACT'; +``` + +--- + +## 사용 가이드 + +### 1. 권한 그룹 생성 + +```sql +-- 예: ILSHIN 회사의 "개발팀" 권한 그룹 생성 +INSERT INTO authority_master (objid, auth_name, auth_code, company_code, status, writer, regdate) +VALUES (nextval('seq_authority_master'), '개발팀', 'DEV_TEAM', 'ILSHIN', 'active', 'admin', NOW()); +``` + +### 2. 권한 그룹에 멤버 추가 + +```sql +-- 예: '개발팀'에 사용자 'dev1' 추가 +INSERT INTO authority_sub_user (master_objid, user_id) +VALUES ( + (SELECT objid FROM authority_master WHERE auth_code = 'DEV_TEAM' AND company_code = 'ILSHIN'), + 'dev1' +); +``` + +### 3. 메뉴 권한 설정 + +```sql +-- 예: '개발팀'에게 특정 메뉴의 CRUD 권한 부여 +INSERT INTO rel_menu_auth (menu_objid, auth_objid, create_yn, read_yn, update_yn, delete_yn, execute_yn, export_yn, writer) +VALUES ( + 1005, -- 메뉴 ID + (SELECT objid FROM authority_master WHERE auth_code = 'DEV_TEAM' AND company_code = 'ILSHIN'), + 'Y', 'Y', 'Y', 'Y', 'Y', 'N', -- CRUD + Execute 권한 + 'admin' +); +``` + +### 4. 사용자 권한 확인 + +```sql +-- 예: 'dev1' 사용자가 메뉴 1005를 수정할 수 있는지 확인 +SELECT check_menu_crud_permission('dev1', 1005, 'update'); +-- 결과: TRUE 또는 FALSE + +-- 예: 'dev1' 사용자가 접근 가능한 모든 메뉴 조회 +SELECT * FROM get_user_menus_with_permissions('dev1', 'ILSHIN'); +``` + +--- + +## 다음 단계 + +### 1. 백엔드 API 구현 + +**필요한 API**: + +- `GET /api/roles/:id/menu-permissions` - 권한 그룹의 메뉴 권한 조회 +- `POST /api/roles/:id/menu-permissions` - 메뉴 권한 설정 +- `GET /api/users/menus` - 현재 사용자가 접근 가능한 메뉴 목록 +- `POST /api/menu-permissions/check` - 특정 메뉴에 대한 권한 확인 + +**구현 파일**: + +- `backend-node/src/services/RoleService.ts` +- `backend-node/src/controllers/roleController.ts` +- `backend-node/src/middleware/permissionMiddleware.ts` + +### 2. 프론트엔드 UI 개발 + +**필요한 페이지/컴포넌트**: + +1. **권한 그룹 상세 페이지** (`/admin/roles/[id]`) + + - 기본 정보 (이름, 코드, 회사) + - 멤버 관리 (Dual List Box) ✅ 이미 구현됨 + - **메뉴 권한 설정** (체크박스 그리드) ⬅️ 신규 개발 필요 + +2. **메뉴 권한 설정 그리드** + + ``` + ┌─────────────────┬────────┬────────┬────────┬────────┬────────┬────────┐ + │ 메뉴 │ 생성 │ 읽기 │ 수정 │ 삭제 │ 실행 │ 내보내기│ + ├─────────────────┼────────┼────────┼────────┼────────┼────────┼────────┤ + │ 대시보드 │ ☐ │ ☑ │ ☐ │ ☐ │ ☐ │ ☐ │ + │ 계약 관리 │ ☑ │ ☑ │ ☑ │ ☐ │ ☐ │ ☑ │ + │ 사용자 관리 │ ☐ │ ☑ │ ☐ │ ☐ │ ☐ │ ☐ │ + └─────────────────┴────────┴────────┴────────┴────────┴────────┴────────┘ + ``` + +3. **네비게이션 메뉴** (사용자별 권한 필터링) + + - `get_user_menus_with_permissions` 함수 활용 + - 읽기 권한이 있는 메뉴만 표시 + +4. **버튼/액션 권한 제어** + - 생성 버튼: `can_create` + - 수정 버튼: `can_update` + - 삭제 버튼: `can_delete` + - 실행 버튼: `can_execute` (플로우, DDL) + - 내보내기 버튼: `can_export` + +**구현 파일**: + +- `frontend/components/admin/RoleDetailManagement.tsx` (메뉴 권한 탭 추가) +- `frontend/components/admin/MenuPermissionGrid.tsx` (신규) +- `frontend/lib/api/role.ts` (메뉴 권한 API 추가) +- `frontend/hooks/useMenuPermission.ts` (신규) + +### 3. 테스트 시나리오 + +**시나리오 1: 영업팀 권한 설정** + +1. 영업팀 권한 그룹 생성 +2. 멤버 추가 (3명) +3. 메뉴 권한 설정: + - 대시보드: 읽기만 + - 계약 관리: CRUD + 내보내기 + - 플로우 관리: 읽기 + 실행 +4. 영업팀 사용자로 로그인하여 검증 + +**시나리오 2: 동적 화면 생성 및 권한 설정** + +1. "배송 현황" 화면 생성 +2. 자동으로 메뉴 추가 확인 +3. 영업팀에게 읽기 권한 부여 +4. 영업팀 사용자 로그인하여 메뉴 표시 확인 + +--- + +## 주의사항 + +### 1. 기존 데이터 호환성 + +- 기존 `menu_info` 테이블 구조는 그대로 유지 +- 새로운 컬럼만 추가되어 기존 데이터에 영향 없음 + +### 2. 권한 타입 매핑 + +- `menu_type`이 `numeric`에서 `VARCHAR`로 변경되지 않음 (기존 구조 유지) +- `menu_type = 2`가 동적 생성 메뉴를 의미 + +### 3. 데이터 마이그레이션 불필요 + +- 기존 권한 데이터는 그대로 유지 +- 새로운 권한 그룹은 수동으로 설정 필요 + +--- + +## 검증 체크리스트 + +- [x] `authority_master.company_code` 컬럼 존재 확인 +- [x] `menu_info.screen_code`, `menu_info.menu_code` 컬럼 존재 확인 +- [x] `rel_menu_auth.execute_yn`, `rel_menu_auth.export_yn` 컬럼 존재 확인 +- [x] 트리거 함수 생성 확인 (`auto_create_menu_for_screen`, `auto_deactivate_menu_for_screen`) +- [x] 권한 체크 함수 생성 확인 (`check_menu_crud_permission`) +- [x] 사용자 메뉴 조회 함수 생성 확인 (`get_user_menus_with_permissions`) +- [x] 권한 요약 뷰 생성 확인 (`v_menu_permission_summary`) +- [ ] 백엔드 API 구현 +- [ ] 프론트엔드 UI 구현 +- [ ] 테스트 시나리오 실행 + +--- + +## 관련 문서 + +- `docs/메뉴_기반_권한_시스템_가이드.md` - 사용자 가이드 +- `docs/권한_체계_가이드.md` - 3단계 권한 체계 개요 +- `db/migrations/028_add_company_code_to_authority_master.sql` - 권한 그룹 마이그레이션 +- `db/migrations/031_add_menu_auth_columns.sql` - 메뉴 권한 마이그레이션 + +--- + +## 문의사항 + +기술적 문의사항이나 추가 기능 요청은 개발팀에 문의하세요. diff --git a/docs/권한_체계_가이드.md b/docs/권한_체계_가이드.md new file mode 100644 index 00000000..8e954523 --- /dev/null +++ b/docs/권한_체계_가이드.md @@ -0,0 +1,589 @@ +# 3단계 권한 체계 가이드 + +## 📋 목차 + +1. [권한 체계 개요](#권한-체계-개요) +2. [권한 레벨 상세](#권한-레벨-상세) +3. [데이터베이스 설정](#데이터베이스-설정) +4. [백엔드 구현](#백엔드-구현) +5. [프론트엔드 구현](#프론트엔드-구현) +6. [실무 예제](#실무-예제) +7. [FAQ](#faq) + +--- + +## 권한 체계 개요 + +### 3단계 권한 구조 + +``` +┌────────────────────┬──────────────┬─────────────────┬────────────────────────┐ +│ 권한 레벨 │ company_code │ user_type │ 접근 범위 │ +├────────────────────┼──────────────┼─────────────────┼────────────────────────┤ +│ 최고 관리자 │ * │ SUPER_ADMIN │ ✅ 전체 회사 데이터 │ +│ (Super Admin) │ │ │ ✅ DDL 실행 권한 │ +│ │ │ │ ✅ 회사 생성/삭제 │ +│ │ │ │ ✅ 시스템 설정 │ +├────────────────────┼──────────────┼─────────────────┼────────────────────────┤ +│ 회사 관리자 │ 20 │ COMPANY_ADMIN │ ✅ 자기 회사 데이터 │ +│ (Company Admin) │ │ │ ✅ 회사 사용자 관리 │ +│ │ │ │ ✅ 회사 설정 변경 │ +│ │ │ │ ❌ DDL 실행 불가 │ +│ │ │ │ ❌ 타회사 접근 불가 │ +├────────────────────┼──────────────┼─────────────────┼────────────────────────┤ +│ 일반 사용자 │ 20 │ USER │ ✅ 자기 회사 데이터 │ +│ (User) │ │ │ ❌ 사용자 관리 불가 │ +│ │ │ │ ❌ 설정 변경 불가 │ +└────────────────────┴──────────────┴─────────────────┴────────────────────────┘ +``` + +### 핵심 원칙 + +1. **company_code = "\*"** → 전체 시스템 접근 (슈퍼관리자 전용) +2. **company_code = "특정코드"** → 해당 회사만 접근 +3. **user_type** → 회사 내 권한 레벨 결정 + +--- + +## 권한 레벨 상세 + +### 1️⃣ 슈퍼관리자 (SUPER_ADMIN) + +**조건:** + +- `company_code = '*'` +- `user_type = 'SUPER_ADMIN'` + +**권한:** + +- ✅ 모든 회사 데이터 조회/수정 +- ✅ DDL 실행 (CREATE TABLE, ALTER TABLE 등) +- ✅ 회사 생성/삭제 +- ✅ 시스템 설정 변경 +- ✅ 모든 사용자 관리 +- ✅ 코드 관리, 템플릿 관리 등 전역 설정 + +**사용 사례:** + +- 시스템 전체 관리자 +- 데이터베이스 스키마 변경 +- 새로운 회사 추가 +- 전사 공통 설정 관리 + +**계정 예시:** + +```sql +INSERT INTO user_info (user_id, user_name, company_code, user_type) +VALUES ('super_admin', '시스템 관리자', '*', 'SUPER_ADMIN'); +``` + +--- + +### 2️⃣ 회사 관리자 (COMPANY_ADMIN) + +**조건:** + +- `company_code = '특정 회사 코드'` (예: '20') +- `user_type = 'COMPANY_ADMIN'` + +**권한:** + +- ✅ 자기 회사 데이터 조회/수정 +- ✅ 자기 회사 사용자 관리 (추가/수정/삭제) +- ✅ 자기 회사 설정 변경 +- ✅ 자기 회사 대시보드/화면 관리 +- ❌ DDL 실행 불가 +- ❌ 타 회사 데이터 접근 불가 +- ❌ 시스템 전역 설정 변경 불가 + +**사용 사례:** + +- 각 회사의 IT 관리자 +- 회사 내 사용자 계정 관리 +- 회사별 커스터마이징 설정 + +**계정 예시:** + +```sql +INSERT INTO user_info (user_id, user_name, company_code, user_type) +VALUES ('company_admin_20', '회사20 관리자', '20', 'COMPANY_ADMIN'); +``` + +--- + +### 3️⃣ 일반 사용자 (USER) + +**조건:** + +- `company_code = '특정 회사 코드'` (예: '20') +- `user_type = 'USER'` + +**권한:** + +- ✅ 자기 회사 데이터 조회/수정 +- ✅ 자신이 만든 화면/대시보드 관리 +- ❌ 사용자 관리 불가 +- ❌ 회사 설정 변경 불가 +- ❌ 타 회사 데이터 접근 불가 + +**사용 사례:** + +- 일반 업무 사용자 +- 데이터 입력/조회 +- 개인 대시보드 생성 + +**계정 예시:** + +```sql +INSERT INTO user_info (user_id, user_name, company_code, user_type) +VALUES ('user_kim', '김철수', '20', 'USER'); +``` + +--- + +## 데이터베이스 설정 + +### 마이그레이션 실행 + +```bash +# 권한 체계 마이그레이션 실행 +psql -U postgres -d your_database -f db/migrations/026_add_user_type_hierarchy.sql +``` + +### 주요 변경사항 + +1. **코드 테이블 업데이트:** + + - `ADMIN` → `COMPANY_ADMIN` 으로 변경 + - `SUPER_ADMIN` 신규 추가 + +2. **PostgreSQL 함수 추가:** + + - `is_super_admin(user_id)` - 슈퍼관리자 확인 + - `is_company_admin(user_id, company_code)` - 회사 관리자 확인 + - `can_access_company_data(user_id, company_code)` - 데이터 접근 권한 + +3. **권한 뷰 생성:** + - `v_user_permissions` - 사용자별 권한 요약 + +--- + +## 백엔드 구현 + +### 1. 권한 체크 유틸리티 사용 + +```typescript +import { + isSuperAdmin, + isCompanyAdmin, + isAdmin, + canExecuteDDL, + canAccessCompanyData, + canManageUsers, +} from "../utils/permissionUtils"; + +// 슈퍼관리자 확인 +if (isSuperAdmin(req.user)) { + // 전체 데이터 조회 +} + +// 회사 데이터 접근 권한 확인 +if (canAccessCompanyData(req.user, targetCompanyCode)) { + // 해당 회사 데이터 조회 +} + +// 사용자 관리 권한 확인 +if (canManageUsers(req.user, targetCompanyCode)) { + // 사용자 추가/수정/삭제 +} +``` + +### 2. 미들웨어 사용 + +```typescript +import { + requireSuperAdmin, + requireAdmin, + requireCompanyAccess, + requireUserManagement, + requireDDLPermission, +} from "../middleware/permissionMiddleware"; + +// 슈퍼관리자 전용 엔드포인트 +router.post( + "/api/admin/ddl/execute", + authenticate, + requireDDLPermission, + ddlController.execute +); + +// 관리자 전용 엔드포인트 (슈퍼관리자 + 회사관리자) +router.get( + "/api/admin/users", + authenticate, + requireAdmin, + userController.getUserList +); + +// 회사 데이터 접근 체크 +router.get( + "/api/data/:companyCode/orders", + authenticate, + requireCompanyAccess, + orderController.getOrders +); + +// 사용자 관리 권한 체크 +router.post( + "/api/admin/users/:companyCode", + authenticate, + requireUserManagement, + userController.createUser +); +``` + +### 3. 서비스 레이어 구현 + +```typescript +// ❌ 잘못된 방법 - 하드코딩된 회사 코드 +async getOrders(companyCode: string) { + return query("SELECT * FROM orders WHERE company_code = $1", [companyCode]); +} + +// ✅ 올바른 방법 - 권한 체크 포함 +async getOrders(user: PersonBean, companyCode: string) { + // 권한 확인 + if (!canAccessCompanyData(user, companyCode)) { + throw new Error("해당 회사 데이터에 접근할 권한이 없습니다."); + } + + // 슈퍼관리자는 모든 데이터 조회 가능 + if (isSuperAdmin(user)) { + if (companyCode === "*") { + return query("SELECT * FROM orders"); // 전체 조회 + } + } + + // 일반 사용자/회사 관리자는 자기 회사만 + return query("SELECT * FROM orders WHERE company_code = $1", [companyCode]); +} +``` + +--- + +## 프론트엔드 구현 + +### 1. 사용자 타입 정의 + +```typescript +// frontend/types/user.ts +export interface UserInfo { + userId: string; + userName: string; + companyCode: string; + userType: string; // 'SUPER_ADMIN' | 'COMPANY_ADMIN' | 'USER' + isSuperAdmin?: boolean; + isCompanyAdmin?: boolean; + isAdmin?: boolean; +} +``` + +### 2. 권한 기반 UI 렌더링 + +```tsx +import { useAuth } from "@/hooks/useAuth"; + +function AdminPanel() { + const { user } = useAuth(); + + return ( +
+ {/* 슈퍼관리자만 표시 */} + {user?.isSuperAdmin && ( + + )} + + {/* 관리자만 표시 (슈퍼관리자 + 회사관리자) */} + {user?.isAdmin && ( + + )} + + {/* 모든 사용자 표시 */} + +
+ ); +} +``` + +### 3. 권한 체크 Hook + +```typescript +// frontend/hooks/usePermissions.ts +export function usePermissions() { + const { user } = useAuth(); + + return { + isSuperAdmin: user?.isSuperAdmin ?? false, + isCompanyAdmin: user?.isCompanyAdmin ?? false, + isAdmin: user?.isAdmin ?? false, + canExecuteDDL: user?.isSuperAdmin ?? false, + canManageUsers: user?.isAdmin ?? false, + canAccessCompany: (companyCode: string) => { + if (user?.isSuperAdmin) return true; + return user?.companyCode === companyCode; + }, + }; +} + +// 사용 예시 +function DataTable({ companyCode }: { companyCode: string }) { + const { canAccessCompany } = usePermissions(); + + if (!canAccessCompany(companyCode)) { + return
접근 권한이 없습니다.
; + } + + return ; +} +``` + +--- + +## 실무 예제 + +### 예제 1: 주문 데이터 조회 + +**시나리오:** + +- 슈퍼관리자: 모든 회사의 주문 조회 +- 회사20 관리자: 회사20의 주문만 조회 +- 회사20 사용자: 회사20의 주문만 조회 + +**백엔드 구현:** + +```typescript +// orders.service.ts +export class OrderService { + async getOrders(user: PersonBean, companyCode?: string) { + let sql = "SELECT * FROM orders WHERE 1=1"; + const params: any[] = []; + + // 슈퍼관리자가 아닌 경우 회사 필터 적용 + if (!isSuperAdmin(user)) { + sql += " AND company_code = $1"; + params.push(user.companyCode); + } else if (companyCode && companyCode !== "*") { + // 슈퍼관리자가 특정 회사를 지정한 경우 + sql += " AND company_code = $1"; + params.push(companyCode); + } + + return query(sql, params); + } +} +``` + +**프론트엔드 구현:** + +```tsx +function OrderList() { + const { user } = useAuth(); + const [selectedCompany, setSelectedCompany] = useState(user?.companyCode); + + // 슈퍼관리자는 회사 선택 가능 + const showCompanySelector = user?.isSuperAdmin; + + return ( +
+ {showCompanySelector && ( + + )} + + +
+ ); +} +``` + +--- + +### 예제 2: 사용자 관리 + +**시나리오:** + +- 슈퍼관리자: 모든 회사의 사용자 관리 +- 회사20 관리자: 회사20 사용자만 관리 +- 회사20 사용자: 사용자 관리 불가 + +**백엔드 구현:** + +```typescript +// users.controller.ts +router.post("/api/admin/users", authenticate, async (req, res) => { + const { companyCode, userId, userName } = req.body; + + // 권한 확인 + if (!canManageUsers(req.user, companyCode)) { + return res.status(403).json({ + success: false, + error: "사용자 관리 권한이 없습니다.", + }); + } + + // 슈퍼관리자가 아닌 경우, 자기 회사만 가능 + if (!isSuperAdmin(req.user) && companyCode !== req.user.companyCode) { + return res.status(403).json({ + success: false, + error: "다른 회사의 사용자를 생성할 수 없습니다.", + }); + } + + // 사용자 생성 + await UserService.createUser({ companyCode, userId, userName }); + + res.json({ success: true }); +}); +``` + +--- + +### 예제 3: DDL 실행 (테이블 생성) + +**시나리오:** + +- 슈퍼관리자만 DDL 실행 가능 +- 다른 모든 사용자는 차단 + +**백엔드 구현:** + +```typescript +// ddl.controller.ts +router.post( + "/api/admin/ddl/execute", + authenticate, + requireDDLPermission, // 슈퍼관리자 체크 미들웨어 + async (req, res) => { + const { sql } = req.body; + + // 추가 보안 검증 + if (!canExecuteDDL(req.user)) { + return res.status(403).json({ + success: false, + error: "DDL 실행 권한이 없습니다.", + }); + } + + // DDL 실행 + await query(sql); + + // 감사 로그 기록 + await AuditService.logDDL({ + userId: req.user.userId, + sql, + timestamp: new Date(), + }); + + res.json({ success: true }); + } +); +``` + +**프론트엔드 구현:** + +```tsx +function DDLExecutor() { + const { user } = useAuth(); + + // 슈퍼관리자가 아니면 컴포넌트 자체를 숨김 + if (!user?.isSuperAdmin) { + return null; + } + + return ( +
+

DDL 실행 (슈퍼관리자 전용)

+