diff --git a/pom.xml b/pom.xml index 40ec706..f83ad52 100644 --- a/pom.xml +++ b/pom.xml @@ -551,19 +551,42 @@ - mysql mysql-connector-java 5.1.17 - --> - + + diff --git a/src/main/java/egovframework/com/cmm/privacy/auditlog/AccessLogQueue.java b/src/main/java/egovframework/com/cmm/privacy/auditlog/AccessLogQueue.java index 22802ec..c651bb5 100644 --- a/src/main/java/egovframework/com/cmm/privacy/auditlog/AccessLogQueue.java +++ b/src/main/java/egovframework/com/cmm/privacy/auditlog/AccessLogQueue.java @@ -19,7 +19,7 @@ import org.springframework.stereotype.Component; * 개인정보 접속기록 비동기 적재 큐 (개발항목 #33, SFR-004 / DBR-001). * *

목적: 접속기록 적재(직전 해시 조회 → 봉인 → INSERT)를 업무 트랜잭션과 분리해 - * 개인정보 처리 서비스의 응답시간에 영향을 주지 않게 한다({@link PersonalDataLogAspect}는 + * 개인정보 처리 서비스의 응답시간에 영향을 주지 않게 한다(수집기는 * {@link #enqueue}만 호출하고 즉시 반환).

* *

순번·해시체인 무결성(#39): 컨슈머는 단일 스레드다. 큐에서 하나씩 꺼내 diff --git a/src/main/java/egovframework/com/cmm/privacy/auditlog/JobMappingController.java b/src/main/java/egovframework/com/cmm/privacy/auditlog/JobMappingController.java deleted file mode 100644 index 48cb873..0000000 --- a/src/main/java/egovframework/com/cmm/privacy/auditlog/JobMappingController.java +++ /dev/null @@ -1,267 +0,0 @@ -package egovframework.com.cmm.privacy.auditlog; - -import java.net.URLEncoder; -import java.util.List; - -import javax.annotation.Resource; -import javax.servlet.http.HttpServletRequest; - -import org.springframework.stereotype.Controller; -import org.springframework.ui.ModelMap; -import org.springframework.validation.BindingResult; -import org.springframework.web.bind.annotation.ModelAttribute; -import org.springframework.web.bind.annotation.RequestMapping; -import org.springframework.web.bind.annotation.RequestParam; - -import egovframework.com.cmm.LoginVO; -import egovframework.com.cmm.util.EgovUserDetailsHelper; -import egovframework.rte.ptl.mvc.tags.ui.pagination.PaginationInfo; - -/** - * 업무유형 매핑 관리 컨트롤러 — 개인정보보호 기능개선 사업(SFR-004 / SER-002 / #32-1). - * - *

{@code PDAL_JOB_MAPPING}(#32) 을 화면에서 등록·수정·사용중지·삭제한다. 이 매핑은 - * 접속기록 수집 Aspect(#31)가 (서비스 클래스, 메서드)를 업무코드·업무명으로 분류하는 기준이며, - * 접속기록 조회 화면(#47)의 '수행업무' 검색 드롭다운 소스이기도 하다.

- * - *

화면 경로 {@code /WEB-INF/jsp/site/mnt/sec/jobMapping/list.jsp}, {@code regist.jsp}

- * - *

수집 범위와의 관계(오해 주의): 매핑을 지우거나 사용중지해도 수집이 멈추지는 않는다. - * 수집 대상은 {@code context-syslogaop.xml} 의 pointcut 이 정하고, 이 테이블은 분류만 담당한다. - * 매핑이 없으면 메서드명으로 유도한 기본 분류로 적재된다.

- * - *

접근통제: 인터셉터 3단 중 ①인증판정이 비활성이고 ②③은 fail-open 이라 - * /mnt 하위라도 무인증 노출이 가능하므로, {@link #handlerUser()} 로 화면·엔드포인트 - * 단에서 직접 판정한다(PersonalDataAccessLogController 와 동일 방식).

- * - *
- * << 개정이력(Modification Information) >>
- *   수정일        수정자   수정내용
- *  ----------    ------  ---------------------------
- *  2026.08.07    URP     최초 생성 (개인정보보호 SFR-004 #32-1)
- * 
- */ -@Controller -public class JobMappingController { - - private static final String LOGIN_REDIRECT = "redirect:/uat/uia/egovLoginUsr.do"; - private static final String LIST_REDIRECT = "redirect:/mnt/sec/jobMapping/listView.do?page=9100045"; - - @Resource(name = "jobMappingManageService") - private JobMappingManageService jobMappingManageService; - - /** - * 업무유형 매핑 목록 화면(서버 렌더링). - * 뷰: /WEB-INF/jsp/site/mnt/sec/jobMapping/list.jsp - */ - @RequestMapping(value = "/mnt/sec/jobMapping/listView.do") - public String listView(@ModelAttribute("jobMappingSearchVO") JobMappingSearchVO searchVO, - @RequestParam(value = "resultMsg", required = false) String resultMsg, - ModelMap model) throws Exception { - if (handlerUser() == null) { - return LOGIN_REDIRECT; - } - - // selectJobMappingManageList 내부에서 firstIndex/totalRecordCount 를 세팅한다. - List list = jobMappingManageService.selectJobMappingManageList(searchVO); - - PaginationInfo paginationInfo = new PaginationInfo(); - paginationInfo.setCurrentPageNo(searchVO.getPageIndex()); - paginationInfo.setRecordCountPerPage(searchVO.getRecordCountPerPage()); - paginationInfo.setPageSize(10); - paginationInfo.setTotalRecordCount(searchVO.getTotalRecordCount()); - - model.addAttribute("resultList", list); - model.addAttribute("paginationInfo", paginationInfo); - model.addAttribute("resultMsg", resultMsg); - return "site/mnt/sec/jobMapping/list"; - } - - /** - * 등록·수정 폼 화면. mappingId 가 있으면 수정, 없으면 등록. - * 뷰: /WEB-INF/jsp/site/mnt/sec/jobMapping/regist.jsp - */ - @RequestMapping(value = "/mnt/sec/jobMapping/registView.do") - public String registView(@RequestParam(value = "mappingId", required = false) Long mappingId, - ModelMap model) throws Exception { - if (handlerUser() == null) { - return LOGIN_REDIRECT; - } - - JobMappingVO vo = null; - if (mappingId != null && mappingId.longValue() > 0) { - vo = jobMappingManageService.selectJobMapping(mappingId.longValue()); - if (vo == null) { - return listRedirect("대상 매핑이 존재하지 않습니다."); - } - } - if (vo == null) { - vo = new JobMappingVO(); - vo.setMethodPttrn("*"); - vo.setSortOrdr(100); // DDL DEFAULT 와 동일 - vo.setUseAt("Y"); - } - model.addAttribute("jobMappingVO", vo); - return "site/mnt/sec/jobMapping/regist"; - } - - /** - * 등록·수정 저장. mappingId 가 0 이면 등록, 그 외는 수정. - * - *

{@code BindingResult} 를 반드시 받는다 — 숫자 필드(우선순위)를 비우거나 문자로 채우면 - * 바인딩이 실패하는데, 이 파라미터가 없으면 Spring 이 BindException 을 던져 500 오류 화면이 뜬다. - * 여기서 받아 사용자 메시지로 되돌린다.

- */ - @RequestMapping(value = "/mnt/sec/jobMapping/save.do") - public String save(@ModelAttribute("jobMappingVO") JobMappingVO vo, - BindingResult bindingResult, - HttpServletRequest request) throws Exception { - LoginVO user = handlerUser(); - if (user == null) { - return LOGIN_REDIRECT; - } - if (!isPost(request) || !isSameOrigin(request)) { - return listRedirect("잘못된 요청 경로입니다."); - } - if (bindingResult.hasErrors()) { - return listRedirect("입력값 형식이 올바르지 않습니다. 우선순위는 숫자로 입력하세요."); - } - - try { - if (vo.getMappingId() > 0) { - jobMappingManageService.updateJobMapping(vo, user.getId()); - return listRedirect("수정되었습니다. 접속기록 분류에 즉시 반영됩니다."); - } - jobMappingManageService.insertJobMapping(vo, user.getId()); - return listRedirect("등록되었습니다. 접속기록 분류에 즉시 반영됩니다."); - } catch (IllegalArgumentException e) { - return listRedirect(e.getMessage()); - } - } - - /** 사용여부 전환(Y↔N) — 삭제 대신 권장하는 비활성화 경로. */ - @RequestMapping(value = "/mnt/sec/jobMapping/useAt.do") - public String updateUseAt(@RequestParam("mappingId") long mappingId, - @RequestParam("useAt") String useAt, - HttpServletRequest request) throws Exception { - LoginVO user = handlerUser(); - if (user == null) { - return LOGIN_REDIRECT; - } - if (!isPost(request) || !isSameOrigin(request)) { - return listRedirect("잘못된 요청 경로입니다."); - } - - try { - jobMappingManageService.updateJobMappingUseAt(mappingId, useAt, user.getId()); - return listRedirect("Y".equals(useAt) ? "사용으로 전환했습니다." : "사용중지로 전환했습니다."); - } catch (IllegalArgumentException e) { - return listRedirect(e.getMessage()); - } - } - - /** 매핑 삭제(행 제거). */ - @RequestMapping(value = "/mnt/sec/jobMapping/delete.do") - public String delete(@RequestParam("mappingId") long mappingId, - HttpServletRequest request) throws Exception { - LoginVO user = handlerUser(); - if (user == null) { - return LOGIN_REDIRECT; - } - if (!isPost(request) || !isSameOrigin(request)) { - return listRedirect("잘못된 요청 경로입니다."); - } - - try { - jobMappingManageService.deleteJobMapping(mappingId, user.getId()); - return listRedirect("삭제되었습니다."); - } catch (IllegalArgumentException e) { - return listRedirect(e.getMessage()); - } - } - - /** - * Resolver 캐시 재적재. 화면을 거치지 않고 DB 를 직접 수정한 경우 - * WAS 재기동 없이 반영하기 위한 수단이다. - */ - @RequestMapping(value = "/mnt/sec/jobMapping/refresh.do") - public String refresh(HttpServletRequest request) throws Exception { - if (handlerUser() == null) { - return LOGIN_REDIRECT; - } - if (!isPost(request) || !isSameOrigin(request)) { - return listRedirect("잘못된 요청 경로입니다."); - } - jobMappingManageService.refreshCache(); - return listRedirect("매핑 캐시를 재적재했습니다."); - } - - /** 처리결과 메시지를 붙여 목록으로 리다이렉트. */ - private String listRedirect(String message) throws Exception { - if (message == null) { - return LIST_REDIRECT; - } - return LIST_REDIRECT + "&resultMsg=" + URLEncoder.encode(message, "UTF-8"); - } - - /** 상태를 바꾸는 요청은 POST 로 한정(링크 클릭·이미지 태그를 통한 변경 방지). */ - private boolean isPost(HttpServletRequest request) { - return "POST".equalsIgnoreCase(request.getMethod()); - } - - /** - * 취급자 영역(/mnt) 인증·구분 판정. - * - * @return 인증된 업무사용자(USR) LoginVO, 그 외에는 null - */ - private LoginVO handlerUser() { - if (!Boolean.TRUE.equals(EgovUserDetailsHelper.isAuthenticated())) { - return null; - } - Object o = EgovUserDetailsHelper.getAuthenticatedUser(); - if (!(o instanceof LoginVO)) { - return null; - } - LoginVO loginVO = (LoginVO) o; - return "USR".equals(loginVO.getUserSe()) ? loginVO : null; - } - - /** - * CSRF 부분 완화. Spring Security 2.0.4 에는 CSRF 토큰 기능이 없으므로, - * Origin/Referer 가 전달된 경우에 한해 요청 호스트와 동일한지 확인한다 - * (헤더 미전송 환경은 과잉차단을 피해 통과 — AccountMngController 와 동일 규약). - */ - private boolean isSameOrigin(HttpServletRequest request) { - String source = request.getHeader("Origin"); - if (source == null) { - source = request.getHeader("Referer"); - } - if (source == null) { - return true; - } - String sourceHost = hostOf(source); - String requestHost = hostOf(request.getHeader("Host")); - return sourceHost != null && sourceHost.equalsIgnoreCase(requestHost); - } - - /** "https://host:8080/path" · "host:8080" 등에서 호스트명만 추출. */ - private String hostOf(String value) { - if (value == null) { - return null; - } - String host = value; - int scheme = host.indexOf("://"); - if (scheme >= 0) { - host = host.substring(scheme + 3); - } - int slash = host.indexOf('/'); - if (slash >= 0) { - host = host.substring(0, slash); - } - int colon = host.indexOf(':'); - if (colon >= 0) { - host = host.substring(0, colon); - } - return host.length() == 0 ? null : host; - } -} diff --git a/src/main/java/egovframework/com/cmm/privacy/auditlog/JobMappingDAO.java b/src/main/java/egovframework/com/cmm/privacy/auditlog/JobMappingDAO.java deleted file mode 100644 index a3b02f3..0000000 --- a/src/main/java/egovframework/com/cmm/privacy/auditlog/JobMappingDAO.java +++ /dev/null @@ -1,78 +0,0 @@ -package egovframework.com.cmm.privacy.auditlog; - -import java.util.List; - -import org.springframework.stereotype.Repository; - -import egovframework.com.cmm.service.impl.EgovComAbstractDAO; - -/** - * 개인정보 처리 업무유형 매핑(PDAL_JOB_MAPPING) 조회 DAO. - * - *

문화품앗이 개인정보보호 기능개선 사업 - 개발항목 #32.

- * - * @author arko-privacy-auditlog (Fork B) - * @since 2026-07-21 - */ -@Repository("jobMappingDAO") -public class JobMappingDAO extends EgovComAbstractDAO { - - /** - * 사용중(USE_AT='Y')인 업무유형 매핑 전체를 우선순위(SORT_ORDR) 오름차순으로 조회한다. - * (Resolver가 기동 시 1회 로드해 메모리 캐시) - */ - @SuppressWarnings("unchecked") - public List selectJobMappingList() { - return list("JobMappingDAO.selectJobMappingList", null); - } - - // ================================================================= - // 관리 화면(#32-1) CRUD — 위 selectJobMappingList 와 달리 USE_AT='N' 행도 대상 - // ================================================================= - - /** 관리 목록 조회(검색 + 페이징). 사용중지(N) 행도 포함한다. */ - @SuppressWarnings("unchecked") - public List selectJobMappingManageList(JobMappingSearchVO searchVO) { - return list("JobMappingDAO.selectJobMappingManageList", searchVO); - } - - /** 관리 목록 전체 건수 */ - public int selectJobMappingManageListCount(JobMappingSearchVO searchVO) { - Object cnt = selectByPk("JobMappingDAO.selectJobMappingManageListCount", searchVO); - return cnt == null ? 0 : ((Integer) cnt).intValue(); - } - - /** 매핑 단건 조회 */ - public JobMappingVO selectJobMapping(long mappingId) { - return (JobMappingVO) selectByPk("JobMappingDAO.selectJobMapping", Long.valueOf(mappingId)); - } - - /** - * (TARGET_CLASS, METHOD_PTTRN) 중복 건수. 수정 시에는 자기 자신을 제외한다. - * 테이블에 유니크 제약이 없어(운영 중 행 추가/삭제 자유) 서비스 계층에서 막는다. - */ - public int selectJobMappingDuplicateCount(JobMappingVO vo) { - Object cnt = selectByPk("JobMappingDAO.selectJobMappingDuplicateCount", vo); - return cnt == null ? 0 : ((Integer) cnt).intValue(); - } - - /** 매핑 등록 */ - public void insertJobMapping(JobMappingVO vo) { - insert("JobMappingDAO.insertJobMapping", vo); - } - - /** 매핑 수정 */ - public void updateJobMapping(JobMappingVO vo) { - update("JobMappingDAO.updateJobMapping", vo); - } - - /** 매핑 사용여부 전환(Y↔N) — 삭제 대신 권장하는 비활성화 경로 */ - public void updateJobMappingUseAt(JobMappingVO vo) { - update("JobMappingDAO.updateJobMappingUseAt", vo); - } - - /** 매핑 삭제 */ - public void deleteJobMapping(long mappingId) { - delete("JobMappingDAO.deleteJobMapping", Long.valueOf(mappingId)); - } -} diff --git a/src/main/java/egovframework/com/cmm/privacy/auditlog/JobMappingManageService.java b/src/main/java/egovframework/com/cmm/privacy/auditlog/JobMappingManageService.java deleted file mode 100644 index 5f2f914..0000000 --- a/src/main/java/egovframework/com/cmm/privacy/auditlog/JobMappingManageService.java +++ /dev/null @@ -1,53 +0,0 @@ -package egovframework.com.cmm.privacy.auditlog; - -import java.util.List; - -/** - * 업무유형 매핑(PDAL_JOB_MAPPING) 관리 서비스 — 개발항목 #32-1. - * - *

#32 는 테이블·로더까지만 구현되어 매핑 행을 DB 직접 INSERT 로만 운영했다. - * 본 서비스는 취급자가 화면에서 매핑을 등록·수정·사용중지·삭제할 수 있게 한다. - * 매핑이 바뀌면 {@link JobMappingResolver} 캐시를 즉시 무효화하므로 WAS 재기동이 불요하다.

- * - * @author URP - * @since 2026-08-07 - */ -public interface JobMappingManageService { - - /** 관리 목록 조회(검색 + 페이징). searchVO 에 firstIndex/totalRecordCount 를 세팅한다. */ - List selectJobMappingManageList(JobMappingSearchVO searchVO) throws Exception; - - /** 매핑 단건 조회(수정 폼). 없으면 null. */ - JobMappingVO selectJobMapping(long mappingId) throws Exception; - - /** - * 매핑 등록. - * - * @throws IllegalArgumentException 입력값 위반 또는 (대상클래스, 메서드패턴) 중복 - */ - void insertJobMapping(JobMappingVO vo, String actorId) throws Exception; - - /** - * 매핑 수정. - * - * @throws IllegalArgumentException 입력값 위반 / 중복 / 대상 매핑 없음 - */ - void updateJobMapping(JobMappingVO vo, String actorId) throws Exception; - - /** - * 사용여부 전환(Y↔N). 삭제 대신 권장하는 비활성화 경로. - * - * @throws IllegalArgumentException 대상 매핑 없음 또는 useAt 값 오류 - */ - void updateJobMappingUseAt(long mappingId, String useAt, String actorId) throws Exception; - - /** - * 매핑 삭제(행 제거). - * - * @throws IllegalArgumentException 대상 매핑 없음 - */ - void deleteJobMapping(long mappingId, String actorId) throws Exception; - - /** Resolver 캐시 강제 재적재(DB 직접 수정분 반영용). */ - void refreshCache() throws Exception; -} diff --git a/src/main/java/egovframework/com/cmm/privacy/auditlog/JobMappingManageServiceImpl.java b/src/main/java/egovframework/com/cmm/privacy/auditlog/JobMappingManageServiceImpl.java deleted file mode 100644 index 1d390cf..0000000 --- a/src/main/java/egovframework/com/cmm/privacy/auditlog/JobMappingManageServiceImpl.java +++ /dev/null @@ -1,181 +0,0 @@ -package egovframework.com.cmm.privacy.auditlog; - -import java.util.List; -import java.util.regex.Pattern; - -import javax.annotation.Resource; - -import org.springframework.stereotype.Service; - -/** - * 업무유형 매핑(PDAL_JOB_MAPPING) 관리 서비스 구현 — 개발항목 #32-1. - * - *

클래스명이 {@code *Impl} 이고 패키지가 {@code egovframework.com..} 이므로 - * {@code context-transaction.xml} 의 requiredTx pointcut 에 걸려 메서드 단위 트랜잭션이 적용된다 - * (DB 반영 + 캐시 무효화가 함께 처리된다).

- * - *

캐시 무효화 범위: {@link JobMappingResolver#refresh()} 는 해당 JVM 의 캐시만 - * 비운다. arko 는 단일 Tomcat ROOT 전개라 현재는 충분하나, 다중 인스턴스로 확장하면 - * 인스턴스별 재적재 수단(관리 화면의 '캐시 재적재' 버튼을 각 노드에서 호출 등)이 필요하다.

- * - *

입력 검증: TARGET_CLASS/METHOD_PTTRN 은 Aspect 가 (클래스 단순명, 메서드명)으로 - * 매칭하는 값이라 자바 식별자 형식만 허용한다. 형식을 열어두면 어떤 값도 매칭되지 않는 - * 죽은 매핑이 쌓여 접속기록 분류가 조용히 폴백 처리된다.

- * - * @author URP - * @since 2026-08-07 - */ -@Service("jobMappingManageService") -public class JobMappingManageServiceImpl implements JobMappingManageService { - - /** 자바 식별자(패키지 포함 FQN 허용) */ - private static final Pattern CLASS_PATTERN = - Pattern.compile("^[A-Za-z_$][A-Za-z0-9_$]*(\\.[A-Za-z_$][A-Za-z0-9_$]*)*$"); - /** 메서드명 접두(부분 식별자) */ - private static final Pattern METHOD_PATTERN = - Pattern.compile("^[A-Za-z_$][A-Za-z0-9_$]*$"); - /** 업무코드 — 대문자/숫자/언더스코어 */ - private static final Pattern CODE_PATTERN = - Pattern.compile("^[A-Z0-9_]+$"); - - @Resource(name = "jobMappingDAO") - private JobMappingDAO jobMappingDAO; - - @Resource(name = "jobMappingResolver") - private JobMappingResolver jobMappingResolver; - - public List selectJobMappingManageList(JobMappingSearchVO searchVO) throws Exception { - if (searchVO.getPageIndex() < 1) { - searchVO.setPageIndex(1); - } - if (searchVO.getRecordCountPerPage() < 1 || searchVO.getRecordCountPerPage() > 100) { - searchVO.setRecordCountPerPage(20); - } - searchVO.setFirstIndex((searchVO.getPageIndex() - 1) * searchVO.getRecordCountPerPage()); - searchVO.setTotalRecordCount(jobMappingDAO.selectJobMappingManageListCount(searchVO)); - return jobMappingDAO.selectJobMappingManageList(searchVO); - } - - public JobMappingVO selectJobMapping(long mappingId) throws Exception { - return jobMappingDAO.selectJobMapping(mappingId); - } - - public void insertJobMapping(JobMappingVO vo, String actorId) throws Exception { - normalize(vo); - validate(vo); - // 신규는 자기 자신 제외가 없으므로 mappingId=0 으로 전체 대조 - vo.setMappingId(0L); - if (jobMappingDAO.selectJobMappingDuplicateCount(vo) > 0) { - throw new IllegalArgumentException("이미 등록된 조합입니다. (대상클래스 + 메서드패턴)"); - } - vo.setRegisterId(actorId); - vo.setUpdusrId(actorId); - jobMappingDAO.insertJobMapping(vo); - jobMappingResolver.refresh(); - } - - public void updateJobMapping(JobMappingVO vo, String actorId) throws Exception { - normalize(vo); - validate(vo); - if (jobMappingDAO.selectJobMapping(vo.getMappingId()) == null) { - throw new IllegalArgumentException("대상 매핑이 존재하지 않습니다."); - } - if (jobMappingDAO.selectJobMappingDuplicateCount(vo) > 0) { - throw new IllegalArgumentException("이미 등록된 조합입니다. (대상클래스 + 메서드패턴)"); - } - vo.setUpdusrId(actorId); - jobMappingDAO.updateJobMapping(vo); - jobMappingResolver.refresh(); - } - - public void updateJobMappingUseAt(long mappingId, String useAt, String actorId) throws Exception { - if (!"Y".equals(useAt) && !"N".equals(useAt)) { - throw new IllegalArgumentException("사용여부 값이 올바르지 않습니다."); - } - JobMappingVO target = jobMappingDAO.selectJobMapping(mappingId); - if (target == null) { - throw new IllegalArgumentException("대상 매핑이 존재하지 않습니다."); - } - JobMappingVO vo = new JobMappingVO(); - vo.setMappingId(mappingId); - vo.setUseAt(useAt); - vo.setUpdusrId(actorId); - jobMappingDAO.updateJobMappingUseAt(vo); - jobMappingResolver.refresh(); - } - - public void deleteJobMapping(long mappingId, String actorId) throws Exception { - if (jobMappingDAO.selectJobMapping(mappingId) == null) { - throw new IllegalArgumentException("대상 매핑이 존재하지 않습니다."); - } - jobMappingDAO.deleteJobMapping(mappingId); - jobMappingResolver.refresh(); - } - - public void refreshCache() throws Exception { - jobMappingResolver.refresh(); - } - - /** 공백 제거 + 미입력 항목 기본값 보정(체크박스/셀렉트 미전송 대비). */ - private void normalize(JobMappingVO vo) { - vo.setTargetClass(trim(vo.getTargetClass())); - vo.setMethodPttrn(trim(vo.getMethodPttrn())); - vo.setJobSeCode(trim(vo.getJobSeCode())); - vo.setJobSeNm(trim(vo.getJobSeNm())); - vo.setDmlGubun(trim(vo.getDmlGubun())); - vo.setRm(trim(vo.getRm())); - - if (vo.getMethodPttrn() == null) { - vo.setMethodPttrn("*"); // DDL DEFAULT 와 동일 - } - // dmlGubun 은 미입력(null) 이 곧 '메서드명으로 유도' 규약이라 보정하지 않는다. - if (!"N".equals(vo.getUseAt())) { - vo.setUseAt("Y"); - } - if (vo.getJobSeCode() != null) { - vo.setJobSeCode(vo.getJobSeCode().toUpperCase()); - } - } - - /** 화면 우회 입력(직접 POST)에 대비한 서버측 검증. 위반 시 사용자 메시지로 예외. */ - private void validate(JobMappingVO vo) { - if (vo.getTargetClass() == null) { - throw new IllegalArgumentException("대상 클래스명은 필수입니다."); - } - if (vo.getTargetClass().length() > 200 || !CLASS_PATTERN.matcher(vo.getTargetClass()).matches()) { - throw new IllegalArgumentException("대상 클래스명은 자바 클래스명(또는 FQN) 형식으로 200자 이내 입력하세요."); - } - if (!"*".equals(vo.getMethodPttrn()) - && (vo.getMethodPttrn().length() > 100 || !METHOD_PATTERN.matcher(vo.getMethodPttrn()).matches())) { - throw new IllegalArgumentException("메서드 패턴은 '*' 또는 메서드명 접두(영문/숫자) 100자 이내로 입력하세요."); - } - if (vo.getJobSeCode() == null) { - throw new IllegalArgumentException("업무코드는 필수입니다."); - } - if (vo.getJobSeCode().length() > 30 || !CODE_PATTERN.matcher(vo.getJobSeCode()).matches()) { - throw new IllegalArgumentException("업무코드는 영문 대문자·숫자·밑줄 조합으로 30자 이내 입력하세요."); - } - if (vo.getJobSeNm() == null || vo.getJobSeNm().length() > 100) { - throw new IllegalArgumentException("업무명은 필수이며 100자 이내로 입력하세요."); - } - if (vo.getDmlGubun() != null && !"S".equals(vo.getDmlGubun()) && !"I".equals(vo.getDmlGubun()) - && !"U".equals(vo.getDmlGubun()) && !"D".equals(vo.getDmlGubun())) { - throw new IllegalArgumentException("처리구분 값이 올바르지 않습니다."); - } - if (vo.getSortOrdr() < 0 || vo.getSortOrdr() > 9999) { - throw new IllegalArgumentException("우선순위는 0~9999 범위로 입력하세요."); - } - if (vo.getRm() != null && vo.getRm().length() > 300) { - throw new IllegalArgumentException("비고는 300자 이내로 입력하세요."); - } - } - - /** 앞뒤 공백 제거 후 빈 문자열은 null 로(iBatis isNotEmpty·DB NULL 규약 일치). */ - private String trim(String v) { - if (v == null) { - return null; - } - String s = v.trim(); - return s.length() == 0 ? null : s; - } -} diff --git a/src/main/java/egovframework/com/cmm/privacy/auditlog/JobMappingResolver.java b/src/main/java/egovframework/com/cmm/privacy/auditlog/JobMappingResolver.java deleted file mode 100644 index 45df757..0000000 --- a/src/main/java/egovframework/com/cmm/privacy/auditlog/JobMappingResolver.java +++ /dev/null @@ -1,112 +0,0 @@ -package egovframework.com.cmm.privacy.auditlog; - -import java.util.ArrayList; -import java.util.List; - -import javax.annotation.Resource; - -import org.springframework.stereotype.Component; - -/** - * 업무유형 매핑 해석기 (개발항목 #32). - * - *

PDAL_JOB_MAPPING 테이블을 메모리에 캐시하고, 접속기록 Aspect(#31)가 가로챈 - * (대상클래스, 메서드명) 조합을 업무코드/업무명으로 해석한다.

- * - *

매칭 규칙: USE_AT='Y' 매핑을 SORT_ORDR 오름차순으로 순회하여, 대상 클래스가 - * 일치(단순명 또는 FQN)하고 메서드명이 METHOD_PTTRN으로 시작(또는 '*')하는 첫 매핑을 반환. - * 구체 규칙(낮은 SORT_ORDR)이 폴백('*', 높은 SORT_ORDR)보다 우선한다.

- * - * @author arko-privacy-auditlog (Fork B) - * @since 2026-07-21 - */ -@Component("jobMappingResolver") -public class JobMappingResolver { - - @Resource(name = "jobMappingDAO") - private JobMappingDAO jobMappingDAO; - - /** 캐시(불변 스냅샷 참조 교체 방식). */ - private volatile List cache; - - /** - * (대상클래스 단순명, 메서드명)에 매칭되는 업무유형 매핑을 반환한다. - * 매칭이 없으면 null (호출측이 메서드명 기반 기본값으로 폴백). - */ - public JobMappingVO resolve(String targetSimpleClassName, String methodName) { - if (targetSimpleClassName == null || methodName == null) { - return null; - } - List list = getCache(); - for (int i = 0; i < list.size(); i++) { - JobMappingVO m = list.get(i); - if (classMatches(m.getTargetClass(), targetSimpleClassName) - && methodMatches(m.getMethodPttrn(), methodName)) { - return m; - } - } - return null; - } - - /** 매핑이 변경되면(관리자 화면 등) 호출해 캐시를 무효화한다. */ - public void refresh() { - this.cache = null; - } - - private List getCache() { - List local = this.cache; - if (local == null) { - synchronized (this) { - if (this.cache == null) { - List loaded = null; - try { - loaded = jobMappingDAO.selectJobMappingList(); - } catch (Exception e) { - // 매핑 로드 실패는 접속기록 수집 자체를 막지 않는다(메서드명 폴백으로 동작). - loaded = null; - } - this.cache = (loaded == null) ? new ArrayList() : loaded; - } - local = this.cache; - } - } - return local; - } - - private boolean classMatches(String pattern, String simpleName) { - if (pattern == null) { - return false; - } - // 단순명 정확 일치, 또는 FQN 등록 시 '.단순명'으로 끝나는지 - return pattern.equals(simpleName) || pattern.endsWith("." + simpleName); - } - - private boolean methodMatches(String pattern, String methodName) { - if (pattern == null || "*".equals(pattern)) { - return true; - } - return methodName.startsWith(pattern); - } - - /** - * 메서드명 접두로 처리구분(DML_GUBUN)을 유도한다. - * insert/regist/creat/add/save→I, update/modify/chang→U, delete/remov→D, 그 외 S(조회). - */ - public static String deriveDmlGubun(String methodName) { - if (methodName == null) { - return "S"; - } - String m = methodName.toLowerCase(); - if (m.startsWith("insert") || m.startsWith("regist") || m.startsWith("creat") - || m.startsWith("add") || m.startsWith("save")) { - return "I"; - } - if (m.startsWith("update") || m.startsWith("modify") || m.startsWith("chang")) { - return "U"; - } - if (m.startsWith("delete") || m.startsWith("remov")) { - return "D"; - } - return "S"; - } -} diff --git a/src/main/java/egovframework/com/cmm/privacy/auditlog/JobMappingSearchVO.java b/src/main/java/egovframework/com/cmm/privacy/auditlog/JobMappingSearchVO.java deleted file mode 100644 index e7545fe..0000000 --- a/src/main/java/egovframework/com/cmm/privacy/auditlog/JobMappingSearchVO.java +++ /dev/null @@ -1,61 +0,0 @@ -package egovframework.com.cmm.privacy.auditlog; - -import java.io.Serializable; - -/** - * 업무유형 매핑(PDAL_JOB_MAPPING) 관리 화면 검색조건 VO — 개발항목 #32-1. - * - *

매핑 조회용 {@link JobMappingVO} 와 달리 USE_AT='N' 행도 조회 대상이다. - * 관리 화면에서는 사용중지된 매핑도 보여야 재사용·복구가 가능하기 때문이다 - * ({@code JobMappingDAO.selectJobMappingList} 는 Resolver 캐시용이라 'Y'만 조회).

- * - * @author URP - * @since 2026-08-07 - */ -public class JobMappingSearchVO implements Serializable { - - private static final long serialVersionUID = 1L; - - /** 대상 클래스명(부분 일치) */ - private String searchTargetClass; - /** 업무코드 또는 업무명(부분 일치) */ - private String searchJobSe; - /** 사용여부 Y/N (빈 값이면 전체) */ - private String searchUseAt; - /** 처리구분 S/I/U/D (빈 값이면 전체) */ - private String searchDmlGubun; - - // ---- 페이징 ---- - /** 현재 페이지 번호(1-base) */ - private int pageIndex = 1; - /** 페이지당 레코드 수 */ - private int recordCountPerPage = 20; - /** 조회 시작 offset(0-base) = (pageIndex-1) * recordCountPerPage */ - private int firstIndex = 0; - /** 전체 레코드 수(조회 후 세팅) */ - private int totalRecordCount = 0; - - public String getSearchTargetClass() { return searchTargetClass; } - public void setSearchTargetClass(String searchTargetClass) { this.searchTargetClass = searchTargetClass; } - - public String getSearchJobSe() { return searchJobSe; } - public void setSearchJobSe(String searchJobSe) { this.searchJobSe = searchJobSe; } - - public String getSearchUseAt() { return searchUseAt; } - public void setSearchUseAt(String searchUseAt) { this.searchUseAt = searchUseAt; } - - public String getSearchDmlGubun() { return searchDmlGubun; } - public void setSearchDmlGubun(String searchDmlGubun) { this.searchDmlGubun = searchDmlGubun; } - - public int getPageIndex() { return pageIndex; } - public void setPageIndex(int pageIndex) { this.pageIndex = pageIndex; } - - public int getRecordCountPerPage() { return recordCountPerPage; } - public void setRecordCountPerPage(int recordCountPerPage) { this.recordCountPerPage = recordCountPerPage; } - - public int getFirstIndex() { return firstIndex; } - public void setFirstIndex(int firstIndex) { this.firstIndex = firstIndex; } - - public int getTotalRecordCount() { return totalRecordCount; } - public void setTotalRecordCount(int totalRecordCount) { this.totalRecordCount = totalRecordCount; } -} diff --git a/src/main/java/egovframework/com/cmm/privacy/auditlog/JobMappingVO.java b/src/main/java/egovframework/com/cmm/privacy/auditlog/JobMappingVO.java index 119aace..f267f45 100644 --- a/src/main/java/egovframework/com/cmm/privacy/auditlog/JobMappingVO.java +++ b/src/main/java/egovframework/com/cmm/privacy/auditlog/JobMappingVO.java @@ -3,11 +3,19 @@ package egovframework.com.cmm.privacy.auditlog; import java.io.Serializable; /** - * 개인정보 처리 업무유형 매핑(PDAL_JOB_MAPPING) VO. + * 개인정보 처리 업무유형 매핑 VO. * *

문화품앗이 개인정보보호 기능개선 사업 - 개발항목 #32 (SFR-004 / SER-002). - * 접속기록 수집({@link PersonalDataLogAspect})이 가로챈 서비스 메서드를 - * 업무코드/업무명으로 해석하는 매핑 한 행.

+ * 본래 PDAL_JOB_MAPPING 한 행을 담아, 폐기된 서비스 AOP 수집이 가로챈 서비스 메서드를 + * 업무코드/업무명으로 해석하는 데 썼다.

+ * + *

[2026-08-11] 업무유형 매핑 관리 화면(메뉴 9100045) 삭제 후에도 이 클래스는 존치한다 — + * 접속기록 조회화면(9100044)의 '수행업무' 드롭다운 statement + * {@code PersonalDataAccessLogDAO.selectJobSeCodeList}(PrivacyAccessLog_SQL_Mysql.xml / + * _Oracle.xml)가 {@code resultClass} 로 이 클래스를 재사용한다. 실제 데이터는 + * PDAL_JOB_MAPPING 이 아니라 PERSONAL_DATA_ACCESS_LOG 에서 직접 뽑으며 + * jobSeCode/jobSeNm 두 프로퍼티만 채워진다. 삭제하면 iBatis2 특성상 기동 시가 아니라 + * 해당 드롭다운 최초 조회 시점에 resultClass 로드 실패로 터진다.

* * @author arko-privacy-auditlog (Fork B) * @since 2026-07-21 diff --git a/src/main/java/egovframework/com/cmm/privacy/auditlog/PersonalDataAccessLogController.java b/src/main/java/egovframework/com/cmm/privacy/auditlog/PersonalDataAccessLogController.java index 2b4de76..35b8c98 100644 --- a/src/main/java/egovframework/com/cmm/privacy/auditlog/PersonalDataAccessLogController.java +++ b/src/main/java/egovframework/com/cmm/privacy/auditlog/PersonalDataAccessLogController.java @@ -55,6 +55,9 @@ public class PersonalDataAccessLogController { private static final String LOGIN_REDIRECT = "redirect:/uat/uia/egovLoginUsr.do"; + /** 화면에 띄울 '내 다운로드 요청' 최대 건수(만료 전 기준). 폭주 방지용 상한이다. */ + private static final int MY_JOB_MAX = 100; + @Resource(name = "personalDataAccessLogSearchService") private PersonalDataAccessLogSearchService personalDataAccessLogSearchService; @@ -216,7 +219,8 @@ public class PersonalDataAccessLogController { } StringBuilder sb = new StringBuilder(256); - List list = accessLogDownloadJobService.selectMyJobs(user.getId(), 10); + // 목록과 같은 범위를 봐야 한다 — 다르면 화면에 떠 있는데 폴링이 갱신하지 않는 행이 생긴다. + List list = accessLogDownloadJobService.selectMyJobs(user.getId(), MY_JOB_MAX); if (list != null) { for (AccessLogDownloadJobVO j : list) { sb.append(j.getJobId()).append('|') @@ -243,14 +247,26 @@ public class PersonalDataAccessLogController { return (v == null) ? "" : v; } - /** 내 다운로드 작업 목록(최근 10건). 조회 실패해도 화면은 떠야 하므로 빈 목록으로 fail-safe. */ + /** + * 내 다운로드 작업 목록 — 만료 전 이력만(2026-08-10 정책). + * + *

종전에는 '최근 10건'이라 이미 파일이 파기된 행까지 남아, 화면상 '완료'인데 눌러도 + * 내려받아지지 않는 상태가 생겼다. 만료 기준으로 거르면 목록에 보이는 건 전부 실제로 + * 내려받을 수 있는 것들이다. 테이블 행은 그대로 둔다 — 누가 언제 왜 개인정보를 + * 반출 요청했는지는 감사 증적이라 지우지 않고, 화면에서만 감춘다.

+ * + *

{@link #MY_JOB_MAX} 는 보관 3일 기준으로 사실상 걸리지 않지만, 단기 대량 요청 시 + * 화면이 통째로 길어지는 것을 막는 안전장치다.

+ * + *

조회 실패해도 화면은 떠야 하므로 빈 목록으로 fail-safe.

+ */ private List selectMyDownloadJobs() { try { LoginVO user = handlerUser(); if (user == null) { return new ArrayList(); } - List list = accessLogDownloadJobService.selectMyJobs(user.getId(), 10); + List list = accessLogDownloadJobService.selectMyJobs(user.getId(), MY_JOB_MAX); return (list == null) ? new ArrayList() : list; } catch (Exception e) { return new ArrayList(); diff --git a/src/main/java/egovframework/com/cmm/privacy/auditlog/PersonalDataLogAspect.java b/src/main/java/egovframework/com/cmm/privacy/auditlog/PersonalDataLogAspect.java deleted file mode 100644 index 6085baf..0000000 --- a/src/main/java/egovframework/com/cmm/privacy/auditlog/PersonalDataLogAspect.java +++ /dev/null @@ -1,280 +0,0 @@ -package egovframework.com.cmm.privacy.auditlog; - -import java.lang.reflect.Method; -import java.text.SimpleDateFormat; -import java.util.Collection; -import java.util.Date; -import java.util.Map; - -import javax.annotation.Resource; -import javax.servlet.http.HttpServletRequest; -import javax.servlet.http.HttpSession; - -import org.apache.commons.logging.Log; -import org.apache.commons.logging.LogFactory; -import org.aspectj.lang.ProceedingJoinPoint; -import org.springframework.stereotype.Component; -import org.springframework.web.context.request.RequestAttributes; -import org.springframework.web.context.request.RequestContextHolder; -import org.springframework.web.context.request.ServletRequestAttributes; - -import egovframework.com.cmm.LoginVO; - -/** - * 개인정보 접속기록 수집 Aspect (개발항목 #31, SFR-004 / DBR-001 / SER-002). - * - *

[2026-08-07 사용 중단] 이 Aspect 는 더 이상 적용되지 않는다. - * {@code context-syslogaop.xml}의 {@code }가 주석 처리됐고, 접속기록 수집은 - * {@code egovframework.com.cmm.privacy.interceptor.MenuAccessLogInterceptor}가 담당한다. - * 중단 사유: 서비스 impl 을 가로채는 방식은 관리자 화면과 프론트 화면이 같은 서비스 - * 메서드를 공유하는 구조에서 호출 화면을 구분하지 못한다. 그 결과 무인증 프론트 방문이 - * 접속자ID {@code ANONYMOUS}, 수행업무 "재능나눔 회원관리"로 적재됐다. 요구사항(SFR-004)은 - * "개인정보취급자가 무슨 업무를 처리했는지"이므로, 요청 URL 로 관리자 화면을 식별하고 - * 메뉴명을 수행업무로 남기는 인터셉터 방식으로 전환했다. - * 클래스는 판단 근거·복원 경로 보존을 위해 남겨둔다(빈은 등록되나 어떤 pointcut 에도 걸리지 않음).

- * - *

한정 수집: {@code COMTNSYSLOG}의 select* 전 서비스 대상 방식이 아니라, - * 개인정보 처리 서비스로 한정한 pointcut(스키마 기반 {@code }, context-syslogaop.xml)에만 - * 적용된다. 가로챈 메서드는 업무유형 매핑 테이블({@link JobMappingResolver}, #32)로 업무코드를 얻고, - * 접속 IP({@link ClientIpResolver}, #34)와 해시체인({@link HashChainService}, #39)을 결합해 - * {@code PERSONAL_DATA_ACCESS_LOG}에 적재한다.

- * - *

본문 미적재 원칙: 처리 파라미터(개인정보 본문)는 로그에 담지 않는다. 식별자(TRGET_ID)만 - * 베스트에포트로 취득한다(#36).

- * - *

로그인 세션 한정(2026-08-07): 세션에 {@code loginVO}가 없는 요청은 수집하지 않는다. - * 종전에는 접속자ID를 {@code ANONYMOUS}로 적재했다. 근거는 두 가지다. - * ① 제안요청서(붙임2) SFR-004 정의가 "개인정보취급자 접속 및 업무처리 기록 관리"이고 - * 세부내용 첫 항목도 "개인정보취급자의 접속기록을 저장·관리"로, 비로그인 상태(정보주체 본인의 - * 가입·실명확인 등)는 대상이 아니다(SER-002·안전성 확보조치 기준 제8조도 동일 기준). - * ② 비정상접근 탐지(#35)가 {@code GROUP BY CONECT_ID} 기반이라, 모든 익명 요청이 - * {@code ANONYMOUS} 한 계정으로 뭉쳐 계정공유의심(상이 IP 다수)·대량조회에 상시 오탐을 만든다. - * 제외되는 대표 경로: 회원가입({@code insert*}), 실명확인({@code MberSsnServiceImpl}), - * 휴면해제, 계정잠금 해제, 인증번호 발송. 이 경로들의 추적은 인증 실패 이력(#22)이 담당한다.

- * - *

수집 범위(#36): 회원·업체 2개 지점 한정에서 전 개인정보 처리 서비스로 확장. - * pointcut 대상은 {@code context-syslogaop.xml}에 개인정보 취급 서비스 impl만 열거(과수집 방지). - * 신규 화면 추가 시에는 이 pointcut(또는 대상 서비스에 편입)과 업무유형 매핑({@link JobMappingResolver})만 - * 갱신하면 되고 컨트롤러 수동 호출은 불요하다.

- * - *

TRGET_ID 취득 규약(#36): 가로챈 메서드 인자에서 식별자만 베스트에포트로 추출한다. - * ① 인자가 VO/도메인 빈이면 우선순위 getter({@code getEsntlId→getUniqId→getMberId→getEmplyrId→ - * getEntrprsId→getGroupId→getUserId→getId→getSn}) 중 첫 non-blank 값. ② 인자가 {@code Map}이면 - * 동일 우선순위 키. ③ 원시 {@code String}/{@code Number} 인자는 식별자·검색어 구분이 불가하므로 - * 스킵(검색어·개인정보 본문 오적재 방지). 어느 것도 못 얻으면 {@code null}(fail-safe). - * 처리대상 건수(TRGET_CN)는 반환값이 {@code Collection}이면 "N건"으로 기록한다.

- * - *

스키마 기반 AOP(@AspectJ 미사용)라 {@code aspectj-autoproxy}를 전역 활성화하지 않는다 — - * 기존 프로젝트 프록시 동작에 영향을 주지 않기 위함.

- * - * @author arko-privacy-auditlog (Fork B) - * @since 2026-07-21 - */ -@Component("personalDataLogAspect") -public class PersonalDataLogAspect { - - private static final Log LOG = LogFactory.getLog(PersonalDataLogAspect.class); - - private static final String DT_FORMAT = "yyyy-MM-dd HH:mm:ss"; - - @Resource(name = "jobMappingResolver") - private JobMappingResolver jobMappingResolver; - - /** #33 비동기 적재 큐 — Aspect는 enqueue만 하고 즉시 반환(업무 응답시간 무영향). */ - @Resource(name = "accessLogQueue") - private AccessLogQueue accessLogQueue; - - /** - * 스키마 기반 {@code } 대상 메서드. - * 업무는 정상 수행하고, 성공/예외 여부와 무관하게 접속기록을 남긴다(기록 실패는 업무에 영향 없음). - */ - public Object around(ProceedingJoinPoint pjp) throws Throwable { - String processResult = "S"; - Object result = null; - try { - result = pjp.proceed(); - return result; - } catch (Throwable t) { - processResult = "F"; - throw t; - } finally { - try { - writeAccessLog(pjp, processResult, result); - } catch (Exception ignore) { - // 접속기록 수집은 업무 흐름을 절대 방해하지 않는다. - LOG.error("[개인정보/#31] 접속기록 수집 중 예외(무시)", ignore); - } - } - } - - private void writeAccessLog(ProceedingJoinPoint pjp, String processResult, Object result) { - HttpServletRequest request = currentRequest(); - // 웹 요청 컨텍스트가 없으면(배치/스케줄러 등) 접속기록 대상이 아니다. - if (request == null) { - return; - } - - // 로그인 세션이 없는 요청은 취급자 접속기록이 아니므로 수집하지 않는다(사유는 클래스 Javadoc). - // 매핑 조회보다 먼저 판정해 불필요한 작업을 하지 않는다. - LoginVO login = currentLogin(request); - if (login == null || !notBlank(login.getId())) { - return; - } - - String targetSimpleName = pjp.getTarget().getClass().getSimpleName(); - String methodName = pjp.getSignature().getName(); - - JobMappingVO mapping = jobMappingResolver.resolve(targetSimpleName, methodName); - - String jobSeCode; - String jobSeNm; - String dmlGubun; - if (mapping != null) { - jobSeCode = mapping.getJobSeCode(); - jobSeNm = mapping.getJobSeNm(); - dmlGubun = (mapping.getDmlGubun() != null && mapping.getDmlGubun().length() > 0) - ? mapping.getDmlGubun() - : JobMappingResolver.deriveDmlGubun(methodName); - } else { - // pointcut 범위 안이지만 매핑 미등록 → 메서드명 기반 기본값(누락 없이 수집) - jobSeCode = targetSimpleName; - jobSeNm = targetSimpleName + "." + methodName; - dmlGubun = JobMappingResolver.deriveDmlGubun(methodName); - } - - PersonalDataAccessLogVO vo = new PersonalDataAccessLogVO(); - vo.setConectId(login.getId()); - vo.setConectDt(new SimpleDateFormat(DT_FORMAT).format(new Date())); - vo.setConectIp(safeIp(request)); - vo.setJobSeCode(jobSeCode); - vo.setJobSeNm(jobSeNm); - // #36 처리대상 식별자(본문 아님)·건수 베스트에포트 취득 — 규약은 클래스 Javadoc 참조. - vo.setTrgetId(extractTargetId(pjp.getArgs())); - vo.setTrgetCn(extractTargetCount(result)); - vo.setDmlGubun(dmlGubun); - vo.setProcessResult(processResult); - vo.setUserSe(login != null ? login.getUserSe() : null); - - // #33 비동기 큐에 적재만 하고 즉시 반환 — 봉인+INSERT는 큐 단일 컨슈머가 순차 수행(순번 보장). - accessLogQueue.enqueue(vo); - } - - private HttpServletRequest currentRequest() { - RequestAttributes attrs = RequestContextHolder.getRequestAttributes(); - if (attrs instanceof ServletRequestAttributes) { - return ((ServletRequestAttributes) attrs).getRequest(); - } - return null; - } - - private LoginVO currentLogin(HttpServletRequest request) { - HttpSession session = request.getSession(false); - if (session == null) { - return null; - } - Object o = session.getAttribute("loginVO"); - return (o instanceof LoginVO) ? (LoginVO) o : null; - } - - private String safeIp(HttpServletRequest request) { - try { - String ip = ClientIpResolver.getClientIp(request); - return notBlank(ip) ? ip : "-"; - } catch (Exception e) { - return "-"; - } - } - - private static boolean notBlank(String s) { - return s != null && s.trim().length() > 0; - } - - // ===================== #36 처리대상 식별자/건수 취득 ===================== - - /** 우선순위 식별자 getter(개인정보 본문이 아닌 식별자만). */ - private static final String[] ID_GETTERS = { - "getEsntlId", "getUniqId", "getMberId", "getEmplyrId", "getEntrprsId", - "getGroupId", "getUserId", "getId", "getSn" - }; - /** Map 파라미터용 우선순위 식별자 키(ID_GETTERS와 동일 순서). */ - private static final String[] ID_KEYS = { - "esntlId", "uniqId", "mberId", "emplyrId", "entrprsId", - "groupId", "userId", "id", "sn" - }; - /** TRGET_ID 컬럼 길이(VARCHAR(50)) 보호. */ - private static final int TRGET_ID_MAX = 50; - - /** - * 인자에서 처리대상 식별자를 베스트에포트로 추출한다(규약: 클래스 Javadoc). - * VO는 우선순위 getter, Map은 우선순위 키. 원시 String/Number 인자는 스킵(검색어 오적재 방지). - * 실패 시 null(fail-safe) — 접속기록 자체는 계속 적재된다. - */ - private String extractTargetId(Object[] args) { - if (args == null) { - return null; - } - for (Object arg : args) { - if (arg == null) { - continue; - } - String id = null; - if (arg instanceof Map) { - id = fromMap((Map) arg); - } else if (isDomainBean(arg)) { - id = fromBean(arg); - } - // 원시 String/Number/Collection/배열 인자는 식별자 확정 불가 → 스킵(본문·검색어 오적재 방지) - if (notBlank(id)) { - return id.trim().length() > TRGET_ID_MAX ? id.trim().substring(0, TRGET_ID_MAX) : id.trim(); - } - } - return null; - } - - /** 반환값이 컬렉션이면 처리대상 건수(범위)를 "N건"으로 기록한다. */ - private String extractTargetCount(Object result) { - if (result instanceof Collection) { - return ((Collection) result).size() + "건"; - } - return null; - } - - /** VO/도메인 빈 판정 — String/래퍼/Collection/Map/배열은 제외. */ - private boolean isDomainBean(Object arg) { - if (arg instanceof String || arg instanceof Number || arg instanceof Boolean - || arg instanceof Character || arg instanceof Collection || arg instanceof Map - || arg.getClass().isArray()) { - return false; - } - return true; - } - - private String fromBean(Object bean) { - for (String getter : ID_GETTERS) { - try { - Method m = bean.getClass().getMethod(getter); - Object v = m.invoke(bean); - if (v != null && notBlank(String.valueOf(v))) { - return String.valueOf(v); - } - } catch (Exception ignore) { - // 해당 getter 없음/접근 불가 → 다음 후보 - } - } - return null; - } - - private String fromMap(Map map) { - for (String key : ID_KEYS) { - try { - Object v = map.get(key); - if (v != null && notBlank(String.valueOf(v))) { - return String.valueOf(v); - } - } catch (Exception ignore) { - // 일부 Map 구현의 키 타입 불일치 등 → 다음 후보 - } - } - return null; - } -} diff --git a/src/main/java/egovframework/com/cmm/privacy/author/AuthorWorkflowController.java b/src/main/java/egovframework/com/cmm/privacy/author/AuthorWorkflowController.java index a9b6e99..7c2127d 100644 --- a/src/main/java/egovframework/com/cmm/privacy/author/AuthorWorkflowController.java +++ b/src/main/java/egovframework/com/cmm/privacy/author/AuthorWorkflowController.java @@ -32,6 +32,13 @@ public class AuthorWorkflowController { protected static final String JSON = "jsonView"; + /** + * 화면에 띄울 '내 다운로드 요청' 최대 건수. 목록은 만료 전 이력만 보여주며 + * (2026-08-10 정책, 접속기록 다운로드와 동일), 이 값은 단기 대량 요청 시 화면이 + * 통째로 길어지는 것을 막는 상한이다. 테이블 행 자체는 감사 증적이라 그대로 둔다. + */ + private static final int MY_JOB_MAX = 100; + @Resource(name = "authorWorkflowService") private AuthorWorkflowService authorWorkflowService; @@ -201,7 +208,7 @@ public class AuthorWorkflowController { } StringBuilder sb = new StringBuilder(256); - List list = authorHistDownloadJobService.selectMyJobs(user.getId(), 10); + List list = authorHistDownloadJobService.selectMyJobs(user.getId(), MY_JOB_MAX); if (list != null) { for (AuthorHistDownloadJobVO j : list) { sb.append(j.getJobId()).append('|') @@ -322,7 +329,7 @@ public class AuthorWorkflowController { if (user == null) { return new java.util.ArrayList(); } - List list = authorHistDownloadJobService.selectMyJobs(user.getId(), 10); + List list = authorHistDownloadJobService.selectMyJobs(user.getId(), MY_JOB_MAX); return (list == null) ? new java.util.ArrayList() : list; } catch (Exception e) { return new java.util.ArrayList(); diff --git a/src/main/java/egovframework/com/cmm/privacy/conect/ConectLogController.java b/src/main/java/egovframework/com/cmm/privacy/conect/ConectLogController.java deleted file mode 100644 index 5e63bed..0000000 --- a/src/main/java/egovframework/com/cmm/privacy/conect/ConectLogController.java +++ /dev/null @@ -1,194 +0,0 @@ -package egovframework.com.cmm.privacy.conect; - -import java.io.OutputStream; -import java.io.OutputStreamWriter; -import java.io.PrintWriter; -import java.net.URLEncoder; -import java.text.SimpleDateFormat; -import java.util.ArrayList; -import java.util.Date; -import java.util.List; - -import javax.annotation.Resource; -import javax.servlet.http.HttpServletRequest; -import javax.servlet.http.HttpServletResponse; - -import org.springframework.stereotype.Controller; -import org.springframework.ui.ModelMap; -import org.springframework.web.bind.annotation.ModelAttribute; -import org.springframework.web.bind.annotation.RequestMapping; -import org.springframework.web.bind.annotation.RequestParam; - -import egovframework.com.cmm.LoginVO; -import egovframework.com.cmm.privacy.auditlog.ClientIpResolver; -import egovframework.com.cmm.util.EgovUserDetailsHelper; -import egovframework.rte.ptl.mvc.tags.ui.pagination.PaginationInfo; - -/** - * 로그인·로그아웃 접속기록 조회·다운로드 컨트롤러 — 개인정보보호 기능개선 사업(#62). - * - *

{@code COMTNLOGINLOG} 적재분(접속ID·접속일시·접속IP·접속위치)을 조건별로 검색하고 - * CSV 로 내려받는다. 화면: {@code /WEB-INF/jsp/site/mnt/sec/conectLog/list.jsp}

- * - *

다운로드: 사유 입력이 필수이며 미입력 시 400 으로 차단하고 파일을 열지 않는다. - * 다운로드자·일시·IP·사유·건수는 {@code PDAL_DOWNLOAD_HISTORY}(#42 재사용)에 적재된다.

- * - *

접근통제: 인터셉터 3단 중 ①인증판정이 비활성이고 ②③은 fail-open 이라 - * /mnt 하위라도 무인증 노출이 가능하므로, {@link #handlerUser()} 로 화면·엔드포인트 - * 단에서 직접 판정한다(PersonalDataAccessLogController 와 동일 방식).

- * - *
- * << 개정이력(Modification Information) >>
- *   수정일        수정자   수정내용
- *  ----------    ------  ---------------------------
- *  2026.08.07    URP     최초 생성 (개인정보보호 #62)
- * 
- */ -@Controller -public class ConectLogController { - - private static final String LOGIN_REDIRECT = "redirect:/uat/uia/egovLoginUsr.do"; - - @Resource(name = "conectLogService") - private ConectLogService conectLogService; - - /** - * 로그인·로그아웃 접속기록 조회 화면(서버 렌더링). - * 뷰: /WEB-INF/jsp/site/mnt/sec/conectLog/list.jsp - */ - @RequestMapping(value = "/mnt/sec/conectLog/listView.do") - public String listView(@ModelAttribute("conectLogVO") ConectLogVO searchVO, - ModelMap model) throws Exception { - if (handlerUser() == null) { - return LOGIN_REDIRECT; - } - - // selectConectLogList 내부에서 firstIndex/totalRecordCount 를 세팅한다. - List list = conectLogService.selectConectLogList(searchVO); - - PaginationInfo paginationInfo = new PaginationInfo(); - paginationInfo.setCurrentPageNo(searchVO.getPageIndex()); - paginationInfo.setRecordCountPerPage(searchVO.getRecordCountPerPage()); - paginationInfo.setPageSize(10); - paginationInfo.setTotalRecordCount(searchVO.getTotalRecordCount()); - - model.addAttribute("resultList", list); - model.addAttribute("paginationInfo", paginationInfo); - model.addAttribute("conectLcList", selectConectLcList()); - return "site/mnt/sec/conectLog/list"; - } - - /** 접속기록 목록(AJAX/JSON). */ - @RequestMapping(value = "/mnt/privacy/conectLogList.do") - public String selectList(@ModelAttribute("searchVO") ConectLogVO searchVO, - ModelMap model) throws Exception { - if (handlerUser() == null) { - model.addAttribute("result", "fail"); - model.addAttribute("message", "접근 권한이 없습니다. 관리자 계정으로 로그인 후 이용하세요."); - return "jsonView"; - } - model.addAttribute("resultList", conectLogService.selectConectLogList(searchVO)); - model.addAttribute("totalCount", Integer.valueOf(searchVO.getTotalRecordCount())); - return "jsonView"; - } - - /** - * 접속기록 다운로드(CSV) — 사유 필수 + 다운로드 이력 적재. - * 사유 미입력·차단 시 파일을 열지 않고 400 으로 응답한다. - */ - @RequestMapping(value = "/mnt/sec/conectLog/download.do") - public void download(@ModelAttribute("conectLogVO") ConectLogVO searchVO, - @RequestParam(value = "dlReason", required = false) String dlReason, - HttpServletRequest request, HttpServletResponse response) throws Exception { - - LoginVO user = handlerUser(); - if (user == null) { - response.sendError(HttpServletResponse.SC_FORBIDDEN, "접근 권한이 없습니다."); - return; - } - - List list; - try { - list = conectLogService.downloadConectLog(user.getId(), - ClientIpResolver.getClientIp(request), dlReason, searchVO); - } catch (IllegalArgumentException e) { - // 사유 미입력 등 차단 — 다운로드를 열지 않고 오류만 응답 - response.sendError(HttpServletResponse.SC_BAD_REQUEST, e.getMessage()); - return; - } - - String fileName = URLEncoder.encode( - "로그인접속기록_" + new SimpleDateFormat("yyyyMMddHHmmss").format(new Date()) + ".csv", "UTF-8"); - response.setContentType("text/csv; charset=UTF-8"); - response.setHeader("Content-Disposition", "attachment; filename=\"" + fileName + "\"; filename*=UTF-8''" + fileName); - response.setHeader("Content-Transfer-Encoding", "binary"); - - OutputStream os = response.getOutputStream(); - os.write(0xEF); os.write(0xBB); os.write(0xBF); // UTF-8 BOM (엑셀 한글 호환) - PrintWriter pw = new PrintWriter(new OutputStreamWriter(os, "UTF-8")); - pw.println("로그번호,접속일시,접속ID,성명,사용자구분,접속구분,접속IP,접속위치"); - if (list != null) { - for (ConectLogVO log : list) { - pw.println(csv(log.getLogId()) + "," + csv(log.getCreatDt()) - + "," + csv(log.getUserId()) + "," + csv(log.getUserNm()) - + "," + csv(userSeLabel(log.getUserSe())) - + "," + csv(mthdLabel(log.getConectMthd())) - + "," + csv(log.getConectIp()) + "," + csv(log.getConectLc())); - } - } - pw.flush(); - } - - /** - * 검색조건 드롭다운용 접속위치 목록. - * 조회 실패해도 화면은 떠야 하므로 빈 목록으로 fail-safe. - */ - private List selectConectLcList() { - try { - List list = conectLogService.selectConectLcList(); - return (list == null) ? new ArrayList() : list; - } catch (Exception e) { - return new ArrayList(); - } - } - - /** CSV 셀 이스케이프(구분자·따옴표·개행 방어). */ - private String csv(String v) { - if (v == null) { - return ""; - } - String s = v.replace("\"", "\"\""); - return "\"" + s.replace("\r", " ").replace("\n", " ") + "\""; - } - - private String mthdLabel(String code) { - if ("I".equals(code)) return "로그인"; - if ("O".equals(code)) return "로그아웃"; - return code; - } - - private String userSeLabel(String code) { - if ("USR".equals(code)) return "업무사용자"; - if ("GNR".equals(code)) return "개인"; - if ("GRO".equals(code)) return "그룹"; - if ("ENT".equals(code)) return "활동처"; - return code; - } - - /** - * 취급자 영역(/mnt) 인증·구분 판정. - * - * @return 인증된 업무사용자(USR) LoginVO, 그 외에는 null - */ - private LoginVO handlerUser() { - if (!Boolean.TRUE.equals(EgovUserDetailsHelper.isAuthenticated())) { - return null; - } - Object o = EgovUserDetailsHelper.getAuthenticatedUser(); - if (!(o instanceof LoginVO)) { - return null; - } - LoginVO loginVO = (LoginVO) o; - return "USR".equals(loginVO.getUserSe()) ? loginVO : null; - } -} diff --git a/src/main/java/egovframework/com/cmm/privacy/conect/ConectLogDAO.java b/src/main/java/egovframework/com/cmm/privacy/conect/ConectLogDAO.java deleted file mode 100644 index 3b751c0..0000000 --- a/src/main/java/egovframework/com/cmm/privacy/conect/ConectLogDAO.java +++ /dev/null @@ -1,52 +0,0 @@ -package egovframework.com.cmm.privacy.conect; - -import java.util.List; - -import org.springframework.stereotype.Repository; - -import egovframework.com.cmm.service.impl.EgovComAbstractDAO; - -/** - * 로그인·로그아웃 접속기록 DAO — 개인정보보호 기능개선 사업(#62). - * - *

{@code COMTNLOGINLOG} 조회 전용. 이 화면은 접속기록에 대한 수정·삭제를 - * 제공하지 않는다(위·변조 방지) — UPDATE/DELETE statement 를 정의하지 않는다. - * statement 는 {@code PrivacyConectLog_SQL_Mysql.xml} - * (namespace {@code ConectLogDAO}), sql-map-config 등록.

- * - * @author URP - * @since 2026-08-07 - */ -@Repository("conectLogDAO") -public class ConectLogDAO extends EgovComAbstractDAO { - - /** 조건별 목록(페이징). */ - @SuppressWarnings("unchecked") - public List selectConectLogList(ConectLogVO searchVO) { - return list("ConectLogDAO.selectConectLogList", searchVO); - } - - /** 조건별 총건수. */ - public int selectConectLogListCount(ConectLogVO searchVO) { - Object cnt = selectByPk("ConectLogDAO.selectConectLogListCount", searchVO); - return (cnt == null) ? 0 : ((Integer) cnt).intValue(); - } - - /** 다운로드용 목록(페이징 없음, 상한은 서비스에서 건다). */ - @SuppressWarnings("unchecked") - public List selectConectLogListForDownload(ConectLogVO searchVO) { - return list("ConectLogDAO.selectConectLogListForDownload", searchVO); - } - - /** - * 검색조건 드롭다운용 접속위치 목록. - * - *

설정 대역(CONECT_IP_LOCATION)과 실제 적재된 값(COMTNLOGINLOG DISTINCT)을 - * 합친다. #32-1 에서 겪은 함정 — 매핑 테이블만 보면 실제 적재값과 어긋난 선택지가 떠 - * 검색 결과가 항상 0건이 된다 — 을 피하기 위한 것이다.

- */ - @SuppressWarnings("unchecked") - public List selectConectLcList() { - return list("ConectLogDAO.selectConectLcList", null); - } -} diff --git a/src/main/java/egovframework/com/cmm/privacy/conect/ConectLogService.java b/src/main/java/egovframework/com/cmm/privacy/conect/ConectLogService.java deleted file mode 100644 index 12b4c18..0000000 --- a/src/main/java/egovframework/com/cmm/privacy/conect/ConectLogService.java +++ /dev/null @@ -1,31 +0,0 @@ -package egovframework.com.cmm.privacy.conect; - -import java.util.List; - -/** - * 로그인·로그아웃 접속기록 조회·다운로드 서비스 — 개인정보보호 기능개선 사업(#62). - * - * @author URP - * @since 2026-08-07 - */ -public interface ConectLogService { - - /** 조건별 접속기록 목록(페이징). searchVO 에 firstIndex/totalRecordCount 를 채워 돌려준다. */ - List selectConectLogList(ConectLogVO searchVO) throws Exception; - - /** 검색조건 드롭다운용 접속위치 목록. */ - List selectConectLcList() throws Exception; - - /** - * 접속기록 다운로드 — 사유 필수. - * - * @param downloaderId 다운로드자 계정ID - * @param downloaderIp 다운로드자 접속 IP - * @param dlReason 다운로드 사유(공백이면 차단) - * @param searchVO 검색조건 - * @return 다운로드 대상 목록 - * @throws IllegalArgumentException 사유 미입력 - */ - List downloadConectLog(String downloaderId, String downloaderIp, - String dlReason, ConectLogVO searchVO) throws Exception; -} diff --git a/src/main/java/egovframework/com/cmm/privacy/conect/ConectLogServiceImpl.java b/src/main/java/egovframework/com/cmm/privacy/conect/ConectLogServiceImpl.java deleted file mode 100644 index ead4be1..0000000 --- a/src/main/java/egovframework/com/cmm/privacy/conect/ConectLogServiceImpl.java +++ /dev/null @@ -1,122 +0,0 @@ -package egovframework.com.cmm.privacy.conect; - -import java.util.ArrayList; -import java.util.List; - -import javax.annotation.Resource; - -import org.springframework.stereotype.Service; - -import egovframework.com.cmm.privacy.auditlog.AccessLogDownloadHistoryDAO; -import egovframework.com.cmm.privacy.auditlog.AccessLogDownloadHistoryVO; - -/** - * 로그인·로그아웃 접속기록 조회·다운로드 서비스 구현 — 개인정보보호 기능개선 사업(#62). - * - *

다운로드 이력: 별도 테이블을 만들지 않고 {@code PDAL_DOWNLOAD_HISTORY}(#42)를 - * 재사용한다. 어떤 화면에서 받아간 것인지 구분되도록 검색조건 요약 앞에 - * {@code [로그인기록]} 을 붙인다. 개인정보 본문은 이력에 남기지 않는다(검색조건 요약만).

- * - *
- * << 개정이력(Modification Information) >>
- *   수정일        수정자   수정내용
- *  ----------    ------  ---------------------------
- *  2026.08.07    URP     최초 생성 (개인정보보호 #62)
- * 
- */ -@Service("conectLogService") -public class ConectLogServiceImpl implements ConectLogService { - - /** 1회 다운로드 상한(건). 무제한 반출·OOM 방지. */ - private static final int DOWNLOAD_LIMIT = 100000; - - /** 다운로드 이력 구분 표식(PDAL_DOWNLOAD_HISTORY 공용 사용). */ - private static final String DL_TAG = "[로그인기록] "; - - @Resource(name = "conectLogDAO") - private ConectLogDAO conectLogDAO; - - @Resource(name = "accessLogDownloadHistoryDAO") - private AccessLogDownloadHistoryDAO accessLogDownloadHistoryDAO; - - @Override - public List selectConectLogList(ConectLogVO searchVO) throws Exception { - normalize(searchVO); - searchVO.setFirstIndex((searchVO.getPageIndex() - 1) * searchVO.getRecordCountPerPage()); - searchVO.setTotalRecordCount(conectLogDAO.selectConectLogListCount(searchVO)); - return conectLogDAO.selectConectLogList(searchVO); - } - - @Override - public List selectConectLcList() throws Exception { - List list = conectLogDAO.selectConectLcList(); - return (list == null) ? new ArrayList() : list; - } - - @Override - public List downloadConectLog(String downloaderId, String downloaderIp, - String dlReason, ConectLogVO searchVO) throws Exception { - - // 사유 미입력이면 조회 자체를 하지 않는다 — 이력 없는 반출을 원천 차단. - if (dlReason == null || dlReason.trim().length() == 0) { - throw new IllegalArgumentException("다운로드 사유는 필수입니다."); - } - normalize(searchVO); - searchVO.setFirstIndex(0); - searchVO.setRecordCountPerPage(DOWNLOAD_LIMIT); - - List list = conectLogDAO.selectConectLogListForDownload(searchVO); - if (list == null) { - list = new ArrayList(); - } - - AccessLogDownloadHistoryVO history = new AccessLogDownloadHistoryVO(); - history.setDownloaderId(downloaderId); - history.setDownloaderIp(downloaderIp); - history.setDlReason(dlReason.trim()); - history.setSearchCond(summarize(searchVO)); - history.setResultCnt(list.size()); - accessLogDownloadHistoryDAO.insertDownloadHistory(history); - - return list; - } - - /** 페이징 파라미터 방어(음수·0·과대 요청). */ - private void normalize(ConectLogVO searchVO) { - if (searchVO.getPageIndex() < 1) { - searchVO.setPageIndex(1); - } - if (searchVO.getRecordCountPerPage() < 1 || searchVO.getRecordCountPerPage() > 100) { - searchVO.setRecordCountPerPage(10); - } - } - - /** - * 검색조건 요약(개인정보 본문 미포함). PDAL_DOWNLOAD_HISTORY.SEARCH_COND 는 - * VARCHAR(1000) 이므로 넘치면 자른다. - */ - private String summarize(ConectLogVO vo) { - StringBuilder sb = new StringBuilder(DL_TAG); - append(sb, "접속ID", vo.getSearchUserId()); - append(sb, "접속구분", vo.getSearchMthd()); - append(sb, "사용자구분", vo.getSearchUserSe()); - append(sb, "IP", vo.getSearchIp()); - append(sb, "접속위치", vo.getSearchLc()); - append(sb, "기간", nvl(vo.getSearchBgnDe()) + "~" + nvl(vo.getSearchEndDe())); - String s = sb.toString(); - return (s.length() > 1000) ? s.substring(0, 1000) : s; - } - - private void append(StringBuilder sb, String label, String value) { - if (value != null && value.trim().length() > 0 && !"~".equals(value.trim())) { - if (sb.length() > DL_TAG.length()) { - sb.append(", "); - } - sb.append(label).append('=').append(value.trim()); - } - } - - private String nvl(String s) { - return (s == null) ? "" : s.trim(); - } -} diff --git a/src/main/java/egovframework/com/cmm/privacy/conect/ConectLogVO.java b/src/main/java/egovframework/com/cmm/privacy/conect/ConectLogVO.java deleted file mode 100644 index 109202c..0000000 --- a/src/main/java/egovframework/com/cmm/privacy/conect/ConectLogVO.java +++ /dev/null @@ -1,115 +0,0 @@ -package egovframework.com.cmm.privacy.conect; - -import java.io.Serializable; - -/** - * 로그인·로그아웃 접속기록 VO — 개인정보보호 기능개선 사업(#62). - * - *

{@code COMTNLOGINLOG} 조회 결과 + 검색조건 + 페이징.

- * - *

접속ID 주의: {@code COMTNLOGINLOG.CONECT_ID}({@link #conectId})는 고유식별자 - * 자리인데 실제 적재값이 16자리 hex 라 회원 테이블 어디와도 매칭되지 않는다(2026-08-07 - * 373,959행 실측). 그래서 화면의 '접속ID'({@link #userId})는 조인 결과가 아니라 적재 시점에 - * 세션 {@code LoginVO.getId()} 를 직접 남긴 {@code CONECT_ACNT_ID} 값이다. - * 2026-08-07 이전 기록은 이 값이 없어 화면이 {@link #conectId} 를 대신 보여준다.

- * - *
- * << 개정이력(Modification Information) >>
- *   수정일        수정자   수정내용
- *  ----------    ------  ---------------------------
- *  2026.08.07    URP     최초 생성 (개인정보보호 #62)
- * 
- */ -public class ConectLogVO implements Serializable { - - private static final long serialVersionUID = 1L; - - // ---- 조회 결과 ---- - /** 로그 일련번호(COMTNLOGINLOG.LOG_ID). */ - private String logId; - /** 접속일시(yyyy-MM-dd HH:mm:ss). */ - private String creatDt; - /** 접속구분 I:로그인 O:로그아웃. */ - private String conectMthd; - /** 접속 IP. */ - private String conectIp; - /** 접속위치명(적재시점 스냅샷). */ - private String conectLc; - /** 고유식별자(원본 CONECT_ID). */ - private String conectId; - /** 접속ID(계정ID — CONECT_ACNT_ID. 2026-08-07 이전 레거시 기록은 null). */ - private String userId; - /** 성명/기관명(표시용 보조 조회 — 탈퇴 등으로 대조 실패 시 null). */ - private String userNm; - /** 사용자구분 USR:업무사용자 GNR:개인 GRO:그룹 ENT:활동처. */ - private String userSe; - /** 오류발생여부 Y/N. */ - private String errOccrrAt; - - // ---- 검색조건 ---- - /** 접속ID(계정ID, 부분일치). */ - private String searchUserId; - /** 접속구분(I/O, 공백=전체). */ - private String searchMthd; - /** 사용자구분(USR/GNR/GRO/ENT, 공백=전체). */ - private String searchUserSe; - /** 접속 IP(부분일치). */ - private String searchIp; - /** 접속위치(완전일치, 공백=전체). */ - private String searchLc; - /** 조회 시작일(yyyy-MM-dd). */ - private String searchBgnDe; - /** 조회 종료일(yyyy-MM-dd). */ - private String searchEndDe; - - // ---- 페이징 ---- - private int pageIndex = 1; - private int recordCountPerPage = 10; - private int firstIndex = 0; - private int totalRecordCount = 0; - - public String getLogId() { return logId; } - public void setLogId(String logId) { this.logId = logId; } - public String getCreatDt() { return creatDt; } - public void setCreatDt(String creatDt) { this.creatDt = creatDt; } - public String getConectMthd() { return conectMthd; } - public void setConectMthd(String conectMthd) { this.conectMthd = conectMthd; } - public String getConectIp() { return conectIp; } - public void setConectIp(String conectIp) { this.conectIp = conectIp; } - public String getConectLc() { return conectLc; } - public void setConectLc(String conectLc) { this.conectLc = conectLc; } - public String getConectId() { return conectId; } - public void setConectId(String conectId) { this.conectId = conectId; } - public String getUserId() { return userId; } - public void setUserId(String userId) { this.userId = userId; } - public String getUserNm() { return userNm; } - public void setUserNm(String userNm) { this.userNm = userNm; } - public String getUserSe() { return userSe; } - public void setUserSe(String userSe) { this.userSe = userSe; } - public String getErrOccrrAt() { return errOccrrAt; } - public void setErrOccrrAt(String errOccrrAt) { this.errOccrrAt = errOccrrAt; } - - public String getSearchUserId() { return searchUserId; } - public void setSearchUserId(String searchUserId) { this.searchUserId = searchUserId; } - public String getSearchMthd() { return searchMthd; } - public void setSearchMthd(String searchMthd) { this.searchMthd = searchMthd; } - public String getSearchUserSe() { return searchUserSe; } - public void setSearchUserSe(String searchUserSe) { this.searchUserSe = searchUserSe; } - public String getSearchIp() { return searchIp; } - public void setSearchIp(String searchIp) { this.searchIp = searchIp; } - public String getSearchLc() { return searchLc; } - public void setSearchLc(String searchLc) { this.searchLc = searchLc; } - public String getSearchBgnDe() { return searchBgnDe; } - public void setSearchBgnDe(String searchBgnDe) { this.searchBgnDe = searchBgnDe; } - public String getSearchEndDe() { return searchEndDe; } - public void setSearchEndDe(String searchEndDe) { this.searchEndDe = searchEndDe; } - - public int getPageIndex() { return pageIndex; } - public void setPageIndex(int pageIndex) { this.pageIndex = pageIndex; } - public int getRecordCountPerPage() { return recordCountPerPage; } - public void setRecordCountPerPage(int recordCountPerPage) { this.recordCountPerPage = recordCountPerPage; } - public int getFirstIndex() { return firstIndex; } - public void setFirstIndex(int firstIndex) { this.firstIndex = firstIndex; } - public int getTotalRecordCount() { return totalRecordCount; } - public void setTotalRecordCount(int totalRecordCount) { this.totalRecordCount = totalRecordCount; } -} diff --git a/src/main/java/egovframework/com/cmm/privacy/conect/IpLocationDAO.java b/src/main/java/egovframework/com/cmm/privacy/conect/IpLocationDAO.java index 0641f67..9ab2e0e 100644 --- a/src/main/java/egovframework/com/cmm/privacy/conect/IpLocationDAO.java +++ b/src/main/java/egovframework/com/cmm/privacy/conect/IpLocationDAO.java @@ -10,7 +10,10 @@ import egovframework.com.cmm.service.impl.EgovComAbstractDAO; * 접속위치 매핑 DAO — 개인정보보호 기능개선 사업(#62). * *

statement 는 {@code PrivacyConectLog_SQL_Mysql.xml} - * (namespace {@code ConectLogDAO})에 함께 정의되어 있다.

+ * (namespace {@code ConectLogDAO})에 정의되어 있다. 2026-08-11 로그인·로그아웃 접속기록 + * 조회화면이 삭제되면서 그 파일에 남은 statement 는 이것 하나뿐이지만, statement id 를 + * 바꾸면 iBatis2 는 기동 시가 아니라 최초 호출 시점에 실패하고 {@link IpLocationResolver} + * 가 그 예외를 삼켜 CONECT_LC 가 조용히 전부 NULL 이 된다. namespace 를 그대로 둔다.

* * @author URP * @since 2026-08-07 diff --git a/src/main/java/egovframework/com/cmm/privacy/crypto/MemberNmCryptoController.java b/src/main/java/egovframework/com/cmm/privacy/crypto/MemberNmCryptoController.java new file mode 100644 index 0000000..54da7fc --- /dev/null +++ b/src/main/java/egovframework/com/cmm/privacy/crypto/MemberNmCryptoController.java @@ -0,0 +1,181 @@ +package egovframework.com.cmm.privacy.crypto; + +import java.util.Map; + +import javax.annotation.Resource; +import javax.servlet.http.HttpServletRequest; + +import org.springframework.stereotype.Controller; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.ResponseBody; + +import egovframework.com.cmm.LoginVO; +import egovframework.com.cmm.util.EgovUserDetailsHelper; + +/** + * 회원 이름 암호화 백필(Action4) 운영 반영 트리거 — 관리자 전용, 1회성 이관 도구. + * + *

왜 컨트롤러가 필요한가: {@link MemberNmCryptoWorker}는 이미 + * {@code @Component("memberNmCryptoWorker")}로 빈에 올라가 있지만 시작·조회·중지를 + * 호출할 진입점이 없었다(로컬 백필은 과거 세션에서 임시 경로로 1회 실행된 것으로 + * 추정 — 운영에는 재사용 불가). 상주 스케줄러에 넣지 않기로 한 설계(Worker Javadoc + * 참조)와 일관되게, 관리자가 명시적으로 시작·중지하는 이 화면 없는 API 엔드포인트로 + * 대체한다.

+ * + *

접근통제: URL이 {@code /mnt/sec/} 대역이라 별도 설정 없이 기존 인터셉터 + * 3종이 그대로 적용된다 — AuthorityInterceptor(ROLE_ADMIN, urlRoleMapping + * {@code \A/mnt/sec/.*\Z}) · SecondFactorInterceptor(2차 인증) · + * MenuAccessLogInterceptor(includePatterns {@code \A/mnt/(sec|privacy)/.*\Z} — 실행 + * 이력이 접속기록에 남는다). {@link #handlerUser()}는 그 위에 얹는 방어 계층이다 + * (다른 /mnt/sec 개인정보 화면과 동일 규약).

+ * + *

메뉴에 올리지 않는다. 1회성 이관 도구를 상시 네비게이션에 노출하면 + * 이관 종료 후에도 실수로 재클릭될 여지가 남는다. URL을 아는 관리자만 + * 호출한다(운영 반영 절차 문서에 URL 기록).

+ * + *

재실행 안전: {@link MemberNmCryptoWorker#start}는 {@code MBER_NM_ENC IS NULL} + * 조건으로 대상을 고르므로, 이미 끝난 뒤 재시작해도 대상 0건으로 즉시 종료된다.

+ * + *
+ * << 개정이력(Modification Information) >>
+ *   수정일        수정자   수정내용
+ *  ----------    ------  ---------------------------
+ *  2026.08.11    URP     최초 생성 — 운영 컷오버 델타 갭 해소(회원 이름 백필 트리거)
+ * 
+ */ +@Controller +public class MemberNmCryptoController { + + @Resource(name = "memberNmCryptoWorker") + private MemberNmCryptoWorker worker; + + /** 진행상태 조회. GET 허용(부작용 없음) — 화면 폴링용으로 쓸 수 있게. */ + @RequestMapping(value = "/mnt/sec/memberNmCrypto/status.do") + @ResponseBody + public Map status() { + if (handlerUser() == null) { + return null; + } + Map progress = worker.getProgress(); + if (progress != null) { + progress.put("running", Boolean.valueOf(worker.isRunning())); + } + return progress; + } + + /** + * 백필 시작. 실수 클릭 방지를 위해 {@code confirm=BACKFILL} 파라미터를 강제한다. + * 이미 실행 중이면 무시(중복 시작 방지는 Worker 내부에서도 한 번 더 걸린다). + */ + @RequestMapping(value = "/mnt/sec/memberNmCrypto/start.do") + @ResponseBody + public String start(@RequestParam(value = "confirm", required = false) String confirm, + @RequestParam(value = "chunkSize", required = false, defaultValue = "1000") int chunkSize, + @RequestParam(value = "sleepMs", required = false, defaultValue = "50") long sleepMs, + HttpServletRequest request) { + LoginVO user = handlerUser(); + if (user == null) { + return "ERROR: 인증되지 않은 요청입니다."; + } + if (!isPost(request) || !isSameOrigin(request)) { + return "ERROR: 잘못된 요청 경로입니다."; + } + if (!"BACKFILL".equals(confirm)) { + return "ERROR: confirm=BACKFILL 파라미터 없이는 시작하지 않습니다(실수 방지)."; + } + boolean started = worker.start(chunkSize, sleepMs); + return started + ? "OK: 백필을 시작했습니다(requester=" + user.getId() + "). /status.do 로 진행률을 확인하세요." + : "SKIP: 이미 실행 중입니다."; + } + + /** 중지 요청. 진행 중인 청크는 마치고 멈춘다(커서 보존, 재시작 시 이어감). */ + @RequestMapping(value = "/mnt/sec/memberNmCrypto/stop.do") + @ResponseBody + public String stop(HttpServletRequest request) { + if (handlerUser() == null) { + return "ERROR: 인증되지 않은 요청입니다."; + } + if (!isPost(request) || !isSameOrigin(request)) { + return "ERROR: 잘못된 요청 경로입니다."; + } + worker.requestStop(); + return "OK: 중지를 요청했습니다. 진행 중인 청크가 끝나면 멈춥니다."; + } + + /** + * 이관 검증(전건 복호화 대사). 77,053건 기준 수십 초~수 분 소요 — 요청 스레드를 + * 그만큼 점유한다. 1회성 도구라 비동기화하지 않았다(뷰 컷오버 직전 1회 호출 용도). + */ + @RequestMapping(value = "/mnt/sec/memberNmCrypto/verify.do") + @ResponseBody + public String verify(@RequestParam(value = "chunkSize", required = false, defaultValue = "1000") int chunkSize, + HttpServletRequest request) { + if (handlerUser() == null) { + return "ERROR: 인증되지 않은 요청입니다."; + } + if (!isPost(request) || !isSameOrigin(request)) { + return "ERROR: 잘못된 요청 경로입니다."; + } + if (worker.isRunning()) { + return "ERROR: 백필이 아직 실행 중입니다 — 완료 후 검증하세요."; + } + return worker.verify(chunkSize); + } + + private boolean isPost(HttpServletRequest request) { + return "POST".equalsIgnoreCase(request.getMethod()); + } + + /** + * 취급자 영역(/mnt) 인증·구분 판정(다른 /mnt/sec 개인정보 화면과 동일 규약). + * + * @return 인증된 업무사용자(USR) LoginVO, 그 외에는 null + */ + private LoginVO handlerUser() { + if (!Boolean.TRUE.equals(EgovUserDetailsHelper.isAuthenticated())) { + return null; + } + Object o = EgovUserDetailsHelper.getAuthenticatedUser(); + if (!(o instanceof LoginVO)) { + return null; + } + LoginVO loginVO = (LoginVO) o; + return "USR".equals(loginVO.getUserSe()) ? loginVO : null; + } + + private boolean isSameOrigin(HttpServletRequest request) { + String source = request.getHeader("Origin"); + if (source == null) { + source = request.getHeader("Referer"); + } + if (source == null) { + return true; + } + String sourceHost = hostOf(source); + String requestHost = hostOf(request.getHeader("Host")); + return sourceHost != null && sourceHost.equalsIgnoreCase(requestHost); + } + + /** "https://host:8080/path" · "host:8080" 등에서 호스트명만 추출. */ + private String hostOf(String value) { + if (value == null) { + return null; + } + String host = value; + int scheme = host.indexOf("://"); + if (scheme >= 0) { + host = host.substring(scheme + 3); + } + int slash = host.indexOf('/'); + if (slash >= 0) { + host = host.substring(0, slash); + } + int colon = host.indexOf(':'); + if (colon >= 0) { + host = host.substring(0, colon); + } + return host.length() == 0 ? null : host; + } +} diff --git a/src/main/java/egovframework/com/cmm/privacy/interceptor/SessionPolicyInterceptor.java b/src/main/java/egovframework/com/cmm/privacy/interceptor/SessionPolicyInterceptor.java index 5923a1e..0d1e77c 100644 --- a/src/main/java/egovframework/com/cmm/privacy/interceptor/SessionPolicyInterceptor.java +++ b/src/main/java/egovframework/com/cmm/privacy/interceptor/SessionPolicyInterceptor.java @@ -52,6 +52,16 @@ public class SessionPolicyInterceptor extends HandlerInterceptorAdapter { /** 로그인 시각(ms) 세션 키. top.jsp 경과시간 표시가 참조한다. */ public static final String ATTR_LOGIN_TIME = "PRIVACY_LOGIN_TIME"; + /** + * 로그인 시각을 찍을 당시의 계정 ID 세션 키. + * + *

시각만 들고 있으면 다른 계정으로 갈아탄 경우를 구분하지 못한다. + * 수동 로그아웃({@code actionLogout})은 세션을 무효화하지 않고 {@code loginVO} 만 null 로 + * 바꾸므로 이 세션 속성들이 그대로 살아남기 때문이다. 계정 ID 를 함께 들고 있다가 + * 달라지면 새 로그인으로 보고 시각을 다시 찍는다.

+ */ + public static final String ATTR_LOGIN_ID = "PRIVACY_LOGIN_ACNT_ID"; + /** 적용 중인 자동 로그아웃 시간(초) 세션 키. top.jsp 카운트다운이 참조한다. */ public static final String ATTR_AUTO_LOGOUT_SEC = "PRIVACY_AUTO_LOGOUT_SEC"; @@ -88,13 +98,25 @@ public class SessionPolicyInterceptor extends HandlerInterceptorAdapter { // 세션을 새로 만들지 않는다 — 무인증 요청까지 세션을 생성하면 불필요한 부하가 된다. HttpSession session = request.getSession(false); - if (session == null || session.getAttribute(ATTR_LOGIN_VO) == null) { + if (session == null) { return true; } - // 로그인 시각 도장 — 로그인 후 최초 1회만. - if (session.getAttribute(ATTR_LOGIN_TIME) == null) { - session.setAttribute(ATTR_LOGIN_TIME, Long.valueOf(System.currentTimeMillis())); + Object loginVO = session.getAttribute(ATTR_LOGIN_VO); + if (loginVO == null) { + // 로그아웃 상태 — 남아 있는 로그인 시각 도장을 지운다. 안 지우면 같은 세션으로 + // 다시 로그인했을 때 '직전 로그인 시각' 기준으로 경과시간이 계산돼 + // 로그인 직후에 "1시간 N분 경과" 같은 값이 뜬다. + clearLoginStamp(session); + return true; + } + + // 로그인 시각 도장 — 계정이 바뀌었거나 도장이 없으면 새로 찍는다. + String acntId = accountIdOf(loginVO); + Object stampedAt = session.getAttribute(ATTR_LOGIN_TIME); + Object stampedId = session.getAttribute(ATTR_LOGIN_ID); + if (stampedAt == null || stampedId == null || !stampedId.equals(acntId)) { + stampLogin(session, acntId); } // 자동 로그아웃 시간 적용(정책값). 미주입 시 web.xml 기본값 유지. @@ -119,6 +141,45 @@ public class SessionPolicyInterceptor extends HandlerInterceptorAdapter { return true; } + /** + * 로그인 시각 도장을 찍는다(지금 시각 기준). + * + *

인증 성공 지점({@code EgovLoginController.actionLogin})에서 직접 호출하면 + * 인터셉터를 기다리지 않고 정확한 로그인 시점이 기록된다.

+ * + * @param session 로그인 세션 + * @param acntId 로그인 계정 ID(널 허용) + */ + public static void stampLogin(HttpSession session, String acntId) { + if (session == null) { + return; + } + session.setAttribute(ATTR_LOGIN_TIME, Long.valueOf(System.currentTimeMillis())); + session.setAttribute(ATTR_LOGIN_ID, acntId == null ? "" : acntId); + } + + /** + * 로그인 시각 도장을 지운다. 로그아웃 지점에서 호출한다. + * + * @param session 세션(널이면 무시) + */ + public static void clearLoginStamp(HttpSession session) { + if (session == null) { + return; + } + session.removeAttribute(ATTR_LOGIN_TIME); + session.removeAttribute(ATTR_LOGIN_ID); + } + + /** 세션의 loginVO 에서 계정 ID 를 꺼낸다. 타입이 달라도 깨지지 않게 리플렉션 없이 방어적으로 처리. */ + private static String accountIdOf(Object loginVO) { + if (loginVO instanceof egovframework.com.cmm.LoginVO) { + String id = ((egovframework.com.cmm.LoginVO) loginVO).getId(); + return id == null ? "" : id; + } + return ""; + } + /** * 로그인 후 경과 시간(초). 도장이 없으면 세션 생성시각을 대신 쓴다. * diff --git a/src/main/resources/egovframework/spring/com/context-syslogaop.xml b/src/main/resources/egovframework/spring/com/context-syslogaop.xml index a31d38b..113be19 100644 --- a/src/main/resources/egovframework/spring/com/context-syslogaop.xml +++ b/src/main/resources/egovframework/spring/com/context-syslogaop.xml @@ -25,67 +25,25 @@ --> - - \ No newline at end of file diff --git a/src/main/resources/egovframework/sqlmap/config/mysql/sql-map-config-mysql-0-site.xml b/src/main/resources/egovframework/sqlmap/config/mysql/sql-map-config-mysql-0-site.xml index 6f9d77e..7ed2e38 100644 --- a/src/main/resources/egovframework/sqlmap/config/mysql/sql-map-config-mysql-0-site.xml +++ b/src/main/resources/egovframework/sqlmap/config/mysql/sql-map-config-mysql-0-site.xml @@ -112,9 +112,6 @@ - - - @@ -139,7 +136,9 @@ - + diff --git a/src/main/resources/egovframework/sqlmap/site/mnt/biz/privacy/PrivacyAuthorHistDownloadJob_SQL_Mysql.xml b/src/main/resources/egovframework/sqlmap/site/mnt/biz/privacy/PrivacyAuthorHistDownloadJob_SQL_Mysql.xml index 974bace..6164e12 100644 --- a/src/main/resources/egovframework/sqlmap/site/mnt/biz/privacy/PrivacyAuthorHistDownloadJob_SQL_Mysql.xml +++ b/src/main/resources/egovframework/sqlmap/site/mnt/biz/privacy/PrivacyAuthorHistDownloadJob_SQL_Mysql.xml @@ -62,6 +62,12 @@ + - - FROM COMTNLOGINLOG a - - ORDER BY a.CREAT_DT DESC, a.LOG_ID DESC - LIMIT #firstIndex#, #recordCountPerPage# - - - - - - - - - - - - - SELECT * FROM ( - SELECT inner_t.*, ROWNUM AS rnum FROM ( - - FROM COMTNLOGINLOG a - - ORDER BY a.CREAT_DT DESC, a.LOG_ID DESC - ) inner_t - WHERE ROWNUM <= #firstIndex# + #recordCountPerPage# - ) - WHERE rnum > #firstIndex# - - - - - - - - @@ -84,6 +100,8 @@ EXPIRE_DT AS expireDt FROM PDAL_DOWNLOAD_JOB WHERE REQUESTER_ID = #requesterId# + AND STATUS <> 'X' + AND (EXPIRE_DT IS NULL OR EXPIRE_DT > NOW()) ORDER BY JOB_ID DESC LIMIT #limitCnt# ]]> diff --git a/src/main/resources/egovframework/sqlmap/site/mnt/biz/privacy/PrivacyDownloadJob_SQL_Oracle.xml b/src/main/resources/egovframework/sqlmap/site/mnt/biz/privacy/PrivacyDownloadJob_SQL_Oracle.xml index ae2de63..f17c6a6 100644 --- a/src/main/resources/egovframework/sqlmap/site/mnt/biz/privacy/PrivacyDownloadJob_SQL_Oracle.xml +++ b/src/main/resources/egovframework/sqlmap/site/mnt/biz/privacy/PrivacyDownloadJob_SQL_Oracle.xml @@ -85,6 +85,8 @@ EXPIRE_DT AS expireDt FROM PDAL_DOWNLOAD_JOB WHERE REQUESTER_ID = #requesterId# + AND STATUS <> 'X' + AND (EXPIRE_DT IS NULL OR EXPIRE_DT > SYSDATE) AND ROWNUM <= #limitCnt# ORDER BY JOB_ID DESC ]]> diff --git a/src/main/resources/egovframework/sqlmap/site/mnt/biz/privacy/PrivacyJobMapping_SQL_Mysql.xml b/src/main/resources/egovframework/sqlmap/site/mnt/biz/privacy/PrivacyJobMapping_SQL_Mysql.xml deleted file mode 100644 index 784cda5..0000000 --- a/src/main/resources/egovframework/sqlmap/site/mnt/biz/privacy/PrivacyJobMapping_SQL_Mysql.xml +++ /dev/null @@ -1,165 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/src/main/resources/egovframework/sqlmap/site/mnt/biz/privacy/PrivacyJobMapping_SQL_Oracle.xml b/src/main/resources/egovframework/sqlmap/site/mnt/biz/privacy/PrivacyJobMapping_SQL_Oracle.xml deleted file mode 100644 index 9055742..0000000 --- a/src/main/resources/egovframework/sqlmap/site/mnt/biz/privacy/PrivacyJobMapping_SQL_Oracle.xml +++ /dev/null @@ -1,163 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/src/main/webapp/WEB-INF/config/egovframework/springmvc/egov-com-servlet.xml b/src/main/webapp/WEB-INF/config/egovframework/springmvc/egov-com-servlet.xml index b9331c1..5686b1c 100644 --- a/src/main/webapp/WEB-INF/config/egovframework/springmvc/egov-com-servlet.xml +++ b/src/main/webapp/WEB-INF/config/egovframework/springmvc/egov-com-servlet.xml @@ -22,6 +22,19 @@ + + diff --git a/src/main/webapp/WEB-INF/jsp/site/mnt/left.jsp b/src/main/webapp/WEB-INF/jsp/site/mnt/left.jsp index a4ffbd6..5675110 100644 --- a/src/main/webapp/WEB-INF/jsp/site/mnt/left.jsp +++ b/src/main/webapp/WEB-INF/jsp/site/mnt/left.jsp @@ -48,13 +48,13 @@ || param.page eq '9100022' || param.page eq '9100031' || param.page eq '9100034' || param.page eq '9100041' || param.page eq '9100042'|| param.page eq '9100043' || param.page eq '9100071' || param.page eq '9100072' || param.page eq '9100080' - || param.page eq '9100091' || param.page eq '9100092' || param.page eq '9100093' || param.page eq '9100035' || param.page eq '9100036' || param.page eq '9100037' || param.page eq '9100038' || param.page eq '9100039' || param.page eq '9100044' || param.page eq '9100045' || param.page eq '9100046' || param.page eq '9100047' || param.page eq ''}">class="on">시스템관리 + || param.page eq '9100091' || param.page eq '9100092' || param.page eq '9100093' || param.page eq '9100035' || param.page eq '9100036' || param.page eq '9100037' || param.page eq '9100038' || param.page eq '9100039' || param.page eq '9100044' || param.page eq '9100047' || param.page eq ''}">class="on">시스템관리
    style="display: block;"> + || param.page eq '9100091' || param.page eq '9100092' || param.page eq '9100093' || param.page eq '9100035' || param.page eq '9100036' || param.page eq '9100037' || param.page eq '9100038' || param.page eq '9100039' || param.page eq '9100044' || param.page eq '9100047' || param.page eq ''}">style="display: block;">
  • class="on">${menuUpperVo2.menuNm} @@ -94,10 +94,10 @@
  • -
  • class="on">${menuUpperVo2.menuNm} +
  • class="on">${menuUpperVo2.menuNm} -
      style="display: block;"> +
        style="display: block;"> @@ -126,14 +126,7 @@
      • class="on">${menuUpperVo4.menuNm}
      • - <%-- [개인정보/#32-1] 업무유형 매핑 관리 (접속기록 수행업무 분류 기준) --%> - -
      • class="on">${menuUpperVo4.menuNm}
      • -
        - <%-- [개인정보/#62] 로그인·로그아웃 접속기록 조회·다운로드 --%> - -
      • class="on">${menuUpperVo4.menuNm}
      • -
        + <%-- [2026-08-11] 9100045 업무유형 매핑 관리 / 9100046 로그인·로그아웃 접속기록 분기 삭제(화면·메뉴 폐기) --%> <%-- [개인정보/SER-001 요건4·5] 접근통제 정책 설정(계정잠금·자동로그아웃·세션재발급) --%>
      • class="on">${menuUpperVo4.menuNm}
      • diff --git a/src/main/webapp/WEB-INF/jsp/site/mnt/sec/accessLog/list.jsp b/src/main/webapp/WEB-INF/jsp/site/mnt/sec/accessLog/list.jsp index d834a0d..774cca1 100644 --- a/src/main/webapp/WEB-INF/jsp/site/mnt/sec/accessLog/list.jsp +++ b/src/main/webapp/WEB-INF/jsp/site/mnt/sec/accessLog/list.jsp @@ -300,7 +300,8 @@ function fn_dml(gubun) {
        내 다운로드 요청 - 파일은 암호화 보관되며 만료 시 자동 파기됩니다. 요청자 본인만 내려받을 수 있습니다. + 파일은 암호화 보관되며 만료 시 자동 파기됩니다. 요청자 본인만 내려받을 수 있습니다. + 만료 전 요청만 표시되며, 만료된 요청도 이력에는 그대로 보존됩니다. diff --git a/src/main/webapp/WEB-INF/jsp/site/mnt/sec/authorHistory/list.jsp b/src/main/webapp/WEB-INF/jsp/site/mnt/sec/authorHistory/list.jsp index 20b5088..40e1db3 100644 --- a/src/main/webapp/WEB-INF/jsp/site/mnt/sec/authorHistory/list.jsp +++ b/src/main/webapp/WEB-INF/jsp/site/mnt/sec/authorHistory/list.jsp @@ -224,7 +224,8 @@ function fn_dlEsc(s) {
        내 다운로드 요청 - 파일은 암호화 보관되며 만료 시 자동 파기됩니다. 요청자 본인만 내려받을 수 있습니다. + 파일은 암호화 보관되며 만료 시 자동 파기됩니다. 요청자 본인만 내려받을 수 있습니다. + 만료 전 요청만 표시되며, 만료된 요청도 이력에는 그대로 보존됩니다.
        내 다운로드 요청 목록
        diff --git a/src/main/webapp/WEB-INF/jsp/site/mnt/sec/conectLog/list.jsp b/src/main/webapp/WEB-INF/jsp/site/mnt/sec/conectLog/list.jsp deleted file mode 100644 index b2be44c..0000000 --- a/src/main/webapp/WEB-INF/jsp/site/mnt/sec/conectLog/list.jsp +++ /dev/null @@ -1,209 +0,0 @@ -<%@ page language="java" contentType="text/html; charset=utf-8" pageEncoding="utf-8"%> -<%@ taglib prefix="c" uri="http://java.sun.com/jsp/jstl/core"%><%-- Head --%> -<%--// Head --%> -<%@ taglib prefix="fn" uri="http://java.sun.com/jsp/jstl/functions"%> -<%@ taglib prefix="ui" uri="http://egovframework.gov/ctl/ui"%> -<%@ taglib prefix="fmt" uri="http://java.sun.com/jsp/jstl/fmt"%> -<%@ taglib prefix="form" uri="http://www.springframework.org/tags/form"%> -<%@ taglib prefix="spring" uri="http://www.springframework.org/tags"%> - -<%-- - 로그인·로그아웃 접속기록 조회·다운로드 (개인정보보호 #62) - · COMTNLOGINLOG 적재분(접속ID·접속일시·접속IP·접속위치)을 조건별로 검색 - · 접속ID 는 원본 CONECT_ID(고유식별자)가 아니라 회원 테이블 조인으로 얻은 계정ID - · 다운로드는 사유 필수 — 미입력 시 서버가 400 으로 차단하고 파일을 열지 않음 - · 접속기록 위·변조 방지 — 이 화면은 수정·삭제를 제공하지 않는다 ---%> - - - - - - - - - - - - - - - - - -
        - -
        - - -
        - · 시스템 로그인·로그아웃 기록입니다. 기간은 yyyy-MM-dd 형식으로 입력하세요.
        - · 접속ID는 로그인에 사용된 계정ID를 기록 시점에 그대로 저장한 값입니다. 성명은 회원정보에서 보조 조회하며, 탈퇴 등으로 대조되지 않으면 비어 있습니다.
        - · 접속위치는 접속 IP가 속한 대역(CONECT_IP_LOCATION 매핑)을 기록 시점에 판정해 저장한 값입니다. 이후 매핑을 바꿔도 지난 기록은 변하지 않습니다.
        - · 2026-08-07 이전 기록은 접속ID·접속IP·접속위치가 비어 있습니다 — 종전 적재 로직이 IP를 남기지 않았고, 저장된 고유식별자로는 계정을 특정할 수 없습니다(소급 복원 불가). 이 경우 고유식별자를 회색으로 표시합니다.
        - · 다운로드는 사유 입력이 필수이며, 다운로드자·일시·IP·사유·건수가 다운로드 이력에 기록됩니다. 접속기록의 수정·삭제는 제공하지 않습니다. -
        - -
        - 총 건 - -
        - -
        내 다운로드 요청 목록
        - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
        로그인·로그아웃 접속기록 목록
        NO.접속일시접속ID성명사용자구분접속구분접속IP접속위치
        - - - - - - - 업무사용자 - 개인 - 그룹 - 활동처 - - - - - - 로그인 - 로그아웃 - - - - - - 미기록 - - - - - 미기록 - -
        - -
        - -
        - - - -
        - - - - - - - diff --git a/src/main/webapp/WEB-INF/jsp/site/mnt/sec/jobMapping/list.jsp b/src/main/webapp/WEB-INF/jsp/site/mnt/sec/jobMapping/list.jsp deleted file mode 100644 index 9867239..0000000 --- a/src/main/webapp/WEB-INF/jsp/site/mnt/sec/jobMapping/list.jsp +++ /dev/null @@ -1,253 +0,0 @@ -<%@ page language="java" contentType="text/html; charset=utf-8" pageEncoding="utf-8"%> -<%@ taglib prefix="c" uri="http://java.sun.com/jsp/jstl/core"%><%-- Head --%> -<%--// Head --%> -<%@ taglib prefix="fn" uri="http://java.sun.com/jsp/jstl/functions"%> -<%@ taglib prefix="ui" uri="http://egovframework.gov/ctl/ui"%> -<%@ taglib prefix="form" uri="http://www.springframework.org/tags/form"%> -<%@ taglib prefix="spring" uri="http://www.springframework.org/tags"%> - -<%-- - 업무유형 매핑 관리 (개인정보보호 SFR-004 / SER-002 / #32-1) - · PDAL_JOB_MAPPING(#32) 등록·수정·사용중지·삭제. 접속기록 수집 Aspect(#31)의 분류 기준. - · 저장 즉시 JobMappingResolver 캐시가 무효화되어 WAS 재기동 없이 반영된다. - · 상태변경(save/useAt/delete/refresh)은 모두 POST — 링크 클릭으로는 변경되지 않는다. ---%> - - - - - - - - - - - - - <%-- 상태변경 전용 POST 폼(목록 검색폼과 분리) --%> -
        - - -
        - - - - <%-- 검색·페이징 후에도 좌측메뉴 하이라이트가 유지되도록 메뉴번호를 유지한다(left.jsp 는 param.page 로 판정). --%> - - - -
        - -
        - - - -
        -
        - -
        - · 접속기록(개인정보 처리기록)의 수행업무 분류 기준입니다. 수집 Aspect가 가로챈 (대상 클래스, 메서드명)을 업무코드·업무명으로 해석합니다.
        - · 개인정보 화면이 새로 생기면 이 표에 행만 추가하면 분류됩니다(소스 수정 불요). 저장 즉시 반영되며 WAS 재기동이 필요 없습니다.
        - · 매핑을 지워도 수집이 멈추지는 않습니다. 수집 대상은 context-syslogaop.xml 의 pointcut이 정하며, 매핑이 없으면 메서드명 기반 기본 분류(insert→등록, update→수정, delete→삭제, 그 외 조회)로 적재됩니다.
        - · 한 메서드에 여러 매핑이 걸리면 우선순위(작은 값)가 이깁니다. 구체적인 규칙은 낮게, * 폴백은 높게 두세요. -
        - -
        - 총 건 - - -
        - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
        업무유형 매핑 목록
        순위대상 클래스메서드 패턴업무코드 / 업무명처리구분사용최종변경관리
        - -
        -
        -
        - -
        - - 조회 - 등록 - 수정 - 삭제 - 자동 - - - - 중지 - 사용 - - - - 시드 - -
        -
        -
        -
        - - - - - - - - - - -
        - -
        - -
        - -
        - - - - - - - - - diff --git a/src/main/webapp/WEB-INF/jsp/site/mnt/sec/jobMapping/regist.jsp b/src/main/webapp/WEB-INF/jsp/site/mnt/sec/jobMapping/regist.jsp deleted file mode 100644 index 22f34cb..0000000 --- a/src/main/webapp/WEB-INF/jsp/site/mnt/sec/jobMapping/regist.jsp +++ /dev/null @@ -1,188 +0,0 @@ -<%@ page language="java" contentType="text/html; charset=utf-8" pageEncoding="utf-8"%> -<%@ taglib prefix="c" uri="http://java.sun.com/jsp/jstl/core"%><%-- Head --%> -<%--// Head --%> -<%@ taglib prefix="fn" uri="http://java.sun.com/jsp/jstl/functions"%> -<%@ taglib prefix="form" uri="http://www.springframework.org/tags/form"%> - -<%-- - 업무유형 매핑 등록·수정 (개인정보보호 SFR-004 / SER-002 / #32-1) - · mappingId 가 0 이면 등록, 그 외는 수정(컨트롤러 save.do 가 분기). - · 저장은 POST 전용. 서버측에서 형식·중복을 다시 검증하므로 여기 검사는 1차 안내용이다. ---%> - - - - - - - - - - - - - -
        - · 접속기록 수집 Aspect가 가로챈 (대상 클래스, 메서드명)을 업무코드·업무명으로 분류하는 규칙 한 건입니다.
        - · 대상 클래스는 수집 대상(pointcut)에 이미 포함된 서비스여야 의미가 있습니다. 여기 등록한다고 수집 범위가 넓어지지는 않습니다 - (수집 범위 변경은 context-syslogaop.xml 수정·배포가 필요합니다).
        - · 저장하면 매핑 캐시가 즉시 갱신되어 WAS 재기동 없이 반영됩니다. -
        - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
        업무유형 매핑 수정등록
        대상 클래스 * - - 서비스 impl 단순 클래스명(예: EgovMberManageServiceImpl) 또는 패키지 포함 FQN. 200자 이내. -
        메서드 패턴 * - - 메서드명 접두 매칭(예: select, insertMber) 또는 *(해당 클래스 전체). -
        업무코드 * - - 영문 대문자·숫자·밑줄 30자 이내(예: MBER_VIEW). 소문자로 입력해도 대문자로 저장됩니다. 접속기록 검색 드롭다운의 값이 됩니다. -
        업무명 * - - 화면·기능명(예: 회원관리-조회). 접속기록 목록의 '수행업무' 열에 그대로 표시됩니다. 100자 이내. -
        처리구분 - - - - - - - - '자동' 선택 시 메서드명 접두로 유도합니다(insert·regist·creat·add·save→등록 / update·modify·chang→수정 / delete·remov→삭제 / 그 외→조회). -
        우선순위 - - 여러 매핑이 걸리면 작은 값이 우선합니다. 구체 규칙은 10~30, * 폴백은 90 이상을 권장합니다(0~9999). -
        사용여부 - - - - - 사용중지해도 수집은 계속되며, 이 규칙만 매칭 대상에서 빠집니다. -
        비고 - - 등록 근거·주의사항 등. 300자 이내. -
        변경 이력 - 등록 - () - - / 최종변경 - () - -
        - -
        - - -
        -
        - - - - - - - - - diff --git a/src/script/mysql/ddl/privacy.job_mapping_audit_cols_mysql.sql b/src/script/mysql/ddl/privacy.job_mapping_audit_cols_mysql.sql deleted file mode 100644 index 677deb2..0000000 --- a/src/script/mysql/ddl/privacy.job_mapping_audit_cols_mysql.sql +++ /dev/null @@ -1,72 +0,0 @@ --- ===================================================================== --- 업무유형 매핑 테이블 확장 : PDAL_JOB_MAPPING 에 변경자·변경일시 컬럼 추가 --- 문화품앗이 개인정보보호 기능개선 사업 (SFR-004 / SER-002) --- 개발항목 #32-1 (관리 화면) - 작성일: 2026-08-07 DB: MySQL 8 / MariaDB --- --------------------------------------------------------------------- --- [사유] #32 는 테이블+로더까지만 구현되어 매핑 행을 DB 직접 INSERT 로만 운영했다. --- 관리 화면(#32-1)에서 취급자가 매핑을 등록·수정·사용중지할 수 있게 되면, --- "누가 접속기록 분류를 바꿨는가"가 감사 대상이 된다. --- 특히 USE_AT='N' 전환은 해당 업무의 접속기록 분류를 무력화하므로 --- (수집 자체는 계속되나 업무코드가 폴백 처리됨) 변경 주체 기록이 필요하다. --- --- [멱등] MySQL 8 은 ADD COLUMN IF NOT EXISTS 미지원 → information_schema 로 --- 존재를 확인해 없을 때만 실행한다(재실행 안전, MySQL 8 / MariaDB 공통). --- --- [선행] privacy.job_mapping_create_mysql.sql (PDAL_JOB_MAPPING 생성) --- ===================================================================== - --- ── 등록자 ── -SET @ddl := IF( - (SELECT COUNT(*) FROM information_schema.COLUMNS - WHERE TABLE_SCHEMA = DATABASE() - AND TABLE_NAME = 'PDAL_JOB_MAPPING' AND COLUMN_NAME = 'REGISTER_ID') > 0, - 'SELECT ''SKIP: PDAL_JOB_MAPPING.REGISTER_ID already exists'' AS msg', - 'ALTER TABLE PDAL_JOB_MAPPING - ADD COLUMN REGISTER_ID VARCHAR(20) NULL COMMENT ''등록자 계정ID(시드행은 NULL)''' -); -PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt; - --- ── 등록일시 ── -SET @ddl := IF( - (SELECT COUNT(*) FROM information_schema.COLUMNS - WHERE TABLE_SCHEMA = DATABASE() - AND TABLE_NAME = 'PDAL_JOB_MAPPING' AND COLUMN_NAME = 'REG_DT') > 0, - 'SELECT ''SKIP: PDAL_JOB_MAPPING.REG_DT already exists'' AS msg', - 'ALTER TABLE PDAL_JOB_MAPPING - ADD COLUMN REG_DT DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT ''등록 일시''' -); -PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt; - --- ── 최종 변경자 ── -SET @ddl := IF( - (SELECT COUNT(*) FROM information_schema.COLUMNS - WHERE TABLE_SCHEMA = DATABASE() - AND TABLE_NAME = 'PDAL_JOB_MAPPING' AND COLUMN_NAME = 'UPDUSR_ID') > 0, - 'SELECT ''SKIP: PDAL_JOB_MAPPING.UPDUSR_ID already exists'' AS msg', - 'ALTER TABLE PDAL_JOB_MAPPING - ADD COLUMN UPDUSR_ID VARCHAR(20) NULL COMMENT ''최종 변경자 계정ID''' -); -PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt; - --- ── 최종 변경일시 ── -SET @ddl := IF( - (SELECT COUNT(*) FROM information_schema.COLUMNS - WHERE TABLE_SCHEMA = DATABASE() - AND TABLE_NAME = 'PDAL_JOB_MAPPING' AND COLUMN_NAME = 'UPDT_DT') > 0, - 'SELECT ''SKIP: PDAL_JOB_MAPPING.UPDT_DT already exists'' AS msg', - 'ALTER TABLE PDAL_JOB_MAPPING - ADD COLUMN UPDT_DT DATETIME NULL COMMENT ''최종 변경 일시''' -); -PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt; - --- [검증] --- SELECT COLUMN_NAME, COLUMN_TYPE, IS_NULLABLE, COLUMN_COMMENT --- FROM information_schema.COLUMNS --- WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'PDAL_JOB_MAPPING' --- ORDER BY ORDINAL_POSITION; --- -- 기대: 기존 9컬럼 + REGISTER_ID / REG_DT / UPDUSR_ID / UPDT_DT = 13컬럼 - --- [롤백] --- ALTER TABLE PDAL_JOB_MAPPING --- DROP COLUMN REGISTER_ID, DROP COLUMN REG_DT, --- DROP COLUMN UPDUSR_ID, DROP COLUMN UPDT_DT; diff --git a/src/script/mysql/ddl/privacy.job_mapping_create_mysql.sql b/src/script/mysql/ddl/privacy.job_mapping_create_mysql.sql deleted file mode 100644 index 5ea35ca..0000000 --- a/src/script/mysql/ddl/privacy.job_mapping_create_mysql.sql +++ /dev/null @@ -1,68 +0,0 @@ --- ===================================================================== --- 개인정보 처리 업무유형 매핑 테이블 : PDAL_JOB_MAPPING --- 문화품앗이 개인정보보호 기능개선 사업 (SFR-004 / SER-002) --- 개발항목 #32 - 담당: arko-privacy-auditlog (Fork B) --- 작성일: 2026-07-21 DB: MySQL 8 / MariaDB --- --------------------------------------------------------------------- --- [목적] 접속기록 수집(#31 PersonalDataLogAspect)이 가로챈 서비스 메서드를 --- 업무코드(JOB_SE_CODE)·업무명(JOB_SE_NM)으로 매핑한다. --- AOP pointcut 범위(개인정보 처리 서비스 패키지) 안에서 신규 화면이 추가되면 --- 이 테이블에 행만 등록하면 수집되며, Aspect/pointcut 소스 수정이 불요하다. --- --- [매칭 규칙] Aspect가 (대상클래스 단순명, 메서드명)으로 조회. --- - TARGET_CLASS : 서비스 impl 단순 클래스명(예: EgovMberManageServiceImpl) 또는 FQN --- - METHOD_PTTRN : 메서드명 접두 매칭(예: 'select','insertMber') 또는 '*'(전체) --- - DML_GUBUN : 지정 시 우선, NULL이면 메서드명 접두로 유도(insert*→I/update*→U/delete*→D/그외 S) --- - SORT_ORDR : 다중 매칭 시 낮은 값 우선(구체 규칙을 위로) --- --- ※ URL 패턴 기반 수집이 필요하면(서블릿 필터형) 동일 테이블에 URL_PTTRN 컬럼을 추가해 확장. --- ===================================================================== - -CREATE TABLE IF NOT EXISTS PDAL_JOB_MAPPING ( - MAPPING_ID BIGINT NOT NULL AUTO_INCREMENT COMMENT '매핑 일련번호(PK)', - TARGET_CLASS VARCHAR(200) NOT NULL COMMENT '대상 서비스 impl 단순 클래스명 또는 FQN', - METHOD_PTTRN VARCHAR(100) NOT NULL DEFAULT '*' COMMENT '메서드명 접두 매칭(또는 * 전체)', - JOB_SE_CODE VARCHAR(30) NOT NULL COMMENT '수행업무 유형 코드', - JOB_SE_NM VARCHAR(100) NOT NULL COMMENT '수행업무 유형(화면·기능명)', - DML_GUBUN CHAR(1) NULL COMMENT '처리구분 강제값 S/I/U/D (NULL=메서드명 유도)', - SORT_ORDR INT NOT NULL DEFAULT 100 COMMENT '매칭 우선순위(낮을수록 우선)', - USE_AT CHAR(1) NOT NULL DEFAULT 'Y' COMMENT '사용여부 Y/N', - RM VARCHAR(300) NULL COMMENT '비고', - PRIMARY KEY (MAPPING_ID), - KEY IX_PJM_LOOKUP (USE_AT, TARGET_CLASS, SORT_ORDR) -) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 - COMMENT='개인정보 처리 업무유형 매핑(접속기록 #31/#32)'; - --- --------------------------------------------------------------------- --- 초기 매핑 시드 : 현행 개인정보 처리 서비스(#31 pointcut 대상과 일치) --- 신규 개인정보 화면 추가 시 아래 UNION ALL 목록에 행만 추가하면 수집된다. --- --- [멱등] (TARGET_CLASS, METHOD_PTTRN) 조합이 이미 있으면 넣지 않는다. --- 테이블에 유니크 제약이 없어(운영 중 행 추가/삭제 자유) 재실행 시 --- 시드가 통째로 중복 적재되는 것을 이 NOT EXISTS 가드로 막는다. --- --------------------------------------------------------------------- -INSERT INTO PDAL_JOB_MAPPING (TARGET_CLASS, METHOD_PTTRN, JOB_SE_CODE, JOB_SE_NM, DML_GUBUN, SORT_ORDR, RM) -SELECT s.TARGET_CLASS, s.METHOD_PTTRN, s.JOB_SE_CODE, s.JOB_SE_NM, s.DML_GUBUN, s.SORT_ORDR, s.RM - FROM ( - SELECT 'EgovMberManageServiceImpl' AS TARGET_CLASS, 'select' AS METHOD_PTTRN, 'MBER_VIEW' AS JOB_SE_CODE, '회원관리-조회' AS JOB_SE_NM, 'S' AS DML_GUBUN, 10 AS SORT_ORDR, '회원 개인정보 조회' AS RM - UNION ALL SELECT 'EgovMberManageServiceImpl', 'insert', 'MBER_REG', '회원관리-등록', 'I', 10, NULL - UNION ALL SELECT 'EgovMberManageServiceImpl', 'update', 'MBER_MOD', '회원관리-수정', 'U', 10, NULL - UNION ALL SELECT 'EgovMberManageServiceImpl', 'delete', 'MBER_DEL', '회원관리-삭제', 'D', 10, NULL - UNION ALL SELECT 'EgovMberManageServiceImpl', '*', 'MBER_ETC', '회원관리-기타', NULL, 90, '미분류 폴백' - UNION ALL SELECT 'EgovEntrprsManageServiceImpl', 'select', 'ENTRPRS_VIEW', '업체관리-조회', 'S', 10, '업체 담당자 개인정보 조회' - UNION ALL SELECT 'EgovEntrprsManageServiceImpl', 'insert', 'ENTRPRS_REG', '업체관리-등록', 'I', 10, NULL - UNION ALL SELECT 'EgovEntrprsManageServiceImpl', 'update', 'ENTRPRS_MOD', '업체관리-수정', 'U', 10, NULL - UNION ALL SELECT 'EgovEntrprsManageServiceImpl', 'delete', 'ENTRPRS_DEL', '업체관리-삭제', 'D', 10, NULL - UNION ALL SELECT 'EgovEntrprsManageServiceImpl', '*', 'ENTRPRS_ETC', '업체관리-기타', NULL, 90, '미분류 폴백' - UNION ALL SELECT 'MberSsnServiceImpl', '*', 'MBER_SSN', '회원 고유식별정보 처리', NULL, 20, '주민등록번호 등 고유식별정보' - UNION ALL SELECT 'BizMberMergeServiceImpl', '*', 'MBER_MERGE', '회원 통합/이관', NULL, 30, NULL - UNION ALL SELECT 'BizTalentMberManageServiceImpl', '*', 'TALENT_MBER', '재능나눔 회원관리', NULL, 30, NULL - ) s - WHERE NOT EXISTS ( - SELECT 1 FROM PDAL_JOB_MAPPING t - WHERE t.TARGET_CLASS = s.TARGET_CLASS - AND t.METHOD_PTTRN = s.METHOD_PTTRN - ); - --- [검증] SELECT COUNT(*) FROM PDAL_JOB_MAPPING; -- 시드 13건(운영 추가분 별도) --- [롤백] DROP TABLE PDAL_JOB_MAPPING; diff --git a/src/script/mysql/dml/privacy.menu_conect_log_insert_mysql.sql b/src/script/mysql/dml/privacy.menu_conect_log_insert_mysql.sql deleted file mode 100644 index 515dcf6..0000000 --- a/src/script/mysql/dml/privacy.menu_conect_log_insert_mysql.sql +++ /dev/null @@ -1,59 +0,0 @@ --- ===================================================================== --- 관리자 메뉴 등록 : 로그인·로그아웃 접속기록 (MENU_NO 9100046) --- 문화품앗이 개인정보보호 기능개선 사업 (SER-001 / SER-002, 개발항목 62) --- 작성일: 2026-08-07 DB: MySQL 8 / MariaDB --- --------------------------------------------------------------------- --- [배치] 시스템관리(9100000) > 권한관리(9100030) 하위 (MENU_ORDR 12). --- 선행: 9100039 인증 실패 이력 / 9100044 개인정보 접속기록 / --- 9100045 업무유형 매핑 관리 — 같은 보안 화면 묶음. --- [번호] 9100040~9100043 은 게시판관리 그룹이 선점 → 다음 가용 번호 9100046. --- [권한] IS_AUTHENTICATED_FULLY, ROLE_ADMIN (선행 화면들과 동일) --- [재실행] 모든 INSERT NOT EXISTS 가드 → 반복 실행 무해. --- --- ★★ 이 DML 만으로는 좌측메뉴에 안 뜬다 ★★ --- left.jsp 는 메뉴번호 하드코딩 JSP라 9100046 분기 + --- 상위 하이라이트 조건(param.page eq '9100046') 5곳이 함께 배포돼야 렌더된다. --- (arko_renual 소스에는 반영 완료 — 배포본에 JSP 포함 여부만 확인) --- --- ※ 한글 리터럴 포함 — 반드시 UTF-8 로 접속해 실행할 것 --- (mysql --default-character-set=utf8mb4). 적용 후 HEX() 로 깨짐 확인 권장. --- ===================================================================== - --- ── [1] 프로그램(URL) 등록 (COMTNMENUINFO.PROGRM_FILE_NM FK, 메뉴보다 먼저) ── -INSERT INTO COMTNPROGRMLIST (PROGRM_FILE_NM, PROGRM_STRE_PATH, PROGRM_KOREAN_NM, PROGRM_DC, URL) -SELECT * FROM ( - SELECT 'ConectLogList' AS PROGRM_FILE_NM, - '/mnt/sec/conectLog/' AS PROGRM_STRE_PATH, - '로그인 접속기록' AS PROGRM_KOREAN_NM, - '로그인·로그아웃 접속기록 조회·다운로드(접속ID·접속일시·IP·접속위치, 개발항목 62)' AS PROGRM_DC, - '/mnt/sec/conectLog/listView.do?page=9100046' AS URL -) seed -WHERE NOT EXISTS (SELECT 1 FROM COMTNPROGRMLIST p WHERE p.PROGRM_FILE_NM = seed.PROGRM_FILE_NM); - --- ── [2] 메뉴 등록 : 권한관리(9100030) 하위, MENU_ORDR 12 ── -INSERT INTO COMTNMENUINFO (MENU_NM, PROGRM_FILE_NM, MENU_NO, UPPER_MENU_NO, MENU_ORDR, MENU_DC, RELATE_IMAGE_PATH, RELATE_IMAGE_NM) -SELECT * FROM ( - SELECT '로그인 접속기록' AS MENU_NM, 'ConectLogList' AS PROGRM_FILE_NM, 9100046 AS MENU_NO, - 9100030 AS UPPER_MENU_NO, 12 AS MENU_ORDR, '로그인·로그아웃 접속기록 조회' AS MENU_DC, - '/' AS RELATE_IMAGE_PATH, '/' AS RELATE_IMAGE_NM -) seed -WHERE NOT EXISTS (SELECT 1 FROM COMTNMENUINFO m WHERE m.MENU_NO = seed.MENU_NO); - --- ── [3] 권한↔메뉴 노출 매핑 (1건 × 2권한, FK 안전) ── -INSERT INTO COMTNMENUCREATDTLS (MENU_NO, AUTHOR_CODE) -SELECT 9100046, g.ac - FROM (SELECT 'IS_AUTHENTICATED_FULLY' AS ac UNION ALL SELECT 'ROLE_ADMIN') g - WHERE g.ac IN (SELECT AUTHOR_CODE FROM COMTNAUTHORINFO) - AND NOT EXISTS (SELECT 1 FROM COMTNMENUCREATDTLS x - WHERE x.MENU_NO = 9100046 AND x.AUTHOR_CODE = g.ac); - --- ── [검증] ── --- SELECT m.MENU_NO, m.MENU_NM, HEX(m.MENU_NM), m.MENU_ORDR, p.URL, --- (SELECT COUNT(*) FROM COMTNMENUCREATDTLS d WHERE d.MENU_NO = m.MENU_NO) grants --- FROM COMTNMENUINFO m JOIN COMTNPROGRMLIST p ON m.PROGRM_FILE_NM = p.PROGRM_FILE_NM --- WHERE m.MENU_NO = 9100046; --- --- ── [롤백] ── --- DELETE FROM COMTNMENUCREATDTLS WHERE MENU_NO = 9100046; --- DELETE FROM COMTNMENUINFO WHERE MENU_NO = 9100046; --- DELETE FROM COMTNPROGRMLIST WHERE PROGRM_FILE_NM = 'ConectLogList'; diff --git a/src/script/mysql/dml/privacy.menu_job_mapping_conect_log_delete_mysql.sql b/src/script/mysql/dml/privacy.menu_job_mapping_conect_log_delete_mysql.sql new file mode 100644 index 0000000..ceebb39 --- /dev/null +++ b/src/script/mysql/dml/privacy.menu_job_mapping_conect_log_delete_mysql.sql @@ -0,0 +1,45 @@ +-- ===================================================================== +-- 관리자 메뉴 삭제 : 업무유형 매핑 관리(9100045) + 로그인 접속기록(9100046) +-- 문화품앗이 개인정보보호 기능개선 사업 — 2026-08-11 화면 폐기 +-- DB: MySQL 8 / MariaDB (스키마 csv) +-- --------------------------------------------------------------------- +-- [대체] 종전 등록 DML 2건을 되돌린다(두 파일 모두 삭제됨). +-- 구 privacy.menu_job_mapping_insert_mysql.sql (9100045 / JobMappingList) +-- 구 privacy.menu_conect_log_insert_mysql.sql (9100046 / ConectLogList) +-- +-- [삭제 순서] 권한매핑 → 메뉴 → 프로그램. COMTNMENUINFO.PROGRM_FILE_NM 이 +-- COMTNPROGRMLIST 를 FK 로 참조하므로 역순이면 FK 위반 또는 잔재가 남는다. +-- +-- [재실행 안전] DELETE ... WHERE IN 은 멱등 — 반복 실행해도 0 rows affected. +-- [한글 리터럴 없음] 캐릭터셋 사고 위험 없음(접속 인코딩 무관하게 실행 가능). +-- +-- ★★ left.jsp 수정과 반드시 동반 배포 ★★ +-- src/main/webapp/WEB-INF/jsp/site/mnt/left.jsp 에서 9100045/9100046 +-- 분기와 상위 하이라이트 조건 8곳을 제거했다. +-- · JSP 만 올리고 이 DML 을 안 돌리면 → 메뉴 행이 DB 에 남아 좌측메뉴 렌더 시 +-- 어느 에도 안 걸려 조용히 사라지지만, URL 직접 접근 경로는 살아 있다 +-- (컨트롤러가 삭제됐으므로 404). +-- · 이 DML 만 돌리고 JSP 를 안 올리면 → 분기가 매칭될 대상이 없어 무해. +-- 따라서 사고 위험이 낮은 순서는 "DML 먼저 → JSP 배포" 이다. +-- +-- ※ PDAL_JOB_MAPPING / CONECT_IP_LOCATION 테이블은 DROP 하지 않는다. +-- CONECT_IP_LOCATION 은 IpLocationResolver 가 계속 조회해 +-- COMTNLOGINLOG.CONECT_LC 적재에 쓰므로 DROP 하면 접속위치가 전부 NULL 이 된다. +-- ===================================================================== + +-- ── [1] 권한↔메뉴 노출 매핑 제거 ── +DELETE FROM COMTNMENUCREATDTLS WHERE MENU_NO IN (9100045, 9100046); + +-- ── [2] 메뉴 제거 ── +DELETE FROM COMTNMENUINFO WHERE MENU_NO IN (9100045, 9100046); + +-- ── [3] 프로그램(URL) 제거 ── +DELETE FROM COMTNPROGRMLIST WHERE PROGRM_FILE_NM IN ('JobMappingList', 'ConectLogList'); + +-- ── [검증] 세 쿼리 모두 0 건이어야 한다 ── +-- SELECT COUNT(*) FROM COMTNMENUCREATDTLS WHERE MENU_NO IN (9100045, 9100046); +-- SELECT COUNT(*) FROM COMTNMENUINFO WHERE MENU_NO IN (9100045, 9100046); +-- SELECT COUNT(*) FROM COMTNPROGRMLIST WHERE PROGRM_FILE_NM IN ('JobMappingList', 'ConectLogList'); +-- -- 잔존 개인정보 메뉴 확인(9100044, 9100047 은 남아 있어야 정상): +-- SELECT MENU_NO, MENU_NM, MENU_ORDR FROM COMTNMENUINFO +-- WHERE UPPER_MENU_NO = 9100030 ORDER BY MENU_ORDR; diff --git a/src/script/mysql/dml/privacy.menu_job_mapping_insert_mysql.sql b/src/script/mysql/dml/privacy.menu_job_mapping_insert_mysql.sql deleted file mode 100644 index 37b41ff..0000000 --- a/src/script/mysql/dml/privacy.menu_job_mapping_insert_mysql.sql +++ /dev/null @@ -1,60 +0,0 @@ --- ===================================================================== --- 관리자 메뉴 등록 : 업무유형 매핑 관리 (개인정보보호 SFR-004 / #32-1) --- 대상 화면 : /mnt/sec/jobMapping/listView.do --- 작성일 : 2026-08-07 DB: MySQL 8 (운영 DB가 MySQL 이라 Oracle 쌍 미작성) --- --------------------------------------------------------------------- --- [배치] 권한관리(9100030) 하위, 개인정보 접속기록(9100044) 바로 다음. --- 접속기록 화면의 '수행업무' 분류 기준을 편집하는 화면이라 인접 배치한다. --- ※ 9100040~9100043 은 '게시판관리' 그룹이 선점 → 9100045 사용(MENU_ORDR 11) --- --- ★★ 이 DML만으로는 좌측메뉴에 안 뜬다 — left.jsp 수정 배포 필수 ★★ --- src/main/webapp/WEB-INF/jsp/site/mnt/left.jsp 는 메뉴번호 하드코딩 JSP라 --- ① 9100045 분기 1곳 + ② 상위 하이라이트 조건 4곳 --- (시스템관리 class="on" / 시스템관리 display:block / 권한관리 class="on" / --- 권한관리 ul display:block)에 param.page eq '9100045' 가 함께 배포돼야 렌더된다. --- (arko_renual 소스에는 반영 완료 — 배포본에 이 JSP가 포함됐는지만 확인) --- --- [재실행 안전] 모든 INSERT NOT EXISTS 가드 → 반복 실행 무해. --- [노출권한] IS_AUTHENTICATED_FULLY, ROLE_ADMIN (기존 개인정보 메뉴와 동일) --- [적용순서] COMTNPROGRMLIST → COMTNMENUINFO → COMTNMENUCREATDTLS (FK 순서) --- ===================================================================== - --- ── [1] 프로그램(URL) 등록 (COMTNMENUINFO.PROGRM_FILE_NM → FK, 메뉴보다 먼저) ── -INSERT INTO COMTNPROGRMLIST (PROGRM_FILE_NM, PROGRM_STRE_PATH, PROGRM_KOREAN_NM, PROGRM_DC, URL) -SELECT * FROM ( - SELECT 'JobMappingList' AS PROGRM_FILE_NM, '/mnt/sec/jobMapping/' AS PROGRM_STRE_PATH, - '업무유형 매핑 관리' AS PROGRM_KOREAN_NM, - '개인정보 접속기록 업무유형 매핑 관리(SFR-004 #32-1)' AS PROGRM_DC, - '/mnt/sec/jobMapping/listView.do?page=9100045' AS URL -) seed -WHERE NOT EXISTS (SELECT 1 FROM COMTNPROGRMLIST p WHERE p.PROGRM_FILE_NM = seed.PROGRM_FILE_NM); - --- ── [2] 메뉴 등록 : 권한관리(9100030) 하위, MENU_ORDR 11 ── -INSERT INTO COMTNMENUINFO (MENU_NM, PROGRM_FILE_NM, MENU_NO, UPPER_MENU_NO, MENU_ORDR, MENU_DC, RELATE_IMAGE_PATH, RELATE_IMAGE_NM) -SELECT * FROM ( - SELECT '업무유형 매핑 관리' AS MENU_NM, 'JobMappingList' AS PROGRM_FILE_NM, 9100045 AS MENU_NO, - 9100030 AS UPPER_MENU_NO, 11 AS MENU_ORDR, '접속기록 업무유형 매핑 등록·수정' AS MENU_DC, - '/' AS RELATE_IMAGE_PATH, '/' AS RELATE_IMAGE_NM -) seed -WHERE NOT EXISTS (SELECT 1 FROM COMTNMENUINFO m WHERE m.MENU_NO = seed.MENU_NO); - --- ── [3] 권한↔메뉴 노출 매핑 (FK 안전) ── -INSERT INTO COMTNMENUCREATDTLS (MENU_NO, AUTHOR_CODE) -SELECT 9100045, g.ac - FROM (SELECT 'IS_AUTHENTICATED_FULLY' AS ac UNION ALL SELECT 'ROLE_ADMIN') g - WHERE g.ac IN (SELECT AUTHOR_CODE FROM COMTNAUTHORINFO) - AND NOT EXISTS (SELECT 1 FROM COMTNMENUCREATDTLS x - WHERE x.MENU_NO = 9100045 AND x.AUTHOR_CODE = g.ac); - --- ── [검증] ── --- SELECT m.MENU_NO, m.UPPER_MENU_NO, m.MENU_NM, m.MENU_ORDR, p.URL, --- (SELECT COUNT(*) FROM COMTNMENUCREATDTLS d WHERE d.MENU_NO = m.MENU_NO) grants --- FROM COMTNMENUINFO m JOIN COMTNPROGRMLIST p ON m.PROGRM_FILE_NM = p.PROGRM_FILE_NM --- WHERE m.MENU_NO = 9100045; --- -- 한글 깨짐 확인: SELECT HEX(MENU_NM) FROM COMTNMENUINFO WHERE MENU_NO = 9100045; --- -- '업' = EC9785 로 시작하면 정상(UTF-8) - --- ── [롤백] (역순) ── --- DELETE FROM COMTNMENUCREATDTLS WHERE MENU_NO = 9100045; --- DELETE FROM COMTNMENUINFO WHERE MENU_NO = 9100045; --- DELETE FROM COMTNPROGRMLIST WHERE PROGRM_FILE_NM = 'JobMappingList'; diff --git a/src/script/mysql/privacy.install_mysql.sql b/src/script/mysql/privacy.install_mysql.sql index cd855b3..c3de0ed 100644 --- a/src/script/mysql/privacy.install_mysql.sql +++ b/src/script/mysql/privacy.install_mysql.sql @@ -1,18 +1,35 @@ -- ##################################################################### -- arko_renual 설치용 통합 SQL (개인정보보호 기능개선 사업 · arko 대비 변동분) -- 대상 DB : MySQL 5.7 / MariaDB (스키마 csv) --- 구성 : [PART 1] DDL(테이블 생성·변경) → [PART 2] DML(기준/시드 데이터) +-- 구성 : [PART 1] DDL(테이블 생성·변경, 24개 테이블/인덱스) +-- → [PART 2] DML(기준/시드 데이터) -- → [PART 3] 관리자 메뉴 등록 DML(좌측메뉴 노출, COMTNPROGRMLIST/ --- COMTNMENUINFO/COMTNMENUCREATDTLS, 9100035~9100039 + 9100044 + 9100045) --- 실행순서: 반드시 PART 1 → PART 2 → PART 3 순서로 실행 --- (테이블 생성 → 시드 적재 → 메뉴 등록. 메뉴는 FK로 프로그램 등록을 먼저 요구) +-- COMTNMENUINFO/COMTNMENUCREATDTLS, 9100035~9100039 + 9100044 + 9100047) +-- ※ 9100045(업무유형 매핑 관리)·9100046(로그인 접속기록)은 2026-08-11 폐기 +-- → [PART 4] 뷰 GNR 컷오버(comvnusermaster/comvnuserinfo, 회원 이름 암호화 +-- 최종 단계) — **자체 게이트 내장**, 백필 미완료 시 자동 SKIP(PART 4 헤더 참조) +-- 실행순서: 반드시 PART 1 → PART 2 → PART 3 → PART 4 순서로 실행 +-- (테이블 생성 → 시드 적재 → 메뉴 등록 → 뷰 컷오버. 메뉴는 FK로 프로그램 +-- 등록을 먼저 요구하고, PART 4는 PART 1의 COMTNGNRLMBER 컬럼 추가를 전제한다) -- 재실행 : **전 구간 재실행 안전(멱등)** — PART 1 은 CREATE TABLE IF NOT EXISTS 와 -- information_schema 존재 확인 후 실행되는 ALTER(컬럼/인덱스), PART 2·3 은 --- NOT EXISTS 가드를 쓴다. 이미 적용된 항목은 'SKIP: ...' 메시지만 출력하고 --- 넘어가므로, 기존 환경에도 이 파일 하나만 그대로 실행하면 된다. +-- NOT EXISTS 가드, PART 4 는 자체 게이트(이미 적용됨/백필 미완료 시 SKIP)를 쓴다. +-- 이미 적용된 항목은 'SKIP: ...' 메시지만 출력하고 넘어가므로, 기존 환경에도 +-- 이 파일 하나만 그대로 실행하면 된다(PART 4는 백필 완료 후 재실행 1회 필요할 수 있음). -- (2026-08-05 이전 버전은 재실행 시 1050/1060/1061 오류가 났다) -- ★★ PART 3 적용만으로는 좌측메뉴에 안 뜬다 — left.jsp 하드코딩 분기가 -- 반드시 함께 배포돼야 한다(소스에는 이미 반영됨). 상세는 PART 3 헤더 참조. ★★ +-- ★★ PART 4(뷰 컷오버) 전에 회원 이름 백필 배치가 끝나 있어야 한다 — +-- /mnt/sec/memberNmCrypto/start.do?confirm=BACKFILL(POST, ROLE_ADMIN, 2026-08-11 신설 +-- MemberNmCryptoController). 안 끝났으면 PART 4는 조용히 SKIP만 하고 넘어간다(에러 아님). ★★ +-- 2026-08-11 개정: DDL 6건(access_control_policy/download_job/emplyr_scrty_index/ +-- mber_nm_encrypt_create/mber_nm_dead_views_drop 5건→PART 1, mber_nm_view_hash_cutover +-- 1건→신설 PART 4) + 메뉴 1건(access_policy 9100047→PART 3)을 통합해 이 파일 하나가 +-- 다시 완전한 정본이 되도록 했다(그 전까지 DDL 27건 중 19건만 커버하던 drift 해소). +-- `backup/privacy.mber_nm_backup_mysql.sql`(평문 백업)· +-- `backup/privacy.mber_nm_dead_views_backup_20260810.sql`(죽은 뷰 정의 백업)은 +-- 되돌리기용 스냅샷 성격이라 이 통합 파일 실행 **전**에 별도로 실행하는 것을 권장하며 +-- 통합하지 않았다(설치 로직이 아니라 사고 대비 아카이브라 실행 시점이 다름). -- ##################################################################### @@ -189,103 +206,22 @@ CREATE TABLE IF NOT EXISTS AUTHOR_HIST_DOWNLOAD_HISTORY ( COMMENT='권한이력 다운로드 증빙 이력(개인정보보호 SFR-001 #6)'; -- ───────────────────────────────────────────────────────────── --- [privacy.job_mapping_create_mysql.sql] +-- [PDAL_JOB_MAPPING — 2026-08-11 제거] -- ───────────────────────────────────────────────────────────── -- ===================================================================== --- 개인정보 처리 업무유형 매핑 테이블 : PDAL_JOB_MAPPING --- 문화품앗이 개인정보보호 기능개선 사업 (SFR-004 / SER-002) --- 개발항목 #32 - 담당: arko-privacy-auditlog (Fork B) --- 작성일: 2026-07-21 DB: MySQL 8 / MariaDB --- --------------------------------------------------------------------- --- [목적] 접속기록 수집(#31 PersonalDataLogAspect)이 가로챈 서비스 메서드를 --- 업무코드(JOB_SE_CODE)·업무명(JOB_SE_NM)으로 매핑한다. --- AOP pointcut 범위(개인정보 처리 서비스 패키지) 안에서 신규 화면이 추가되면 --- 이 테이블에 행만 등록하면 수집되며, Aspect/pointcut 소스 수정이 불요하다. +-- 업무유형 매핑 테이블(PDAL_JOB_MAPPING) 생성 DDL 과 변경자 컬럼 확장 DDL 이 +-- 여기 있었다(구 privacy.job_mapping_create_mysql.sql / job_mapping_audit_cols_mysql.sql). -- --- [매칭 규칙] Aspect가 (대상클래스 단순명, 메서드명)으로 조회. --- - TARGET_CLASS : 서비스 impl 단순 클래스명(예: EgovMberManageServiceImpl) 또는 FQN --- - METHOD_PTTRN : 메서드명 접두 매칭(예: 'select','insertMber') 또는 '*'(전체) --- - DML_GUBUN : 지정 시 우선, NULL이면 메서드명 접두로 유도(insert*→I/update*→U/delete*→D/그외 S) --- - SORT_ORDR : 다중 매칭 시 낮은 값 우선(구체 규칙을 위로) +-- [제거 사유] 이 테이블은 폐기된 서비스 AOP 수집 방식(PersonalDataLogAspect)의 +-- 분류 기준이었다. 2026-08-07 수집이 MenuAccessLogInterceptor(메뉴명 기반)로 바뀌면서 +-- 소비자가 없어졌고, 2026-08-11 관리 화면(메뉴 9100045)까지 삭제하면서 DDL 을 뺐다. +-- 접속기록의 수행업무(JOB_SE_CODE/JOB_SE_NM) 자체는 그대로 유지된다 — 값의 출처가 +-- 매핑 테이블에서 COMTNMENUINFO 메뉴명으로 바뀐 것뿐이다(SFR-004 요건 회귀 아님). -- --- ※ URL 패턴 기반 수집이 필요하면(서블릿 필터형) 동일 테이블에 URL_PTTRN 컬럼을 추가해 확장. +-- ★ 기존 운영 DB 의 PDAL_JOB_MAPPING 테이블은 DROP 하지 않는다(별도 판단 사항). +-- FK 로 참조하는 테이블이 없고 조회하는 sqlMap 도 남아 있지 않아 방치해도 무해하다. -- ===================================================================== -CREATE TABLE IF NOT EXISTS PDAL_JOB_MAPPING ( - MAPPING_ID BIGINT NOT NULL AUTO_INCREMENT COMMENT '매핑 일련번호(PK)', - TARGET_CLASS VARCHAR(200) NOT NULL COMMENT '대상 서비스 impl 단순 클래스명 또는 FQN', - METHOD_PTTRN VARCHAR(100) NOT NULL DEFAULT '*' COMMENT '메서드명 접두 매칭(또는 * 전체)', - JOB_SE_CODE VARCHAR(30) NOT NULL COMMENT '수행업무 유형 코드', - JOB_SE_NM VARCHAR(100) NOT NULL COMMENT '수행업무 유형(화면·기능명)', - DML_GUBUN CHAR(1) NULL COMMENT '처리구분 강제값 S/I/U/D (NULL=메서드명 유도)', - SORT_ORDR INT NOT NULL DEFAULT 100 COMMENT '매칭 우선순위(낮을수록 우선)', - USE_AT CHAR(1) NOT NULL DEFAULT 'Y' COMMENT '사용여부 Y/N', - RM VARCHAR(300) NULL COMMENT '비고', - PRIMARY KEY (MAPPING_ID), - KEY IX_PJM_LOOKUP (USE_AT, TARGET_CLASS, SORT_ORDR) -) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 - COMMENT='개인정보 처리 업무유형 매핑(접속기록 #31/#32)'; - --- --------------------------------------------------------------------- --- 초기 매핑 시드 : 현행 개인정보 처리 서비스(#31 pointcut 대상과 일치) --- 신규 개인정보 화면 추가 시 아래 형식으로 행만 추가하면 수집된다. --- ※ 시드 INSERT 본문은 PART 2 로 분리되어 있다. --- --------------------------------------------------------------------- - --- ───────────────────────────────────────────────────────────── --- [privacy.job_mapping_audit_cols_mysql.sql] --- ───────────────────────────────────────────────────────────── --- ===================================================================== --- 업무유형 매핑 테이블 확장 : PDAL_JOB_MAPPING 에 변경자·변경일시 컬럼 추가 --- 개발항목 #32-1 (관리 화면) - 2026-08-07 --- --------------------------------------------------------------------- --- [사유] 관리 화면에서 취급자가 매핑을 등록·수정·사용중지할 수 있게 되면 --- "누가 접속기록 분류를 바꿨는가"가 감사 대상이 된다. --- [멱등] information_schema 존재 확인 후 ALTER (재실행 안전). --- ★ 관리 화면 sqlMap(PrivacyJobMapping_SQL_Mysql.xml)이 이 4개 컬럼을 SELECT/INSERT 하므로 --- 소스 배포보다 이 DDL 적용이 반드시 선행돼야 한다(미적용 시 1054 Unknown column). --- ===================================================================== - -SET @ddl := IF( - (SELECT COUNT(*) FROM information_schema.COLUMNS - WHERE TABLE_SCHEMA = DATABASE() - AND TABLE_NAME = 'PDAL_JOB_MAPPING' AND COLUMN_NAME = 'REGISTER_ID') > 0, - 'SELECT ''SKIP: PDAL_JOB_MAPPING.REGISTER_ID already exists'' AS msg', - 'ALTER TABLE PDAL_JOB_MAPPING - ADD COLUMN REGISTER_ID VARCHAR(20) NULL COMMENT ''등록자 계정ID(시드행은 NULL)''' -); -PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt; - -SET @ddl := IF( - (SELECT COUNT(*) FROM information_schema.COLUMNS - WHERE TABLE_SCHEMA = DATABASE() - AND TABLE_NAME = 'PDAL_JOB_MAPPING' AND COLUMN_NAME = 'REG_DT') > 0, - 'SELECT ''SKIP: PDAL_JOB_MAPPING.REG_DT already exists'' AS msg', - 'ALTER TABLE PDAL_JOB_MAPPING - ADD COLUMN REG_DT DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT ''등록 일시''' -); -PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt; - -SET @ddl := IF( - (SELECT COUNT(*) FROM information_schema.COLUMNS - WHERE TABLE_SCHEMA = DATABASE() - AND TABLE_NAME = 'PDAL_JOB_MAPPING' AND COLUMN_NAME = 'UPDUSR_ID') > 0, - 'SELECT ''SKIP: PDAL_JOB_MAPPING.UPDUSR_ID already exists'' AS msg', - 'ALTER TABLE PDAL_JOB_MAPPING - ADD COLUMN UPDUSR_ID VARCHAR(20) NULL COMMENT ''최종 변경자 계정ID''' -); -PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt; - -SET @ddl := IF( - (SELECT COUNT(*) FROM information_schema.COLUMNS - WHERE TABLE_SCHEMA = DATABASE() - AND TABLE_NAME = 'PDAL_JOB_MAPPING' AND COLUMN_NAME = 'UPDT_DT') > 0, - 'SELECT ''SKIP: PDAL_JOB_MAPPING.UPDT_DT already exists'' AS msg', - 'ALTER TABLE PDAL_JOB_MAPPING - ADD COLUMN UPDT_DT DATETIME NULL COMMENT ''최종 변경 일시''' -); -PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt; - -- ───────────────────────────────────────────────────────────── -- [privacy.download_history_create_mysql.sql] @@ -909,32 +845,225 @@ CREATE TABLE IF NOT EXISTS LOGIN_LOCK_EXEMPT ( COMMENT='로그인 실패 계정잠금 예외 계정(개인정보보호 SER-001 24, 최고관리자 잠김 방지)'; +-- ───────────────────────────────────────────────────────────── +-- [privacy.access_control_policy_create_mysql.sql] (2026-08-11 통합) +-- ───────────────────────────────────────────────────────────── +-- ===================================================================== +-- 접근통제 정책 : ACCESS_CONTROL_POLICY (단일행 정책 테이블) +-- 문화품앗이 개인정보보호 기능개선 사업 (SER-001 요건 4·5 / 접근통제 정책 적용) +-- [용도] 계정 잠금 임계값·자동 로그아웃 시간·세션 재발급 여부를 관리자 설정값으로 운영. +-- [반영 경로] LOCK_THRESHOLD→LOGIN_LOCK_COUNT 동기화 / AUTO_LOGOUT_MIN→SessionPolicyInterceptor +-- / SESSION_REGEN_YN→SecondFactorController 세션 재발급 +-- [재실행 안전] CREATE TABLE IF NOT EXISTS + 시드 NOT EXISTS 가드 +-- ===================================================================== +CREATE TABLE IF NOT EXISTS ACCESS_CONTROL_POLICY ( + POLICY_ID INT NOT NULL DEFAULT 1 COMMENT '정책 식별자(단일행=1)', + LOCK_THRESHOLD INT NOT NULL DEFAULT 5 COMMENT '인증 실패 계정 잠금 임계값(회). 현행 10 -> 5', + UNLOCK_MODE VARCHAR(20) NOT NULL DEFAULT 'ADMIN_HIST' COMMENT '잠금 해제 방식 ADMIN_HIST:관리자화면+이력 / ADMIN_HIST_RESN:사유 필수', + AUTO_LOGOUT_MIN INT NOT NULL DEFAULT 30 COMMENT '미사용 자동 로그아웃(분). 현행 web.xml 60 -> 30', + EXPIRE_NOTICE_YN CHAR(1) NOT NULL DEFAULT 'Y' COMMENT '세션 만료 임박 안내 사용여부 Y/N', + EXPIRE_NOTICE_MIN INT NOT NULL DEFAULT 5 COMMENT '만료 임박 안내 시점(만료 N분 전)', + SESSION_REGEN_YN CHAR(1) NOT NULL DEFAULT 'Y' COMMENT '2차 인증 성공 시 세션 재발급(세션고정 차단) 사용여부 Y/N', + UPD_ID VARCHAR(20) NULL COMMENT '최종 수정자', + UPD_DT DATETIME NULL COMMENT '최종 수정일시', + PRIMARY KEY (POLICY_ID) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 + COMMENT='접근통제 정책(개인정보보호 SER-001 요건4-5, 단일행)'; + +INSERT INTO ACCESS_CONTROL_POLICY + (POLICY_ID, LOCK_THRESHOLD, UNLOCK_MODE, AUTO_LOGOUT_MIN, + EXPIRE_NOTICE_YN, EXPIRE_NOTICE_MIN, SESSION_REGEN_YN, UPD_ID, UPD_DT) +SELECT 1, 5, 'ADMIN_HIST', 30, 'Y', 5, 'Y', 'SYSTEM', NOW() + FROM DUAL + WHERE NOT EXISTS (SELECT 1 FROM ACCESS_CONTROL_POLICY WHERE POLICY_ID = 1); + +-- 인증 경로(EgovLoginController/EgovSpringSecurityLoginFilter)가 읽는 LOGIN_LOCK_COUNT 를 +-- 정책값과 일치시킨다(화면 저장 시에도 AccessControlPolicyServiceImpl 이 동일하게 동기화). +UPDATE LOGIN_LOCK_COUNT + SET LOCK_COUNT = (SELECT LOCK_THRESHOLD FROM ACCESS_CONTROL_POLICY WHERE POLICY_ID = 1); + +INSERT INTO LOGIN_LOCK_COUNT (LOCK_COUNT) +SELECT (SELECT LOCK_THRESHOLD FROM ACCESS_CONTROL_POLICY WHERE POLICY_ID = 1) + FROM DUAL + WHERE NOT EXISTS (SELECT 1 FROM LOGIN_LOCK_COUNT); + + +-- ───────────────────────────────────────────────────────────── +-- [privacy.download_job_create_mysql.sql] (2026-08-11 통합) +-- ───────────────────────────────────────────────────────────── +-- ===================================================================== +-- 개인정보 접속기록 CSV 비동기 다운로드 작업 : PDAL_DOWNLOAD_JOB (SFR-004-6 / SER-002) +-- [보안 전제] FILE_PATH 는 반드시 웹 루트 밖(Globals.privacyDownloadPath), 저장 파일은 +-- 암호화(평문 CSV 미보관). EXPIRE_DT 경과분은 파기 배치가 파일 삭제+STATUS='X'. +-- [상태 전이] W(대기) -> R(생성중) -> S(완료) -> X(파기) / 실패는 F +-- ===================================================================== +CREATE TABLE IF NOT EXISTS PDAL_DOWNLOAD_JOB ( + JOB_ID BIGINT NOT NULL AUTO_INCREMENT COMMENT '작업 일련번호(PK)', + REQUESTER_ID VARCHAR(20) NOT NULL COMMENT '요청자 계정ID(취급자)', + REQUESTER_IP VARCHAR(45) NULL COMMENT '요청 IP(IPv6 대응 45)', + DL_REASON VARCHAR(500) NOT NULL COMMENT '다운로드 사유(필수)', + SEARCH_COND VARCHAR(1000) NULL COMMENT '검색조건 요약(개인정보 본문 미포함)', + F_CONECT_ID VARCHAR(50) NULL COMMENT '조건: 접속자ID', + F_JOB_SE_CODE VARCHAR(30) NULL COMMENT '조건: 수행업무 코드', + F_TRGET_ID VARCHAR(50) NULL COMMENT '조건: 처리대상 식별자', + F_CONECT_IP VARCHAR(45) NULL COMMENT '조건: 접속IP', + F_DML_GUBUN CHAR(1) NULL COMMENT '조건: 처리구분 S/I/U/D', + F_PROCESS_RESULT CHAR(1) NULL COMMENT '조건: 처리결과 S/F', + F_FROM_DT VARCHAR(19) NULL COMMENT '조건: 기간 시작', + F_TO_DT VARCHAR(19) NULL COMMENT '조건: 기간 종료', + STATUS CHAR(1) NOT NULL DEFAULT 'W' COMMENT '상태 W:대기 R:생성중 S:완료 F:실패 X:파기', + TOTAL_CNT INT NOT NULL DEFAULT 0 COMMENT '대상 총건수', + DONE_CNT INT NOT NULL DEFAULT 0 COMMENT '기록 완료 건수(진행률)', + FILE_NM VARCHAR(200) NULL COMMENT '내려받을 때 보여줄 파일명', + FILE_PATH VARCHAR(300) NULL COMMENT '서버 저장 절대경로(웹 루트 밖, 암호화 저장)', + FILE_SIZE BIGINT NOT NULL DEFAULT 0 COMMENT '암호화 파일 크기(byte)', + ERR_MSG VARCHAR(500) NULL COMMENT '실패 사유', + DOWNLOAD_CNT INT NOT NULL DEFAULT 0 COMMENT '내려받은 횟수', + LAST_DL_DT DATETIME NULL COMMENT '마지막 내려받은 일시', + REG_DT DATETIME NOT NULL COMMENT '요청 일시', + START_DT DATETIME NULL COMMENT '생성 시작 일시', + END_DT DATETIME NULL COMMENT '생성 종료 일시', + EXPIRE_DT DATETIME NULL COMMENT '파기 예정 일시(경과 시 파일 삭제)', + PRIMARY KEY (JOB_ID), + KEY IX_PDJ_REQUESTER (REQUESTER_ID, REG_DT), + KEY IX_PDJ_STATUS (STATUS, REG_DT) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 + COMMENT='개인정보 접속기록 CSV 비동기 다운로드 작업'; + + +-- ───────────────────────────────────────────────────────────── +-- [privacy.emplyr_scrty_index_mysql.sql] (2026-08-11 통합) +-- ───────────────────────────────────────────────────────────── +-- ===================================================================== +-- 권한 매핑 조회 인덱스 : COMTNEMPLYRSCRTYESTBS(SCRTY_DTRMN_TRGET_ID) (SFR-001/SFR-002) +-- [문제] 인덱스 0개(운영 데이터 기준 약 8만행) — 로그인마다 권한조회 풀스캔, 계정당 약 200ms. +-- [재실행 안전] information_schema 로 존재 확인 후 생성(MySQL 은 CREATE INDEX IF NOT EXISTS 미지원) +-- ===================================================================== +SET @idx := (SELECT COUNT(1) FROM information_schema.STATISTICS + WHERE TABLE_SCHEMA = DATABASE() + AND TABLE_NAME = 'COMTNEMPLYRSCRTYESTBS' + AND INDEX_NAME = 'IX_EMPLYRSCRTY_TRGET'); + +SET @ddl := IF(@idx > 0, + 'SELECT ''SKIP: IX_EMPLYRSCRTY_TRGET already exists'' AS msg', + 'CREATE INDEX IX_EMPLYRSCRTY_TRGET ON COMTNEMPLYRSCRTYESTBS (SCRTY_DTRMN_TRGET_ID)' +); +PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt; + + +-- ───────────────────────────────────────────────────────────── +-- [privacy.mber_nm_encrypt_create_mysql.sql] (2026-08-11 통합) +-- ───────────────────────────────────────────────────────────── +-- ===================================================================== +-- 회원 이름(MBER_NM) 암호화 컬럼 추가 : COMTNGNRLMBER (추가제안 Action4) +-- [대상] COMTNGNRLMBER 단독(실측 77,053건). biz_mber_ssn/biz_join_event/biz_stat_summary_* 는 +-- 5,000건 기준 미달로 미대상. +-- [설계] 평문 컬럼 유지 + 별도 컬럼 추가(MBER_NM_ENC/MBER_NM_HASH/CRYPTO_KEY_VER). +-- MBER_NM_ENC IS NULL 로 미암호화 여부를 판정하므로 이중 암호화 사고가 원천 차단된다. +-- [검색] AES-GCM 랜덤IV라 부분일치 불가 — 완전일치는 HMAC-SHA256 해시(MBER_NM_HASH)로 별도 지원. +-- [무중단] ALGORITHM=INPLACE, LOCK=NONE. +-- ★★ 이 DDL 적용 후, PART 4(뷰 GNR 컷오버) 실행 전에 회원 이름 백필 배치가 반드시 +-- 완료돼야 한다 — /mnt/sec/memberNmCrypto/start.do?confirm=BACKFILL (POST, ROLE_ADMIN) +-- 로 트리거. PART 4 자체에 미완료 시 자동 SKIP 가드가 있다. ★★ +-- ===================================================================== +DROP PROCEDURE IF EXISTS PRC_PRIVACY_ADD_NM_CRYPTO_COLS; +DELIMITER $$ +CREATE PROCEDURE PRC_PRIVACY_ADD_NM_CRYPTO_COLS() +BEGIN + DECLARE v_cnt INT DEFAULT 0; + + SELECT COUNT(*) INTO v_cnt FROM information_schema.COLUMNS + WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'COMTNGNRLMBER' + AND COLUMN_NAME = 'MBER_NM_ENC'; + IF v_cnt = 0 THEN + ALTER TABLE COMTNGNRLMBER + ADD COLUMN MBER_NM_ENC VARCHAR(255) NULL + COMMENT '이름 암호문 Base64(IV12||CT||TAG16), AES-128/GCM', + ALGORITHM=INPLACE, LOCK=NONE; + END IF; + + SELECT COUNT(*) INTO v_cnt FROM information_schema.COLUMNS + WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'COMTNGNRLMBER' + AND COLUMN_NAME = 'MBER_NM_HASH'; + IF v_cnt = 0 THEN + ALTER TABLE COMTNGNRLMBER + ADD COLUMN MBER_NM_HASH CHAR(64) NULL + COMMENT '이름 완전일치 검색용 HMAC-SHA256(pepper) 소문자 hex', + ALGORITHM=INPLACE, LOCK=NONE; + END IF; + + SELECT COUNT(*) INTO v_cnt FROM information_schema.COLUMNS + WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'COMTNGNRLMBER' + AND COLUMN_NAME = 'CRYPTO_KEY_VER'; + IF v_cnt = 0 THEN + ALTER TABLE COMTNGNRLMBER + ADD COLUMN CRYPTO_KEY_VER TINYINT NULL + COMMENT '암호화 키 버전(키 교체 대비). 미암호화 행은 NULL', + ALGORITHM=INPLACE, LOCK=NONE; + END IF; + + SELECT COUNT(*) INTO v_cnt FROM information_schema.STATISTICS + WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'COMTNGNRLMBER' + AND INDEX_NAME = 'IX_GNRLMBER_NM_HASH'; + IF v_cnt = 0 THEN + CREATE INDEX IX_GNRLMBER_NM_HASH ON COMTNGNRLMBER (MBER_NM_HASH); + END IF; +END$$ +DELIMITER ; + +CALL PRC_PRIVACY_ADD_NM_CRYPTO_COLS(); +DROP PROCEDURE IF EXISTS PRC_PRIVACY_ADD_NM_CRYPTO_COLS; + +-- 백필 진행상태 테이블. 커서를 잃어도 MBER_NM_ENC IS NULL 조건이 있어 재실행하면 복구된다. +-- LAST_KEY 가 VARBINARY 인 이유: COMTNGNRLMBER.MBER_ID 가 VARBINARY(32)이고 비인쇄 바이트 포함행 존재. +CREATE TABLE IF NOT EXISTS MIG_CRYPTO_PROGRESS ( + JOB_ID VARCHAR(50) NOT NULL COMMENT '작업 식별자(예: COMTNGNRLMBER.MBER_NM)', + LAST_KEY VARBINARY(32) NULL COMMENT '마지막 처리 PK(커서, 원형 바이트). NULL이면 처음부터', + DONE_CNT BIGINT NOT NULL DEFAULT 0 COMMENT '누적 처리 건수', + ERR_CNT BIGINT NOT NULL DEFAULT 0 COMMENT '누적 실패 건수', + JOB_STTUS CHAR(1) NOT NULL DEFAULT 'R' COMMENT 'R:진행 S:중지요청 C:완료 E:오류', + STR_DT DATETIME NULL COMMENT '시작 일시', + END_DT DATETIME NULL COMMENT '종료 일시', + UPDT_DT DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP + ON UPDATE CURRENT_TIMESTAMP COMMENT '갱신 일시', + RM VARCHAR(500) NULL COMMENT '비고(마지막 오류 메시지 등)', + PRIMARY KEY (JOB_ID) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 + COMMENT='개인정보 암호화 이관 배치 진행상태'; + + +-- ───────────────────────────────────────────────────────────── +-- [privacy.mber_nm_dead_views_drop_mysql.sql] (2026-08-11 통합) +-- ───────────────────────────────────────────────────────────── +-- ===================================================================== +-- 회원 이름 암호화 — 죽은 뷰 8개 정리 +-- [배경] COMTNGNRLMBER.MBER_NM 참조 뷰 10개 중 comvnuserinfo/comvnusermaster(PART 4 대상)만 +-- 실사용. 나머지 8개는 FROM절 참조 0건인 화석(원래 뷰를 쓰던 기능이 인라인 UNION으로 +-- 대체되어 남은 것) — 그대로 두면 뷰도 GNR 분기 평문을 노출한다. +-- [백업] 운영 반영 전 `src/script/mysql/backup/privacy.mber_nm_dead_views_backup_20260810.sql` +-- 로 정의를 먼저 백업해 둘 것(복원 시 그대로 재실행). +-- [재실행 안전] DROP VIEW IF EXISTS. +-- ===================================================================== +DROP VIEW IF EXISTS comtnbbsuserinfo; +DROP VIEW IF EXISTS comvnusermaster2; +DROP VIEW IF EXISTS comvnusermaster_1; +DROP VIEW IF EXISTS excelappconfirmlist; +DROP VIEW IF EXISTS excelappconfirmlist_auth; +DROP VIEW IF EXISTS selectaddedoptionsinf; +DROP VIEW IF EXISTS selectboardarticlelist; +DROP VIEW IF EXISTS selectmiddleappconfirmlist; + + -- ===================================================================== -- PART 2. DML (기준/시드 데이터) ※ 멱등: NOT EXISTS 가드로 중복 적재 방지 --- 수록: PDAL_JOB_MAPPING(업무유형 매핑 13행), ACCOUNT_MNG_POLICY(기본정책 1행) +-- 수록: ACCOUNT_MNG_POLICY(기본정책 1행) -- 미수록: OTP_DEVICE / PERSONAL_DATA_ACCESS_LOG / *_HISTORY = 런타임 적재(시드 없음) +-- [2026-08-11] PDAL_JOB_MAPPING(업무유형 매핑 13행) 시드 제거 — 업무유형 매핑 관리 화면 +-- (메뉴 9100045) 폐기. 접속기록의 수행업무는 MenuAccessLogInterceptor 가 +-- COMTNMENUINFO 메뉴명에서 직접 채우므로 이 시드는 더 이상 소비되지 않는다. -- ===================================================================== --- 1) PDAL_JOB_MAPPING — 개인정보 처리 업무유형 매핑(접속기록 AOP 분류 기준, 필수) -INSERT INTO PDAL_JOB_MAPPING (TARGET_CLASS, METHOD_PTTRN, JOB_SE_CODE, JOB_SE_NM, DML_GUBUN, SORT_ORDR, RM) -SELECT * FROM ( - SELECT 'EgovMberManageServiceImpl' AS TARGET_CLASS, 'select' AS METHOD_PTTRN, 'MBER_VIEW' AS JOB_SE_CODE, '회원관리-조회' AS JOB_SE_NM, 'S' AS DML_GUBUN, 10 AS SORT_ORDR, '회원 개인정보 조회' AS RM UNION ALL - SELECT 'EgovMberManageServiceImpl', 'insert', 'MBER_REG', '회원관리-등록', 'I', 10, NULL UNION ALL - SELECT 'EgovMberManageServiceImpl', 'update', 'MBER_MOD', '회원관리-수정', 'U', 10, NULL UNION ALL - SELECT 'EgovMberManageServiceImpl', 'delete', 'MBER_DEL', '회원관리-삭제', 'D', 10, NULL UNION ALL - SELECT 'EgovMberManageServiceImpl', '*', 'MBER_ETC', '회원관리-기타', NULL, 90, '미분류 폴백' UNION ALL - SELECT 'EgovEntrprsManageServiceImpl', 'select', 'ENTRPRS_VIEW', '업체관리-조회', 'S', 10, '업체 담당자 개인정보 조회' UNION ALL - SELECT 'EgovEntrprsManageServiceImpl', 'insert', 'ENTRPRS_REG', '업체관리-등록', 'I', 10, NULL UNION ALL - SELECT 'EgovEntrprsManageServiceImpl', 'update', 'ENTRPRS_MOD', '업체관리-수정', 'U', 10, NULL UNION ALL - SELECT 'EgovEntrprsManageServiceImpl', 'delete', 'ENTRPRS_DEL', '업체관리-삭제', 'D', 10, NULL UNION ALL - SELECT 'EgovEntrprsManageServiceImpl', '*', 'ENTRPRS_ETC', '업체관리-기타', NULL, 90, '미분류 폴백' UNION ALL - SELECT 'MberSsnServiceImpl', '*', 'MBER_SSN', '회원 고유식별정보 처리', NULL, 20, '주민등록번호 등 고유식별정보' UNION ALL - SELECT 'BizMberMergeServiceImpl', '*', 'MBER_MERGE', '회원 통합/이관', NULL, 30, NULL UNION ALL - SELECT 'BizTalentMberManageServiceImpl', '*', 'TALENT_MBER', '재능나눔 회원관리', NULL, 30, NULL -) seed -WHERE NOT EXISTS (SELECT 1 FROM PDAL_JOB_MAPPING pjm WHERE pjm.JOB_SE_CODE = seed.JOB_SE_CODE); - --- 2) ACCOUNT_MNG_POLICY — 장기 미사용 계정 관리 기본정책(단일행 POLICY_ID=1) +-- 1) ACCOUNT_MNG_POLICY — 장기 미사용 계정 관리 기본정책(단일행 POLICY_ID=1) INSERT INTO ACCOUNT_MNG_POLICY (POLICY_ID, DORMANT_DAY, ALERT_D1, ALERT_D2, ALERT_D3, DELETE_PRESERVE_DAY, BATCH_CYCLE, UPD_ID, UPD_DT) SELECT 1, 365, 30, 7, 0, 365, 'DAILY', 'SYSTEM', NOW() FROM DUAL @@ -1085,66 +1214,33 @@ SELECT 9100044, g.ac AND NOT EXISTS (SELECT 1 FROM COMTNMENUCREATDTLS x WHERE x.MENU_NO = 9100044 AND x.AUTHOR_CODE = g.ac); --- ── [6] 업무유형 매핑 관리 메뉴(9100045) — SFR-004 #32-1, 2026-08-07 추가 ── --- 원본: src/script/mysql/dml/privacy.menu_job_mapping_insert_mysql.sql --- ★ left.jsp 9100045 분기 + 하이라이트 조건 4곳 동반 배포 필수(위와 동일 제약) --- ※ 접속기록(9100044)의 '수행업무' 분류 기준을 편집하는 화면이라 바로 다음(MENU_ORDR 11)에 배치 +-- ── [6] 접근통제 정책 설정 메뉴(9100047) — SER-001 요건 4·5, 2026-08-10 추가(2026-08-11 통합) ── +-- 원본: src/script/mysql/dml/privacy.menu_access_policy_insert_mysql.sql +-- ★ left.jsp 9100047 분기 + 하이라이트 조건 4곳 동반 배포 필수(위와 동일 제약) +-- [선행] 위 PART 1의 ACCESS_CONTROL_POLICY 가 먼저 생성돼 있어야 화면 진입 시 기본값 표시 INSERT INTO COMTNPROGRMLIST (PROGRM_FILE_NM, PROGRM_STRE_PATH, PROGRM_KOREAN_NM, PROGRM_DC, URL) -SELECT * FROM ( - SELECT 'JobMappingList' AS PROGRM_FILE_NM, '/mnt/sec/jobMapping/' AS PROGRM_STRE_PATH, - '업무유형 매핑 관리' AS PROGRM_KOREAN_NM, '개인정보 접속기록 업무유형 매핑 관리(SFR-004 #32-1)' AS PROGRM_DC, - '/mnt/sec/jobMapping/listView.do?page=9100045' AS URL -) seed -WHERE NOT EXISTS (SELECT 1 FROM COMTNPROGRMLIST p WHERE p.PROGRM_FILE_NM = seed.PROGRM_FILE_NM); +SELECT 'AccessPolicySet', '/mnt/sec/accessPolicy/', '접근통제 정책 설정', + '계정 잠금·자동 로그아웃·세션 재발급 정책 설정(SER-001 요건 4·5)', '/mnt/sec/accessPolicy/policyView.do?page=9100047' + FROM DUAL + WHERE NOT EXISTS (SELECT 1 FROM COMTNPROGRMLIST WHERE PROGRM_FILE_NM = 'AccessPolicySet'); INSERT INTO COMTNMENUINFO (MENU_NM, PROGRM_FILE_NM, MENU_NO, UPPER_MENU_NO, MENU_ORDR, MENU_DC, RELATE_IMAGE_PATH, RELATE_IMAGE_NM) -SELECT * FROM ( - SELECT '업무유형 매핑 관리' AS MENU_NM, 'JobMappingList' AS PROGRM_FILE_NM, 9100045 AS MENU_NO, - 9100030 AS UPPER_MENU_NO, 11 AS MENU_ORDR, '접속기록 업무유형 매핑 등록·수정' AS MENU_DC, - '/' AS RELATE_IMAGE_PATH, '/' AS RELATE_IMAGE_NM -) seed -WHERE NOT EXISTS (SELECT 1 FROM COMTNMENUINFO m WHERE m.MENU_NO = seed.MENU_NO); +SELECT '접근통제 정책', 'AccessPolicySet', 9100047, 9100030, 13, '계정 잠금·자동 로그아웃·세션 재발급 정책 설정', '/', '/' + FROM DUAL + WHERE NOT EXISTS (SELECT 1 FROM COMTNMENUINFO WHERE MENU_NO = 9100047); INSERT INTO COMTNMENUCREATDTLS (MENU_NO, AUTHOR_CODE) -SELECT 9100045, g.ac +SELECT 9100047, g.ac FROM (SELECT 'IS_AUTHENTICATED_FULLY' AS ac UNION ALL SELECT 'ROLE_ADMIN') g WHERE g.ac IN (SELECT AUTHOR_CODE FROM COMTNAUTHORINFO) AND NOT EXISTS (SELECT 1 FROM COMTNMENUCREATDTLS x - WHERE x.MENU_NO = 9100045 AND x.AUTHOR_CODE = g.ac); - --- ── [7] 로그인·로그아웃 접속기록 메뉴(9100046) — 개발항목 62, 2026-08-07 추가 ── --- 원본: src/script/mysql/dml/privacy.menu_conect_log_insert_mysql.sql --- ★ left.jsp 9100046 분기 + 하이라이트 조건 4곳 동반 배포 필수(위와 동일 제약) --- ※ 인증 실패 이력(9100039)·개인정보 접속기록(9100044)과 같은 보안 로그 묶음(MENU_ORDR 12) -INSERT INTO COMTNPROGRMLIST (PROGRM_FILE_NM, PROGRM_STRE_PATH, PROGRM_KOREAN_NM, PROGRM_DC, URL) -SELECT * FROM ( - SELECT 'ConectLogList' AS PROGRM_FILE_NM, '/mnt/sec/conectLog/' AS PROGRM_STRE_PATH, - '로그인 접속기록' AS PROGRM_KOREAN_NM, - '로그인·로그아웃 접속기록 조회·다운로드(접속ID·접속일시·IP·접속위치, 개발항목 62)' AS PROGRM_DC, - '/mnt/sec/conectLog/listView.do?page=9100046' AS URL -) seed -WHERE NOT EXISTS (SELECT 1 FROM COMTNPROGRMLIST p WHERE p.PROGRM_FILE_NM = seed.PROGRM_FILE_NM); - -INSERT INTO COMTNMENUINFO (MENU_NM, PROGRM_FILE_NM, MENU_NO, UPPER_MENU_NO, MENU_ORDR, MENU_DC, RELATE_IMAGE_PATH, RELATE_IMAGE_NM) -SELECT * FROM ( - SELECT '로그인 접속기록' AS MENU_NM, 'ConectLogList' AS PROGRM_FILE_NM, 9100046 AS MENU_NO, - 9100030 AS UPPER_MENU_NO, 12 AS MENU_ORDR, '로그인·로그아웃 접속기록 조회' AS MENU_DC, - '/' AS RELATE_IMAGE_PATH, '/' AS RELATE_IMAGE_NM -) seed -WHERE NOT EXISTS (SELECT 1 FROM COMTNMENUINFO m WHERE m.MENU_NO = seed.MENU_NO); - -INSERT INTO COMTNMENUCREATDTLS (MENU_NO, AUTHOR_CODE) -SELECT 9100046, g.ac - FROM (SELECT 'IS_AUTHENTICATED_FULLY' AS ac UNION ALL SELECT 'ROLE_ADMIN') g - WHERE g.ac IN (SELECT AUTHOR_CODE FROM COMTNAUTHORINFO) - AND NOT EXISTS (SELECT 1 FROM COMTNMENUCREATDTLS x - WHERE x.MENU_NO = 9100046 AND x.AUTHOR_CODE = g.ac); + WHERE x.MENU_NO = 9100047 AND x.AUTHOR_CODE = g.ac); -- ── [검증] ── -- SELECT m.MENU_NO,m.UPPER_MENU_NO,m.MENU_NM,m.MENU_ORDR,p.URL, -- (SELECT COUNT(*) FROM COMTNMENUCREATDTLS d WHERE d.MENU_NO=m.MENU_NO) grants -- FROM COMTNMENUINFO m JOIN COMTNPROGRMLIST p ON m.PROGRM_FILE_NM=p.PROGRM_FILE_NM --- WHERE m.MENU_NO IN (9100035,9100036,9100037,9100038,9100039,9100044,9100045,9100046) ORDER BY m.MENU_ORDR; +-- WHERE m.MENU_NO IN (9100035,9100036,9100037,9100038,9100039,9100044,9100047) ORDER BY m.MENU_ORDR; -- ── [롤백] (역순 권장, 4건 일괄) ── -- DELETE FROM COMTNMENUCREATDTLS WHERE MENU_NO BETWEEN 9100035 AND 9100038; @@ -1152,4 +1248,179 @@ SELECT 9100046, g.ac -- DELETE FROM COMTNPROGRMLIST WHERE PROGRM_FILE_NM IN -- ('AuthorRequestList','AuthorHistoryList','DormantStatusList','AccountPolicySet'); + +-- ===================================================================== +-- PART 4. 뷰 GNR 컷오버 — comvnusermaster / comvnuserinfo (2026-08-11 통합) +-- 원본: src/script/mysql/ddl/privacy.mber_nm_view_hash_cutover_mysql.sql +-- 추가제안 Action4(회원정보 암호화)의 마지막 단계. GNR 분기를 평문(MBER_NM)에서 +-- 암호문(MBER_NM_ENC)으로 바꾸고 검색용 USER_NM_HASH 컬럼을 추가한다. +-- +-- ★★ all-or-nothing 게이트 — 이 PART는 자동으로 스스로를 지킨다 ★★ +-- 아래에서 실제 뷰 재정의를 실행하기 '전에' 두 가지를 확인한다: +-- ① comvnusermaster 에 USER_NM_HASH 가 이미 있으면(=이미 컷오버됨) SKIP +-- ② COMTNGNRLMBER 에 MBER_NM_ENC IS NULL 인 행이 하나라도 있으면(=백필 미완료) SKIP +-- → 이 경우 /mnt/sec/memberNmCrypto/start.do?confirm=BACKFILL(POST, ROLE_ADMIN)로 +-- 백필을 먼저 끝내고 이 install_mysql.sql 을 다시 실행하면 된다(재실행 안전). +-- 두 조건을 모두 통과했을 때만 실제 CREATE OR REPLACE VIEW 를 수행한다 — 이 파일 하나만 +-- "필요할 때마다" 반복 실행하는 것이 정상 사용법이다(설치 1회 + 백필 후 재실행 1회, 2번 실행). +-- +-- [기법] information_schema.views.VIEW_DEFINITION 을 서버사이드 REPLACE()로 최소 치환 +-- (PREPARE/EXECUTE 동적 SQL) — comvnuserinfo 의 한글 리터럴(USER_GUBUN_NM 등)을 클라이언트 +-- 에서 절대 재입력하지 않아 셸→mysql.exe stdin 이중 인코딩 문제를 원천 회피한다. +-- [백업] 실행 전 원본 정의를 privacy_view_def_backup 테이블에 저장(롤백은 그 값으로 +-- CREATE OR REPLACE VIEW 재실행). +-- ===================================================================== + +DROP PROCEDURE IF EXISTS PRC_PRIVACY_MBER_NM_VIEW_CUTOVER; +DELIMITER $$ +CREATE PROCEDURE PRC_PRIVACY_MBER_NM_VIEW_CUTOVER() +BEGIN + DECLARE v_pending INT DEFAULT 0; + DECLARE v_done INT DEFAULT 0; + + SELECT COUNT(*) INTO v_pending FROM COMTNGNRLMBER WHERE MBER_NM_ENC IS NULL; + SELECT COUNT(*) INTO v_done FROM information_schema.COLUMNS + WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'comvnusermaster' AND COLUMN_NAME = 'USER_NM_HASH'; + + IF v_done > 0 THEN + SELECT 'SKIP: comvnusermaster.USER_NM_HASH 이미 존재 — 뷰 컷오버 기완료' AS PART4_GATE_STATUS; + ELSEIF v_pending > 0 THEN + SELECT CONCAT('SKIP: 백필 미완료 — COMTNGNRLMBER 미암호화 ', v_pending, + '건 남음. /mnt/sec/memberNmCrypto/start.do?confirm=BACKFILL 로 먼저 완료 후', + ' 이 파일을 재실행할 것') AS PART4_GATE_STATUS; + ELSE + SELECT 'PROCEED: 백필 완료 확인(미암호화 0건) — 뷰 컷오버 진행' AS PART4_GATE_STATUS; + + -- 0. 백업: 현재 뷰 정의를 별도 테이블에 저장(롤백용) + CREATE TABLE IF NOT EXISTS privacy_view_def_backup ( + view_name VARCHAR(64) NOT NULL, + backup_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + view_definition MEDIUMTEXT NOT NULL, + PRIMARY KEY (view_name, backup_at) + ); + + INSERT INTO privacy_view_def_backup (view_name, view_definition) + SELECT 'comvnusermaster', VIEW_DEFINITION + FROM information_schema.views + WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'comvnusermaster'; + + INSERT INTO privacy_view_def_backup (view_name, view_definition) + SELECT 'comvnuserinfo', VIEW_DEFINITION + FROM information_schema.views + WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'comvnuserinfo'; + + -- 1. comvnusermaster 재정의 + -- GNR 분기: MBER_NM(평문) -> MBER_NM_ENC(암호문), USER_NM_HASH 컬럼 추가(MBER_NM_HASH) + -- USR/ENT/GRO 분기: USER_NM_HASH 컬럼 추가(NULL — 암호화 대상 아님) + SET @def_master = ( + SELECT VIEW_DEFINITION FROM information_schema.views + WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'comvnusermaster' + ); + + SET @def_master = REPLACE( + @def_master, + '`comtngnrlmber`.`MBER_NM` AS `USER_NM`', + '`comtngnrlmber`.`MBER_NM_ENC` AS `USER_NM`' + ); + SET @def_master = REPLACE( + @def_master, + ' end) AS `ADULT` from `comtngnrlmber` where', + ' end) AS `ADULT`,`comtngnrlmber`.`MBER_NM_HASH` AS `USER_NM_HASH` from `comtngnrlmber` where' + ); + SET @def_master = REPLACE( + @def_master, + '\'N\' AS `ADULT` from `comtnemplyrinfo` union all', + '\'N\' AS `ADULT`,NULL AS `USER_NM_HASH` from `comtnemplyrinfo` union all' + ); + SET @def_master = REPLACE( + @def_master, + '\'N\' AS `ADULT` from `comtnentrprsmber` where', + '\'N\' AS `ADULT`,NULL AS `USER_NM_HASH` from `comtnentrprsmber` where' + ); + SET @def_master = REPLACE( + @def_master, + '\'N\' AS `ADULT` from `biz_group` where', + '\'N\' AS `ADULT`,NULL AS `USER_NM_HASH` from `biz_group` where' + ); + + SELECT + (LENGTH(@def_master) - LENGTH(REPLACE(@def_master, 'USER_NM_HASH', ''))) / LENGTH('USER_NM_HASH') AS hash_column_occurrences, + (@def_master LIKE '%MBER_NM_ENC%') AS gnr_uses_enc_column, + (@def_master NOT LIKE '%`comtngnrlmber`.`MBER_NM` AS `USER_NM`%') AS old_plaintext_reference_removed; + + SET @sql_master = CONCAT('CREATE OR REPLACE VIEW comvnusermaster AS ', @def_master); + PREPARE stmt_master FROM @sql_master; + EXECUTE stmt_master; + DEALLOCATE PREPARE stmt_master; + + -- 2. comvnuserinfo 재정의 (USER_GUBUN_NM 한글 리터럴은 REPLACE 대상에 포함하지 않는다 — 그대로 보존) + SET @def_info = ( + SELECT VIEW_DEFINITION FROM information_schema.views + WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'comvnuserinfo' + ); + + SET @def_info = REPLACE( + @def_info, + '`comtngnrlmber`.`MBER_NM` AS `USER_NM`', + '`comtngnrlmber`.`MBER_NM_ENC` AS `USER_NM`' + ); + SET @def_info = REPLACE( + @def_info, + '`comtngnrlmber`.`ADMIN_UNLOCK_DT` AS `ADMIN_UNLOCK_DT` from `comtngnrlmber` union all', + '`comtngnrlmber`.`ADMIN_UNLOCK_DT` AS `ADMIN_UNLOCK_DT`,`comtngnrlmber`.`MBER_NM_HASH` AS `USER_NM_HASH` from `comtngnrlmber` union all' + ); + SET @def_info = REPLACE( + @def_info, + '`biz_group`.`ADMIN_UNLOCK_DT` AS `ADMIN_UNLOCK_DT` from `biz_group` union all', + '`biz_group`.`ADMIN_UNLOCK_DT` AS `ADMIN_UNLOCK_DT`,NULL AS `USER_NM_HASH` from `biz_group` union all' + ); + SET @def_info = REPLACE( + @def_info, + '`comtnentrprsmber`.`ADMIN_UNLOCK_DT` AS `ADMIN_UNLOCK_DT` from `comtnentrprsmber` union all', + '`comtnentrprsmber`.`ADMIN_UNLOCK_DT` AS `ADMIN_UNLOCK_DT`,NULL AS `USER_NM_HASH` from `comtnentrprsmber` union all' + ); + SET @def_info = REPLACE( + @def_info, + '`comtnemplyrinfo`.`ADMIN_UNLOCK_DT` AS `ADMIN_UNLOCK_DT` from `comtnemplyrinfo`', + '`comtnemplyrinfo`.`ADMIN_UNLOCK_DT` AS `ADMIN_UNLOCK_DT`,NULL AS `USER_NM_HASH` from `comtnemplyrinfo`' + ); + + SELECT + (LENGTH(@def_info) - LENGTH(REPLACE(@def_info, 'USER_NM_HASH', ''))) / LENGTH('USER_NM_HASH') AS hash_column_occurrences, + (@def_info LIKE '%MBER_NM_ENC%') AS gnr_uses_enc_column, + (@def_info NOT LIKE '%`comtngnrlmber`.`MBER_NM` AS `USER_NM`%') AS old_plaintext_reference_removed; + + SET @sql_info = CONCAT('CREATE OR REPLACE VIEW comvnuserinfo AS ', @def_info); + PREPARE stmt_info FROM @sql_info; + EXECUTE stmt_info; + DEALLOCATE PREPARE stmt_info; + + -- 3. 반영 후 검증 + SELECT TABLE_NAME, COLUMN_NAME, ORDINAL_POSITION + FROM information_schema.columns + WHERE TABLE_SCHEMA = DATABASE() + AND TABLE_NAME IN ('comvnusermaster', 'comvnuserinfo') + AND COLUMN_NAME IN ('USER_NM', 'USER_SE', 'USER_GUBUN', 'USER_NM_HASH') + ORDER BY TABLE_NAME, ORDINAL_POSITION; + + SELECT USER_ID, USER_SE, LEFT(USER_NM, 20) AS user_nm_sample, LEFT(USER_NM_HASH, 20) AS hash_sample + FROM comvnusermaster WHERE USER_SE = 'GNR' LIMIT 1; + SELECT USER_ID, USER_SE, LEFT(USER_NM, 20) AS user_nm_sample, USER_NM_HASH + FROM comvnusermaster WHERE USER_SE = 'USR' LIMIT 1; + SELECT USER_SE, COUNT(*) FROM comvnusermaster GROUP BY USER_SE; + + SELECT USER_GUBUN, LEFT(USER_NM, 20) AS user_nm_sample, LEFT(USER_NM_HASH, 20) AS hash_sample + FROM comvnuserinfo WHERE USER_GUBUN = 'GNR' LIMIT 1; + SELECT USER_GUBUN, COUNT(*) FROM comvnuserinfo GROUP BY USER_GUBUN; + END IF; +END$$ +DELIMITER ; + +CALL PRC_PRIVACY_MBER_NM_VIEW_CUTOVER(); +DROP PROCEDURE IF EXISTS PRC_PRIVACY_MBER_NM_VIEW_CUTOVER; + +-- ── [롤백] ── +-- SELECT view_definition FROM privacy_view_def_backup WHERE view_name='comvnusermaster' ORDER BY backup_at DESC LIMIT 1; +-- -- 위 값을 CREATE OR REPLACE VIEW comvnusermaster AS 로 재실행(comvnuserinfo 동일) + -- ############################## END ##############################