From 3d359a1a8f0056f1423005529a58bfaed2082712 Mon Sep 17 00:00:00 2001 From: infraurp Date: Thu, 27 Aug 2026 16:03:34 +0900 Subject: [PATCH] =?UTF-8?q?=EA=B0=9C=EC=9D=B8=EC=A0=95=EB=B3=B4=EB=B3=B4?= =?UTF-8?q?=ED=98=B8=20=EA=B8=B0=EB=8A=A5=EA=B0=9C=EC=84=A0=209=EC=B0=A8?= =?UTF-8?q?=20=E2=80=94=20=EA=B4=80=EB=A6=AC=EC=9E=90=20=ED=99=94=EB=A9=B4?= =?UTF-8?q?=20=EB=B0=B0=EC=B9=98=EB=A5=BC=20=EC=A0=9C=EC=95=88=EC=84=9C=20?= =?UTF-8?q?=E2=85=A2-4=20=EC=8B=9C=EC=95=88=EB=8C=80=EB=A1=9C=20=EC=9E=AC?= =?UTF-8?q?=ED=8E=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 제안요약서 slide 14~15 의 화면 시안을 열어보니 8차 구현과 어긋나 있었다. 시안은 좌측메뉴를 "접속기록·감사" 한 묶음으로 그리고 그 아래 4개를 두며, 이상행위를 별도 화면이 아니라 접속기록 목록의 "감사" 칸에 인라인으로 표시한다. 8차는 이 넷을 권한관리 하위에 낱개로 붙여 놓았다 — 기능은 같은데 배치가 달랐다. · 메뉴 재편 — "접속기록 · 감사"(9100052) 그룹 신설 ① 접속기록 조회(9100044) ② 개인정보 처리기록(9100053, 신설) ③ 이상행위 감사(9100049) ④ 정기 점검 리포트(9100048) 파기이력(9100050)은 시안에 메뉴가 없어 내렸다. 화면은 그대로 두고 정기 점검 리포트에서 버튼으로 연다(양방향 링크). 기능을 지운 게 아니라 노출 위치만 시안에 맞춘 것이다. 그룹 메뉴의 PROGRM_FILE_NM 은 NOT NULL 이고 기존 그룹이 모두 센티넬 'dir' 을 쓴다 — NULL 로 넣었다가 ERROR 1048 로 실패한 뒤 형제 행을 실측해 정정. · 접속기록 조회 화면에 "감사" 칸 신설 이상(처리결과 F=차단·비인가)은 붉게, 점검(정상이나 대량조회·계정공유의심· 인증실패반복 대상 또는 업무시간 밖)은 노랗게 표시하고 해당 행을 강조한다. 요약 줄도 시안대로 "검색결과 N건 · 보관기간 730일 · 이상행위 N건 탐지". 판정값(auditFlag)은 저장하지 않는다 — 탐지 임계치는 정책값이라 적재해 두면 임계치를 조정한 뒤에도 과거 기준으로 굳은 값이 남는다. 감사 표시는 집계 5회라 기간 미지정 시 최근 30일로 잘라 계산하고, 실패해도 목록은 그대로 보여준다(PSR-001). · ② 개인정보 처리기록 — 조회(S)를 뺀 등록·수정·삭제만 화면·검색조건은 ①과 공유하고 excludeSelect 조건 하나만 다르다. 같은 표를 두 벌 관리하지 않으려는 선택이다. 검증: mvn compile EXIT=0, JSP 4종 Jasper 오류 0건, 로컬 ROOT 반영 후 재기동 정상(25.4초), 신규 processLog 포함 5개 엔드포인트 302→로그인 / 대조군 404, 통합본 privacy.install_mysql.sql PART3 [8] 병합 후 전체 재실행 오류 0건(멱등). Co-Authored-By: Claude Opus 5 --- .../PersonalDataAccessLogController.java | 134 ++++++ .../PersonalDataAccessLogSearchVO.java | 15 + .../auditlog/PersonalDataAccessLogVO.java | 13 + .../privacy/PrivacyAccessLog_SQL_Mysql.xml | 9 + .../privacy/PrivacyAccessLog_SQL_Oracle.xml | 9 + src/main/webapp/WEB-INF/jsp/site/mnt/left.jsp | 54 ++- .../jsp/site/mnt/sec/accessLog/list.jsp | 54 ++- .../jsp/site/mnt/sec/inspection/list.jsp | 6 + .../jsp/site/mnt/sec/purgeHistory/list.jsp | 4 + ...privacy.menu_audit_group_regroup_mysql.sql | 117 +++++ src/script/mysql/privacy.install_mysql.sql | 422 ++++++++++++++---- 11 files changed, 722 insertions(+), 115 deletions(-) create mode 100644 src/script/mysql/dml/privacy.menu_audit_group_regroup_mysql.sql 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 35b8c98..81d445a 100644 --- a/src/main/java/egovframework/com/cmm/privacy/auditlog/PersonalDataAccessLogController.java +++ b/src/main/java/egovframework/com/cmm/privacy/auditlog/PersonalDataAccessLogController.java @@ -9,9 +9,12 @@ import java.net.URLEncoder; import java.text.SimpleDateFormat; import java.util.ArrayList; import java.util.Date; +import java.util.Calendar; +import java.util.HashSet; import java.util.LinkedHashMap; import java.util.List; import java.util.Map; +import java.util.Set; import javax.annotation.Resource; import javax.servlet.http.HttpServletRequest; @@ -58,6 +61,19 @@ public class PersonalDataAccessLogController { /** 화면에 띄울 '내 다운로드 요청' 최대 건수(만료 전 기준). 폭주 방지용 상한이다. */ private static final int MY_JOB_MAX = 100; + /** 감사 표시 — 이상(처리결과 차단·비인가). */ + private static final String AUDIT_ABNORMAL = "A"; + /** 감사 표시 — 점검(정상 처리였으나 이상징후 대상). */ + private static final String AUDIT_WATCH = "W"; + /** 감사 표시 계산 시 기간 미지정이면 적용할 기본 조회기간(일). */ + private static final int AUDIT_DEFAULT_DAYS = 30; + /** 업무시간 시작(시). AbnormalAccessPolicy 기본값과 동일. */ + private static final int OFF_HOURS_START = 9; + /** 업무시간 종료(시). */ + private static final int OFF_HOURS_END = 18; + /** 화면 안내에 표기할 접속기록 보관기간(일). PrivacyBatchServiceImpl 의 파기 기준과 같은 값. */ + private static final int ACCESS_LOG_RETENTION_DAYS = 730; + @Resource(name = "personalDataAccessLogSearchService") private PersonalDataAccessLogSearchService personalDataAccessLogSearchService; @@ -70,6 +86,10 @@ public class PersonalDataAccessLogController { @Resource(name = "accessLogDownloadJobService") private AccessLogDownloadJobService accessLogDownloadJobService; + /** 감사 표시용 이상징후 탐지(#35). 목록 렌더 시 검색기간 기준으로 대조한다. */ + @Resource(name = "abnormalAccessDetectionService") + private AbnormalAccessDetectionService abnormalAccessDetectionService; + /** * 개인정보 접속기록 조회 화면(서버 렌더링). * 뷰: /WEB-INF/jsp/site/mnt/sec/accessLog/list.jsp @@ -77,6 +97,27 @@ public class PersonalDataAccessLogController { @RequestMapping(value = "/mnt/sec/accessLog/listView.do") public String listView(@ModelAttribute("personalDataAccessLogSearchVO") PersonalDataAccessLogSearchVO searchVO, ModelMap model) throws Exception { + return renderList(searchVO, model, false); + } + + /** + * 개인정보 처리기록 조회 화면 — 제안서 시안 좌측 (2) 메뉴. + * + *

(1)(접속기록 조회)이 접속·조회를 포함한 전체 기록이라면, 이 화면은 조회(S)를 빼고 + * 등록·수정·삭제만 남긴다. 정보주체 소명이나 감사 대응에서 "누가 이 사람의 개인정보를 + * 바꿨는가"를 먼저 보게 되는 쪽이라 별도 진입점을 둔다. 화면(JSP)과 검색조건은 (1)과 공유하고 + * {@code excludeSelect} 조건 하나만 다르다 — 같은 표를 두 벌 관리하지 않기 위해서다.

+ */ + @RequestMapping(value = "/mnt/sec/processLog/listView.do") + public String processListView(@ModelAttribute("personalDataAccessLogSearchVO") PersonalDataAccessLogSearchVO searchVO, + ModelMap model) throws Exception { + searchVO.setExcludeSelect(true); + return renderList(searchVO, model, true); + } + + /** (1)·(2) 공통 렌더링. {@code processOnly} 는 화면 제목·안내 문구 분기에만 쓴다. */ + private String renderList(PersonalDataAccessLogSearchVO searchVO, ModelMap model, boolean processOnly) + throws Exception { if (handlerUser() == null) { return LOGIN_REDIRECT; } @@ -97,9 +138,102 @@ public class PersonalDataAccessLogController { model.addAttribute("dmlSummary", selectDmlSummary(searchVO)); // 내 다운로드 작업 목록(비동기) — 상태·진행률 확인 및 내려받기 model.addAttribute("dlJobList", selectMyDownloadJobs()); + model.addAttribute("processOnly", Boolean.valueOf(processOnly)); + model.addAttribute("retentionDays", Integer.valueOf(ACCESS_LOG_RETENTION_DAYS)); + // 시안의 "감사" 칸 — 목록 각 행에 이상/점검 표시를 채우고 기간 전체 탐지 요약을 함께 싣는다 + markAudit(list, searchVO, model); return "site/mnt/sec/accessLog/list"; } + /** + * 목록 각 행에 감사 표시를 채우고, 검색기간 전체의 탐지 요약을 모델에 싣는다. + * + *

판정 규칙(제안서 시안의 표기와 동일):

+ * + * + *

기간을 반드시 좁힌다. 탐지는 5개의 집계 질의라 기간이 열려 있으면 730일치를 훑는다. + * 검색조건에 기간이 없으면 최근 {@link #AUDIT_DEFAULT_DAYS}일로 잘라 계산한다 — 목록 한 장을 + * 여는 비용이 감사 표시 때문에 커지면 안 된다(PSR-001).

+ * + *

실패해도 목록은 보여준다. 감사 표시는 부가 정보이므로, 탐지가 실패하면 표시 없이 + * 넘어간다(목록 자체가 막히면 본래 목적인 접속기록 조회가 불가능해진다).

+ */ + private void markAudit(List list, PersonalDataAccessLogSearchVO searchVO, + ModelMap model) { + if (list == null || list.isEmpty()) { + return; + } + try { + SimpleDateFormat dtFmt = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss"); + String from = searchVO.getFromDtParam(); + String to = searchVO.getToDtParam(); + if (from == null || to == null) { + Calendar cal = Calendar.getInstance(); + String computedTo = dtFmt.format(cal.getTime()); + cal.add(Calendar.DAY_OF_MONTH, -AUDIT_DEFAULT_DAYS); + String computedFrom = dtFmt.format(cal.getTime()); + from = (from == null) ? computedFrom : from; + to = (to == null) ? computedTo : to; + } + + List results = abnormalAccessDetectionService.detect(from, to); + + // 대상자형 유형(대량조회·계정공유·인증실패반복)의 접속자ID 집합 = 점검 대상 + Set watchIds = new HashSet(); + int detected = 0; + for (int i = 0; i < results.size(); i++) { + AbnormalAccessResultVO r = results.get(i); + detected += r.getCount(); + List> rows = r.getRows(); + if (rows == null) { + continue; + } + for (int j = 0; j < rows.size(); j++) { + Object cid = rows.get(j).get("conectId"); + if (cid != null) { + watchIds.add(cid.toString()); + } + } + } + + for (int i = 0; i < list.size(); i++) { + PersonalDataAccessLogVO vo = list.get(i); + if ("F".equals(vo.getProcessResult())) { + vo.setAuditFlag(AUDIT_ABNORMAL); + } else if ((vo.getConectId() != null && watchIds.contains(vo.getConectId())) + || isOffHours(vo.getConectDt())) { + vo.setAuditFlag(AUDIT_WATCH); + } + } + + model.addAttribute("auditDetected", Integer.valueOf(detected)); + model.addAttribute("auditSummary", abnormalAccessDetectionService.summarize(from, to)); + } catch (Exception e) { + // 감사 표시는 부가 정보다 — 실패해도 목록 조회 자체는 살린다. + model.addAttribute("auditError", Boolean.TRUE); + } + } + + /** + * 업무시간(기본 09~18시) 밖 처리인지. 형식이 다르거나 파싱 불가면 false(과대표시 방지). + * 기준시각은 {@code AbnormalAccessPolicy} 기본값과 같다. + */ + private boolean isOffHours(String conectDt) { + if (conectDt == null || conectDt.length() < 13) { + return false; + } + try { + int hour = Integer.parseInt(conectDt.substring(11, 13)); + return hour < OFF_HOURS_START || hour >= OFF_HOURS_END; + } catch (Exception e) { + return false; + } + } + /** * 다운로드 요청(비동기) — 작업만 등록하고 즉시 목록으로 돌아온다. * diff --git a/src/main/java/egovframework/com/cmm/privacy/auditlog/PersonalDataAccessLogSearchVO.java b/src/main/java/egovframework/com/cmm/privacy/auditlog/PersonalDataAccessLogSearchVO.java index 721f76f..0d5c348 100644 --- a/src/main/java/egovframework/com/cmm/privacy/auditlog/PersonalDataAccessLogSearchVO.java +++ b/src/main/java/egovframework/com/cmm/privacy/auditlog/PersonalDataAccessLogSearchVO.java @@ -58,6 +58,18 @@ public class PersonalDataAccessLogSearchVO implements Serializable { /** 전체 레코드 수(조회 후 세팅) */ private int totalRecordCount = 0; + /** + * 조회(S)를 제외하고 등록·수정·삭제만 보여줄지 여부. + * + *

제안서 시안의 좌측 ② "개인정보 처리기록" 메뉴가 이 조건을 켠 화면이다. + * ① "접속기록 조회"가 접속·조회를 포함한 전체 기록이라면, ②는 개인정보를 실제로 + * 바꾼 행위만 추린 화면이다 — 정보주체 소명·감사 대응에서 먼저 보게 되는 쪽이다.

+ * + *

{@code dmlGubun}(단일 값 지정)과는 다른 조건이다. 둘 다 지정되면 SQL 에서 AND 로 걸려 + * 교집합이 되므로, 화면에서는 한쪽만 쓰게 한다.

+ */ + private boolean excludeSelect = false; + public String getConectId() { return conectId; } public void setConectId(String conectId) { this.conectId = conectId; } @@ -94,6 +106,9 @@ public class PersonalDataAccessLogSearchVO implements Serializable { public int getTotalRecordCount() { return totalRecordCount; } public void setTotalRecordCount(int totalRecordCount) { this.totalRecordCount = totalRecordCount; } + public boolean isExcludeSelect() { return excludeSelect; } + public void setExcludeSelect(boolean excludeSelect) { this.excludeSelect = excludeSelect; } + // ===================================================================== // SQL 바인딩용 파생값 (2026-08-07) — setter 없음(읽기 전용). // 원본 필드는 화면 재표시용으로 건드리지 않는다. diff --git a/src/main/java/egovframework/com/cmm/privacy/auditlog/PersonalDataAccessLogVO.java b/src/main/java/egovframework/com/cmm/privacy/auditlog/PersonalDataAccessLogVO.java index 9d859b0..c1766af 100644 --- a/src/main/java/egovframework/com/cmm/privacy/auditlog/PersonalDataAccessLogVO.java +++ b/src/main/java/egovframework/com/cmm/privacy/auditlog/PersonalDataAccessLogVO.java @@ -45,6 +45,16 @@ public class PersonalDataAccessLogVO implements Serializable { /** 직전 레코드 해시(해시체인) */ private String prevHashVal; + /** + * 감사 표시 — 제안서 시안의 목록 맨 오른쪽 "감사" 칸. + * {@code A}:이상(차단·비인가) / {@code W}:점검(정상이나 이상징후 대상) / {@code null}:해당없음. + * + *

DB 컬럼이 아니라 조회 시점에 계산해 채우는 값이다(컨트롤러가 탐지 결과와 대조). + * 탐지 임계치는 정책값이라 언제든 바뀌므로, 판정 결과를 적재해 두면 임계치를 조정한 뒤에도 + * 과거 기준으로 굳은 값이 남는다 — 그래서 저장하지 않는다.

+ */ + private String auditFlag; + public long getLogId() { return logId; } public void setLogId(long logId) { this.logId = logId; } @@ -81,6 +91,9 @@ public class PersonalDataAccessLogVO implements Serializable { public String getHashVal() { return hashVal; } public void setHashVal(String hashVal) { this.hashVal = hashVal; } + public String getAuditFlag() { return auditFlag; } + public void setAuditFlag(String auditFlag) { this.auditFlag = auditFlag; } + public String getPrevHashVal() { return prevHashVal; } public void setPrevHashVal(String prevHashVal) { this.prevHashVal = prevHashVal; } } diff --git a/src/main/resources/egovframework/sqlmap/site/mnt/biz/privacy/PrivacyAccessLog_SQL_Mysql.xml b/src/main/resources/egovframework/sqlmap/site/mnt/biz/privacy/PrivacyAccessLog_SQL_Mysql.xml index 77bcec1..b9bf511 100644 --- a/src/main/resources/egovframework/sqlmap/site/mnt/biz/privacy/PrivacyAccessLog_SQL_Mysql.xml +++ b/src/main/resources/egovframework/sqlmap/site/mnt/biz/privacy/PrivacyAccessLog_SQL_Mysql.xml @@ -113,6 +113,9 @@ CONECT_IP LIKE #conectIpLike# ESCAPE '!' DML_GUBUN = #dmlGubun# PROCESS_RESULT = #processResult# + + DML_GUBUN <> 'S' CONECT_DT >= #fromDtParam# CONECT_DT <= #toDtParam# @@ -135,6 +138,9 @@ CONECT_IP LIKE #conectIpLike# ESCAPE '!' DML_GUBUN = #dmlGubun# PROCESS_RESULT = #processResult# + + DML_GUBUN <> 'S' CONECT_DT >= #fromDtParam# CONECT_DT <= #toDtParam# @@ -163,6 +169,9 @@ CONECT_IP LIKE #conectIpLike# ESCAPE '!' DML_GUBUN = #dmlGubun# PROCESS_RESULT = #processResult# + + DML_GUBUN <> 'S' CONECT_DT >= #fromDtParam# CONECT_DT <= #toDtParam# diff --git a/src/main/resources/egovframework/sqlmap/site/mnt/biz/privacy/PrivacyAccessLog_SQL_Oracle.xml b/src/main/resources/egovframework/sqlmap/site/mnt/biz/privacy/PrivacyAccessLog_SQL_Oracle.xml index b7d7d7d..e42d23b 100644 --- a/src/main/resources/egovframework/sqlmap/site/mnt/biz/privacy/PrivacyAccessLog_SQL_Oracle.xml +++ b/src/main/resources/egovframework/sqlmap/site/mnt/biz/privacy/PrivacyAccessLog_SQL_Oracle.xml @@ -96,6 +96,9 @@ CONECT_IP LIKE #conectIpLike# ESCAPE '!' DML_GUBUN = #dmlGubun# PROCESS_RESULT = #processResult# + + DML_GUBUN <> 'S' CONECT_DT >= #fromDtParam# CONECT_DT <= #toDtParam# @@ -117,6 +120,9 @@ CONECT_IP LIKE #conectIpLike# ESCAPE '!' DML_GUBUN = #dmlGubun# PROCESS_RESULT = #processResult# + + DML_GUBUN <> 'S' CONECT_DT >= #fromDtParam# CONECT_DT <= #toDtParam# @@ -137,6 +143,9 @@ CONECT_IP LIKE #conectIpLike# ESCAPE '!' DML_GUBUN = #dmlGubun# PROCESS_RESULT = #processResult# + + DML_GUBUN <> 'S' CONECT_DT >= #fromDtParam# CONECT_DT <= #toDtParam# 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 3705a72..930d0ce 100644 --- a/src/main/webapp/WEB-INF/jsp/site/mnt/left.jsp +++ b/src/main/webapp/WEB-INF/jsp/site/mnt/left.jsp @@ -94,10 +94,10 @@ -
  • class="on">${menuUpperVo2.menuNm} +
  • class="on">${menuUpperVo2.menuNm} -
      style="display: block;"> +
        style="display: block;"> @@ -122,27 +122,11 @@
      • class="on">${menuUpperVo4.menuNm}
      • - <%-- [개인정보/#47] 개인정보 접속기록 조회·다운로드 (9100040~43은 게시판관리가 선점 → 9100044) --%> - -
      • class="on">${menuUpperVo4.menuNm}
      • -
        <%-- [2026-08-11] 9100045 업무유형 매핑 관리 / 9100046 로그인·로그아웃 접속기록 분기 삭제(화면·메뉴 폐기) --%> <%-- [개인정보/SER-001 요건4·5] 접근통제 정책 설정(계정잠금·자동로그아웃·세션재발급) --%>
      • class="on">${menuUpperVo4.menuNm}
      • - <%-- [개인정보/SFR-004-7 #48] 접속기록 정기점검 리포트(점검결과 입력·보관) --%> - -
      • class="on">${menuUpperVo4.menuNm}
      • -
        - <%-- [개인정보/과업내용 다-① #49] 비정상접근 탐지 5유형 --%> - -
      • class="on">${menuUpperVo4.menuNm}
      • -
        - <%-- [개인정보/SER-002-2] 접속기록 파기이력(파티션 DROP/ADD 증빙) --%> - -
      • class="on">${menuUpperVo4.menuNm}
      • -
        <%-- [개인정보/추가제안 Action4] 회원정보 암호화 관리(복호화 CRUD·사유·재인증) --%>
      • class="on">${menuUpperVo4.menuNm}
      • @@ -155,6 +139,40 @@ + <%-- ============================================================ + [개인정보/제안서 Ⅲ-4 시안] 접속기록 · 감사 (9100052) + ① 접속기록 조회(9100044) ② 개인정보 처리기록(9100053) + ③ 이상행위 감사(9100049) ④ 정기 점검 리포트(9100048) + 제안서 시안이 이 넷을 한 묶음으로 그리고 있어 권한관리 하위에서 이설했다. + 접속기록 파기이력은 시안에 메뉴가 없어 정기 점검 리포트 화면 안에서 연다. + ============================================================ --%> + +
      • class="on">${menuUpperVo2.menuNm} + + + + + +
      • +
        +
      • class="on">${menuUpperVo2.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 774cca1..029b9fa 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 @@ -14,13 +14,17 @@ · 이 테이블은 UPDATE/DELETE statement 미정의(위·변조 방지) — 화면에서도 조회/다운로드만 제공 --%> +<%-- 제안서 시안 좌측 (1)접속기록 조회 / (2)개인정보 처리기록 — 같은 화면을 조건만 바꿔 쓴다. + 목록 재조회·페이징이 자기 화면으로 돌아오도록 진입 URL 을 여기서 정한다. --%> + +