diff --git a/src/main/java/com/eactive/apim/portal/common/migration/LegacyEncryptionMigrationController.java b/src/main/java/com/eactive/apim/portal/common/migration/LegacyEncryptionMigrationController.java new file mode 100644 index 0000000..a56cdc5 --- /dev/null +++ b/src/main/java/com/eactive/apim/portal/common/migration/LegacyEncryptionMigrationController.java @@ -0,0 +1,194 @@ +package com.eactive.apim.portal.common.migration; + +import com.eactive.apim.portal.jpa.PersonalDataEncryptConverter; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Qualifier; +import org.springframework.http.HttpStatus; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; +import org.springframework.web.server.ResponseStatusException; + +import javax.servlet.http.HttpServletRequest; +import javax.sql.DataSource; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +/** + * [임시] 레거시 평문 데이터를 {@link PersonalDataEncryptConverter} 규칙으로 일괄 정규화(암호화)하는 운영 도구. + * + *

배경: {@code @Convert} 컬럼이 평문으로 저장된 레거시 행은, derived query가 검색값을 암호화하면서 + * 평문 DB값과 불일치해 검색/로그인이 실패한다. 컨버터는 읽기에서 평문/암호문을 자동 구분하고 + * 쓰기에서 무조건 인코딩하므로, {@code convertToDatabaseColumn(convertToEntityAttribute(x))}는 + * 평문→인코딩, 인코딩→동일값(멱등)으로 정규화된다. 이 값이 기존과 다를 때만 UPDATE 한다.

+ * + *

보안: 오직 127.0.0.1(localhost)에서 직접 호출한 요청만 허용한다. 기본은 dry-run(미변경)이며, + * 실제 실행은 {@code dryRun=false}를 명시해야 한다. 작업 완료 후 이 클래스는 제거한다.

+ * + *
+ *   # 미리보기(변경 안 함)
+ *   curl -X POST 'http://127.0.0.1:39130/internal/migration/encrypt-legacy'
+ *   # 실제 실행 (PII 컬럼)
+ *   curl -X POST 'http://127.0.0.1:39130/internal/migration/encrypt-legacy?dryRun=false'
+ *   # audit 컬럼(created_by/last_modified_by, 15개 테이블)까지 포함
+ *   curl -X POST 'http://127.0.0.1:39130/internal/migration/encrypt-legacy?dryRun=false&includeAudit=true'
+ * 
+ */ +@Slf4j +@RestController +@RequestMapping("/internal/migration") +public class LegacyEncryptionMigrationController { + + /** PII 직접 컬럼 (로그인/검색에 직접 영향) */ + private static final List PII_TARGETS = Arrays.asList( + new TargetTable("PTL_USER", Arrays.asList("login_id", "email_addr", "phone_number", "mobile_number")), + new TargetTable("PTL_MESSAGE_REQUEST", Arrays.asList("email", "phone")), + new TargetTable("tseairm02", Arrays.asList("cphnno", "emad")), + new TargetTable("PTL_USER_LOG", Arrays.asList("login_id")), + new TargetTable("PTL_TWO_FACTOR_AUTH", Arrays.asList("recipient")) + ); + + /** Auditable(@MappedSuperclass) 상속 테이블의 감사 컬럼 (옵션) */ + private static final List AUDIT_TABLES = Arrays.asList( + "PTL_USER", "ptl_faq", "ptl_org", + "DJB_APISTATUS_INCIDENT", "DJB_APISTATUS_INCIDENT_TIMELINE", "DJB_APISTATUS_INCIDENT_API", + "ptl_file", "PTL_MESSAGE_TEMPLATE", "ptl_notice", "ptl_terms", + "ptl_user_privacy_policy_agreement", "ptl_approval_line", + "PTL_INQUIRY_COMMENT", "ptl_inquiry", "ptl_partnership_application" + ); + private static final List AUDIT_COLUMNS = Arrays.asList("created_by", "last_modified_by"); + + private final JdbcTemplate jdbcTemplate; + private final PersonalDataEncryptConverter converter = new PersonalDataEncryptConverter(); + + public LegacyEncryptionMigrationController(@Qualifier("portalDataSource") DataSource emsDataSource) { + // EMS(EMSAPP) 스키마 데이터소스. 컨버터 적용 테이블은 모두 EMS에 존재한다. + this.jdbcTemplate = new JdbcTemplate(emsDataSource); + } + + @PostMapping("/encrypt-legacy") + @Transactional("transactionManager") + public Map encryptLegacy(HttpServletRequest request, + @RequestParam(defaultValue = "true") boolean dryRun, + @RequestParam(defaultValue = "false") boolean includeAudit) { + assertLocalOnly(request); + assertNotBypass(); + + List targets = new ArrayList<>(PII_TARGETS); + if (includeAudit) { + for (String table : AUDIT_TABLES) { + targets.add(new TargetTable(table, AUDIT_COLUMNS)); + } + } + + List> results = new ArrayList<>(); + int totalChanged = 0; + for (TargetTable target : targets) { + for (String column : target.columns) { + Map r = processColumn(target.table, column, dryRun); + results.add(r); + totalChanged += (int) r.get("changed"); + } + } + + Map response = new LinkedHashMap<>(); + response.put("mode", dryRun ? "dry-run (변경 없음)" : "executed"); + response.put("includeAudit", includeAudit); + response.put("totalChanged", totalChanged); + response.put("results", results); + log.info("[레거시 암호화 마이그레이션] mode={} includeAudit={} totalChanged={}", + dryRun ? "dry-run" : "executed", includeAudit, totalChanged); + return response; + } + + /** + * 단일 (테이블, 컬럼)의 고유값을 정규화하고, 값이 바뀌는 경우에만 UPDATE. + */ + private Map processColumn(String table, String column, boolean dryRun) { + Map r = new LinkedHashMap<>(); + r.put("table", table); + r.put("column", column); + + List values; + try { + values = jdbcTemplate.queryForList( + "SELECT DISTINCT " + column + " FROM " + table + " WHERE " + column + " IS NOT NULL", + String.class); + } catch (Exception e) { + log.warn("[마이그레이션] 조회 실패 table={} column={} : {}", table, column, e.toString()); + r.put("distinct", 0); + r.put("changed", 0); + r.put("error", e.getMessage()); + return r; + } + + int changed = 0; + for (String value : values) { + String normalized; + try { + // 평문 → 인코딩, 이미 인코딩 → 동일값 (멱등) + normalized = converter.convertToDatabaseColumn(converter.convertToEntityAttribute(value)); + } catch (Exception e) { + log.warn("[마이그레이션] 정규화 실패 table={} column={} : {}", table, column, e.toString()); + continue; + } + if (normalized != null && !normalized.equals(value)) { + if (!dryRun) { + jdbcTemplate.update( + "UPDATE " + table + " SET " + column + " = ? WHERE " + column + " = ?", + normalized, value); + } + changed++; + } + } + + r.put("distinct", values.size()); + r.put("changed", changed); + return r; + } + + /** + * damo-manager 가 bypass 모드면 실행 자체를 거부한다. + * bypass 에서는 {@code encrypt} 가 무변환(원문 그대로)이라 정규화가 평문→평문 no-op 이 되어 + * 마이그레이션이 의미가 없고, "완료"로 오인될 위험이 있다. real/fake 모드로 기동 후 실행해야 한다. + */ + private void assertNotBypass() { + if (converter.isBypassMode()) { + log.warn("[마이그레이션] bypass 모드 실행 거부 — 암복호화가 무변환이라 마이그레이션이 무의미함"); + throw new ResponseStatusException(HttpStatus.CONFLICT, + "damo-manager 가 bypass 모드입니다. 암복호화가 무변환(원문 그대로)이라 마이그레이션이 무의미하므로 거부합니다. " + + "real/fake 모드(-Ddamo-manager.enabled=true)로 기동한 뒤 실행하세요."); + } + } + + /** + * 127.0.0.1(localhost) 직접 호출만 허용. 프록시 경유(X-Forwarded-For 존재) 요청은 거부한다. + */ + private void assertLocalOnly(HttpServletRequest request) { + String remote = request.getRemoteAddr(); + boolean localAddr = "127.0.0.1".equals(remote) + || "0:0:0:0:0:0:0:1".equals(remote) + || "::1".equals(remote); + boolean viaProxy = request.getHeader("X-Forwarded-For") != null; + if (!localAddr || viaProxy) { + log.warn("[마이그레이션] 비로컬 접근 차단 remoteAddr={} xff={}", remote, request.getHeader("X-Forwarded-For")); + throw new ResponseStatusException(HttpStatus.FORBIDDEN, "localhost(127.0.0.1) 직접 호출만 허용됩니다."); + } + } + + private static final class TargetTable { + final String table; + final List columns; + + TargetTable(String table, List columns) { + this.table = table; + this.columns = columns; + } + } +}