diff --git a/deploy/systemd/vpd-backoffice-structured-data.conf b/deploy/systemd/vpd-backoffice-structured-data.conf new file mode 100644 index 0000000..6bfff19 --- /dev/null +++ b/deploy/systemd/vpd-backoffice-structured-data.conf @@ -0,0 +1,2 @@ +[Service] +Environment=BACKOFFICE_STRUCTURED_DATA_CATALOG_LOCATION=file:/home/opc/apps/vpd-backoffice/config/structured-data-catalog.json diff --git a/docs/design/702-hmm-backoffice-identity/README.md b/docs/design/702-hmm-backoffice-identity/README.md index 43e7439..2420d96 100644 --- a/docs/design/702-hmm-backoffice-identity/README.md +++ b/docs/design/702-hmm-backoffice-identity/README.md @@ -1,6 +1,6 @@ # HMM 백오피스 사용자·그룹·역할·토큰 관리 전환 (#702) -> 상태: Implementing +> 상태: Implemented > 추적: Redmine #702 / Git 브랜치: `hmm-backoffice` > 대상: `hmm-backoffice.cloud-handson.com`, HMMAIPOC / `ADMIN` @@ -56,6 +56,22 @@ HMM_KNOWLEDGE_DOCUMENTS ──< HMM_KNOWLEDGE_CHUNKS ──< HMM_KNOWLEDGE_TAGS 멱등적으로 준비하고 `CB_VECTOR_*` 조회 호환 뷰를 갱신한다. 기존 물리 `CB_VECTOR_*` 테이블이 존재하는 환경은 덮어쓰지 않는다. +### 재사용 가능한 정형 원장 카탈로그 + +- `/structured-data`와 `/schema-metadata`의 데이터 원본명, Oracle owner, 안내 문구, 최대 조회 건수, + 허용 테이블 목록은 Java·HTML에 하드코딩하지 않는다. +- 기본 HMM 정의는 `src/main/resources/config/structured-data-catalog.json`에 둔다. 배포 환경에서는 + `BACKOFFICE_STRUCTURED_DATA_CATALOG_LOCATION=file:/.../structured-data-catalog.json`으로 외부 + JSON을 지정할 수 있어 다른 회사 PoC에서 애플리케이션 코드를 수정하지 않고 재사용할 수 있다. +- 각 테이블 정의는 `key`, `tableName`, `businessName`, `description`을 기본으로 하고 필요하면 + `previewColumns`, `maskingPolicyName`을 추가한다. 화면 카드, metadata 대상, 실제 SELECT 허용 목록, + ASO 관리 대상은 모두 이 단일 JSON을 기준으로 생성한다. VECTOR/BLOB 등 JDBC 원장 미리보기에 + 부적합한 컬럼은 `previewColumns`에서 제외하되 DB 메타데이터 화면에는 계속 표시한다. +- owner와 table name은 Oracle 단순 식별자 규칙, key는 URL key 규칙으로 시작 시 검증한다. 중복 key, + 중복 table, 빈 목록, 허용 범위를 벗어난 row limit은 기동 실패로 처리해 동적 SQL 범위를 닫는다. +- HMM 기본 카탈로그는 `ADMIN`의 조직, 직원, 휴가 잔여, 휴가 신청, 일별 근태, HR 표준 용어 원장만 + 노출한다. 기존 `KBAIPOC`, `POC_2`, `KB_*` 보험 원장은 HMM 배포 카탈로그에 포함하지 않는다. + ## HMM MCP 및 시스템 설정 - 운영 MCP 주소는 환경변수 `BACKOFFICE_HMM_MCP_PUBLIC_URL`로 관리하며 기본값은 @@ -88,3 +104,15 @@ HMM_KNOWLEDGE_DOCUMENTS ──< HMM_KNOWLEDGE_CHUNKS ──< HMM_KNOWLEDGE_TAGS 애플리케이션은 이전 JAR로 되돌릴 수 있다. 새 `HMM_ACCESS_*` 테이블은 운영 데이터가 생긴 뒤에는 삭제하지 않으며, 문제 발생 시 화면 매퍼만 이전 버전으로 복구한다. + +## 2026-07-23 배포·전수 검증 + +- 운영 JSON: `/home/opc/apps/vpd-backoffice/config/structured-data-catalog.json` +- systemd override: `20-structured-data-catalog.conf` +- 자동 테스트: `mvn test` 103건 통과 +- 브라우저 검증: 로그인 세션으로 메뉴 URL 26개 전부 HTTP 200, 오류 alert 0건, `ORA-` 0건, + `KBAIPOC`/`POC_2`/KB 보험원장 표시 0건, page error 0건, console error 0건 +- 정형 원장 실데이터: 조직 1행, 직원 7행, 휴가 잔여 7행, 휴가 신청 8행, 일별 근태 14행, + HR 표준 용어 21행 렌더링 확인 +- 용어 원장의 VECTOR 컬럼은 `previewColumns`에서 제외해 관리자 미리보기에는 사람이 읽을 수 있는 + 표준 코드·명칭·설명·임베딩 시각만 표시하고, DB 메타데이터 관리에서는 전체 컬럼을 유지한다. diff --git a/docs/reports/2026-07-23-hmm-backoffice-full-browser-audit.md b/docs/reports/2026-07-23-hmm-backoffice-full-browser-audit.md new file mode 100644 index 0000000..b47fb2f --- /dev/null +++ b/docs/reports/2026-07-23-hmm-backoffice-full-browser-audit.md @@ -0,0 +1,60 @@ +# HMM 백오피스 전 화면 브라우저 검증 + +- 검증일: 2026-07-23 KST +- 대상: `https://hmm-backoffice.cloud-handson.com` +- 인증: 운영 관리자 로그인 세션 +- 브라우저: Playwright Chromium, 1440×1000 +- 추적: Redmine #702 + +## 판정 기준 + +각 URL을 직접 열어 HTTP 200, 주 제목 렌더링, 오류 alert 부재를 검사했다. 본문에서 `ORA-`, +`데이터 처리 오류`, `Whitelabel Error Page`, `KBAIPOC`, `POC_2`, KB 보험원장 테이블명을 탐지하고, +브라우저 page error와 console error도 수집했다. + +## 메뉴 결과 + +| 영역 | URL | 결과 | +| --- | --- | --- | +| 대시보드 | `/` | PASS | +| 사용자 | `/users` | PASS | +| 접근 그룹 | `/groups` | PASS | +| 역할 | `/roles` | PASS | +| 행 접근 규칙 | `/permissions` | PASS | +| 컬럼 원문 표시 허용 | `/user-masking-rules` | PASS | +| 권한 현황 | `/effective-matrix` | PASS | +| 보호 상태 | `/vpd-policies` | PASS | +| 컬럼 마스킹 | `/masking-rules` | PASS | +| 토큰 발급 | `/tokens` | PASS | +| 접근 검증 | `/probe` | PASS | +| 조회 대상 | `/objects` | PASS | +| 정형 데이터 조회 | `/structured-data` | PASS | +| 조회 연동 | `/ords-handlers` | PASS | +| 지식자료 관리 | `/vector-knowledge` | PASS | +| 대화형 검색 | `/mcp-chatbot` | PASS | +| 검색 해석 | `/mcp-reasoning` | PASS | +| MCP 서비스 | `/mcp-sse` | PASS | +| 연동 점검 | `/mcp-client-demo` | PASS | +| 운영 현황 | `/operation-status` | PASS | +| 행 접근 필터 구조 | `/vpd-filter-runtime` | PASS | +| DB 메타데이터 | `/schema-metadata` | PASS | +| 보안 SQL 스크립트 | `/security-sql-scripts` | PASS | +| 고급 접근 조건 | `/vpd-filter-policies` | PASS | +| 시스템 설정 | `/settings` | PASS | +| DB 준비 상태 | `/settings/database` | PASS | + +총 26개 URL이 모두 통과했다. 오류 alert, KB/POC 잔재, page error, console error는 각각 0건이다. + +## HMM 정형 원장 결과 + +| JSON key | 테이블 | 렌더링 행 | 결과 | +| --- | --- | ---: | --- | +| `teams` | `ADMIN.HMM_ORG_TEAMS` | 1 | PASS | +| `employees` | `ADMIN.HMM_HR_EMPLOYEES` | 7 | PASS | +| `leave-balances` | `ADMIN.HMM_LEAVE_BALANCES` | 7 | PASS | +| `leave-requests` | `ADMIN.HMM_LEAVE_REQUESTS` | 8 | PASS | +| `attendance-daily` | `ADMIN.HMM_ATTENDANCE_DAILY` | 14 | PASS | +| `hr-terms` | `ADMIN.HMM_HR_TERMS` | 21 | PASS | + +`HMM_HR_TERMS.EMBEDDING`은 VECTOR이므로 JSON `previewColumns`에서 제외했다. 이 설정으로 원장 +미리보기는 21개 용어를 정상 표시하며, VECTOR 자체는 용어 검색 기능에서 계속 사용한다. diff --git a/src/main/java/com/cloudhandson/vpdbackoffice/domain/masking/ManagedMaskingPolicy.java b/src/main/java/com/cloudhandson/vpdbackoffice/domain/masking/ManagedMaskingPolicy.java new file mode 100644 index 0000000..6b1959a --- /dev/null +++ b/src/main/java/com/cloudhandson/vpdbackoffice/domain/masking/ManagedMaskingPolicy.java @@ -0,0 +1,7 @@ +package com.cloudhandson.vpdbackoffice.domain.masking; + +public record ManagedMaskingPolicy( + String objectName, + String policyName +) { +} diff --git a/src/main/java/com/cloudhandson/vpdbackoffice/domain/structured/StructuredDataCatalog.java b/src/main/java/com/cloudhandson/vpdbackoffice/domain/structured/StructuredDataCatalog.java new file mode 100644 index 0000000..32bb946 --- /dev/null +++ b/src/main/java/com/cloudhandson/vpdbackoffice/domain/structured/StructuredDataCatalog.java @@ -0,0 +1,13 @@ +package com.cloudhandson.vpdbackoffice.domain.structured; + +import java.util.List; + +public record StructuredDataCatalog( + String sourceName, + String owner, + String pageHelp, + String catalogDescription, + int rowLimit, + List tables +) { +} diff --git a/src/main/java/com/cloudhandson/vpdbackoffice/domain/structured/StructuredDataTable.java b/src/main/java/com/cloudhandson/vpdbackoffice/domain/structured/StructuredDataTable.java index a247fc5..a8f6e0a 100644 --- a/src/main/java/com/cloudhandson/vpdbackoffice/domain/structured/StructuredDataTable.java +++ b/src/main/java/com/cloudhandson/vpdbackoffice/domain/structured/StructuredDataTable.java @@ -1,9 +1,17 @@ package com.cloudhandson.vpdbackoffice.domain.structured; +import java.util.List; + public record StructuredDataTable( String key, String tableName, String businessName, - String description + String description, + List previewColumns, + String maskingPolicyName ) { + + public StructuredDataTable(String key, String tableName, String businessName, String description) { + this(key, tableName, businessName, description, List.of(), null); + } } diff --git a/src/main/java/com/cloudhandson/vpdbackoffice/mapper/MaskingRuleMapper.java b/src/main/java/com/cloudhandson/vpdbackoffice/mapper/MaskingRuleMapper.java index ca4edb8..eece38a 100644 --- a/src/main/java/com/cloudhandson/vpdbackoffice/mapper/MaskingRuleMapper.java +++ b/src/main/java/com/cloudhandson/vpdbackoffice/mapper/MaskingRuleMapper.java @@ -4,6 +4,7 @@ import com.cloudhandson.vpdbackoffice.domain.masking.ColumnMaskingRule; import com.cloudhandson.vpdbackoffice.domain.masking.MaskingRule; import com.cloudhandson.vpdbackoffice.domain.masking.MaskingRuleCreateCommand; import com.cloudhandson.vpdbackoffice.domain.masking.MaskingPolicyStatus; +import com.cloudhandson.vpdbackoffice.domain.masking.ManagedMaskingPolicy; import com.cloudhandson.vpdbackoffice.domain.masking.UserMaskingRule; import java.util.List; import org.apache.ibatis.annotations.Mapper; @@ -28,7 +29,10 @@ public interface MaskingRuleMapper { List findColumnRules(); - List findPolicyStatuses(); + List findPolicyStatuses( + @Param("owner") String owner, + @Param("policies") List policies + ); ColumnMaskingRule findColumnRule(@Param("columnId") long columnId); diff --git a/src/main/java/com/cloudhandson/vpdbackoffice/service/MaskingPolicySynchronizer.java b/src/main/java/com/cloudhandson/vpdbackoffice/service/MaskingPolicySynchronizer.java index ce4b4ae..8d21949 100644 --- a/src/main/java/com/cloudhandson/vpdbackoffice/service/MaskingPolicySynchronizer.java +++ b/src/main/java/com/cloudhandson/vpdbackoffice/service/MaskingPolicySynchronizer.java @@ -2,6 +2,7 @@ package com.cloudhandson.vpdbackoffice.service; import com.cloudhandson.vpdbackoffice.domain.masking.ColumnMaskingRule; import com.cloudhandson.vpdbackoffice.domain.masking.MaskingTemplate; +import com.cloudhandson.vpdbackoffice.domain.structured.StructuredDataCatalog; import com.cloudhandson.vpdbackoffice.mapper.MaskingRuleMapper; import java.util.ArrayList; import java.util.Collections; @@ -24,37 +25,42 @@ import org.springframework.stereotype.Service; @Service public class MaskingPolicySynchronizer { - private static final String OWNER = "POC_2"; private static final Pattern COLUMN_NAME = Pattern.compile("[A-Z][A-Z0-9_$#]{0,127}"); - private static final Map MANAGED_POLICIES = managedPolicyMap(); private final JdbcTemplate jdbcTemplate; private final MaskingRuleMapper mapper; + private final StructuredDataCatalog catalog; + private final Map managedPolicies; - public MaskingPolicySynchronizer(JdbcTemplate jdbcTemplate, MaskingRuleMapper mapper) { + public MaskingPolicySynchronizer( + JdbcTemplate jdbcTemplate, + MaskingRuleMapper mapper, + StructuredDataCatalogProvider catalogProvider + ) { this.jdbcTemplate = jdbcTemplate; this.mapper = mapper; - } - - private static Map managedPolicyMap() { + this.catalog = catalogProvider.catalog(); Map policies = new LinkedHashMap<>(); - policies.put("KB_CUSTOMERS", "KB_CUSTOMER_PII_REDACT"); - policies.put("KB_CLAIMS", "KB_CLAIM_AMOUNT_REDACT"); - policies.put("KB_CONTRACTS", "KB_CONTRACT_PREMIUM_REDACT"); - policies.put("KB_EXTERNAL_HOLDINGS", "KB_EXT_HOLDING_REDACT"); - return Collections.unmodifiableMap(policies); + catalog.tables().stream() + .filter(table -> table.maskingPolicyName() != null) + .forEach(table -> policies.put(table.tableName(), table.maskingPolicyName())); + this.managedPolicies = Collections.unmodifiableMap(policies); } public Set managedObjectNames() { - return MANAGED_POLICIES.keySet(); + return managedPolicies.keySet(); + } + + public String owner() { + return catalog.owner(); } public boolean isManagedObject(String objectName) { - return objectName != null && MANAGED_POLICIES.containsKey(objectName.trim().toUpperCase(Locale.ROOT)); + return objectName != null && managedPolicies.containsKey(objectName.trim().toUpperCase(Locale.ROOT)); } - static String managedPolicyName(String objectName) { - return MANAGED_POLICIES.get(objectName); + String managedPolicyName(String objectName) { + return managedPolicies.get(objectName); } /** @@ -67,9 +73,9 @@ public class MaskingPolicySynchronizer { public MaskingPolicySyncResult synchronize() { Map> desiredByObject = new LinkedHashMap<>(); for (ColumnMaskingRule rule : mapper.findColumnRules()) { - if (OWNER.equalsIgnoreCase(rule.owner()) + if (catalog.owner().equalsIgnoreCase(rule.owner()) && rule.ruleEnabled() - && MANAGED_POLICIES.containsKey(rule.objectName())) { + && managedPolicies.containsKey(rule.objectName())) { desiredByObject.computeIfAbsent(rule.objectName(), ignored -> new ArrayList<>()).add(rule); } } @@ -79,7 +85,7 @@ public class MaskingPolicySynchronizer { int addedColumns = 0; int modifiedColumns = 0; int droppedColumns = 0; - for (Map.Entry policy : MANAGED_POLICIES.entrySet()) { + for (Map.Entry policy : managedPolicies.entrySet()) { String objectName = policy.getKey(); String policyName = policy.getValue(); List desired = desiredByObject.getOrDefault(objectName, List.of()); @@ -141,7 +147,7 @@ public class MaskingPolicySynchronizer { SELECT enable FROM redaction_policies WHERE object_owner = ? AND object_name = ? AND policy_name = ? - """, String.class, OWNER, objectName, policyName); + """, String.class, catalog.owner(), objectName, policyName); return statuses.isEmpty() ? null : statuses.getFirst(); } @@ -150,7 +156,7 @@ public class MaskingPolicySynchronizer { SELECT column_name FROM redaction_columns WHERE object_owner = ? AND object_name = ? - """, String.class, OWNER, objectName).stream() + """, String.class, catalog.owner(), objectName).stream() .map(this::requiredColumnName) .toList(); } @@ -160,7 +166,7 @@ public class MaskingPolicySynchronizer { BEGIN DBMS_REDACT.DISABLE_POLICY(object_schema => ?, object_name => ?, policy_name => ?); END; - """, OWNER, objectName, policyName); + """, catalog.owner(), objectName, policyName); } private void enablePolicy(String objectName, String policyName) { @@ -168,7 +174,7 @@ public class MaskingPolicySynchronizer { BEGIN DBMS_REDACT.ENABLE_POLICY(object_schema => ?, object_name => ?, policy_name => ?); END; - """, OWNER, objectName, policyName); + """, catalog.owner(), objectName, policyName); } private void dropColumn(String objectName, String policyName, String columnName) { @@ -179,7 +185,7 @@ public class MaskingPolicySynchronizer { action => DBMS_REDACT.DROP_COLUMN, column_name => ? ); END; - """, OWNER, objectName, policyName, columnName); + """, catalog.owner(), objectName, policyName, columnName); } private void addPolicy( @@ -251,9 +257,9 @@ public class MaskingPolicySynchronizer { END; """.formatted(functionConstant); if (regexPattern == null) { - jdbcTemplate.update(sql, OWNER, objectName, policyName, columnName); + jdbcTemplate.update(sql, catalog.owner(), objectName, policyName, columnName); } else { - jdbcTemplate.update(sql, OWNER, objectName, policyName, columnName, regexPattern, regexReplacement); + jdbcTemplate.update(sql, catalog.owner(), objectName, policyName, columnName, regexPattern, regexReplacement); } return; } @@ -276,9 +282,9 @@ public class MaskingPolicySynchronizer { END; """.formatted(actionConstant, functionConstant); if (regexPattern == null) { - jdbcTemplate.update(sql, OWNER, objectName, policyName, columnName); + jdbcTemplate.update(sql, catalog.owner(), objectName, policyName, columnName); } else { - jdbcTemplate.update(sql, OWNER, objectName, policyName, columnName, regexPattern, regexReplacement); + jdbcTemplate.update(sql, catalog.owner(), objectName, policyName, columnName, regexPattern, regexReplacement); } } @@ -314,7 +320,7 @@ public class MaskingPolicySynchronizer { object_schema => ?, object_name => ?, column_name => ?, policy_expression_name => ? ); END; - """, OWNER, objectName, columnName, expressionName); + """, catalog.owner(), objectName, columnName, expressionName); } } diff --git a/src/main/java/com/cloudhandson/vpdbackoffice/service/MaskingRuleService.java b/src/main/java/com/cloudhandson/vpdbackoffice/service/MaskingRuleService.java index 05335de..92621fd 100644 --- a/src/main/java/com/cloudhandson/vpdbackoffice/service/MaskingRuleService.java +++ b/src/main/java/com/cloudhandson/vpdbackoffice/service/MaskingRuleService.java @@ -6,6 +6,7 @@ import com.cloudhandson.vpdbackoffice.domain.masking.MaskingRule; import com.cloudhandson.vpdbackoffice.domain.masking.MaskingRuleCreateCommand; import com.cloudhandson.vpdbackoffice.domain.masking.MaskingPolicyStatus; import com.cloudhandson.vpdbackoffice.domain.masking.MaskingTemplate; +import com.cloudhandson.vpdbackoffice.domain.masking.ManagedMaskingPolicy; import com.cloudhandson.vpdbackoffice.domain.masking.UserMaskingRule; import com.cloudhandson.vpdbackoffice.domain.protectedobject.ProtectedObject; import com.cloudhandson.vpdbackoffice.mapper.MaskingRuleMapper; @@ -55,9 +56,13 @@ public class MaskingRuleService { return mapper.findColumnRules(); } - /** Reads the actual Oracle Data Redaction state for the three managed KB objects. */ + /** Reads Oracle Data Redaction state for the objects declared in the JSON catalogue. */ public List findPolicyStatuses() { - return mapper.findPolicyStatuses(); + List policies = maskingPolicySynchronizer.managedObjectNames().stream() + .map(objectName -> new ManagedMaskingPolicy( + objectName, maskingPolicySynchronizer.managedPolicyName(objectName))) + .toList(); + return mapper.findPolicyStatuses(maskingPolicySynchronizer.owner(), policies); } public Set managedObjectNames() { diff --git a/src/main/java/com/cloudhandson/vpdbackoffice/service/SchemaMetadataService.java b/src/main/java/com/cloudhandson/vpdbackoffice/service/SchemaMetadataService.java index 695beff..cd00697 100644 --- a/src/main/java/com/cloudhandson/vpdbackoffice/service/SchemaMetadataService.java +++ b/src/main/java/com/cloudhandson/vpdbackoffice/service/SchemaMetadataService.java @@ -3,6 +3,7 @@ package com.cloudhandson.vpdbackoffice.service; import com.cloudhandson.vpdbackoffice.domain.schemametadata.SchemaAnnotation; import com.cloudhandson.vpdbackoffice.domain.schemametadata.SchemaMetadataColumn; import com.cloudhandson.vpdbackoffice.domain.schemametadata.SchemaMetadataView; +import com.cloudhandson.vpdbackoffice.domain.structured.StructuredDataCatalog; import com.cloudhandson.vpdbackoffice.domain.structured.StructuredDataTable; import java.util.ArrayList; import java.util.LinkedHashMap; @@ -18,7 +19,6 @@ import org.springframework.transaction.annotation.Transactional; @Service public class SchemaMetadataService { - private static final String OWNER = "POC_2"; private static final int MAX_COMMENT_LENGTH = 4000; private static final int MAX_ANNOTATION_VALUE_LENGTH = 4000; private static final Pattern ORACLE_SIMPLE_NAME = Pattern.compile("[A-Z][A-Z0-9_$#]{0,127}"); @@ -35,6 +35,10 @@ public class SchemaMetadataService { return structuredDataService.tables(); } + public StructuredDataCatalog catalog() { + return structuredDataService.catalog(); + } + public String defaultKey() { return structuredDataService.defaultKey(); } @@ -111,7 +115,7 @@ public class SchemaMetadataService { FROM all_tab_comments WHERE owner = ? AND table_name = ? - """, (rs, rowNum) -> rs.getString(1), OWNER, tableName); + """, (rs, rowNum) -> rs.getString(1), owner(), tableName); return values.isEmpty() ? "" : values.getFirst(); } @@ -146,7 +150,7 @@ public class SchemaMetadataService { "Y".equalsIgnoreCase(rs.getString("nullable")), nullToEmpty(rs.getString("comments")), annotations.getOrDefault(columnTargetKey(rs.getString("column_name")), List.of()) - ), OWNER, tableName); + ), owner(), tableName); } private Map> annotationsByTarget(String tableName) { @@ -216,7 +220,7 @@ public class SchemaMetadataService { WHERE owner = ? AND table_name = ? AND column_name = ? - """, Integer.class, OWNER, tableName, column); + """, Integer.class, owner(), tableName, column); if (count == null || count == 0) { throw new AppException("선택한 테이블에 존재하지 않는 컬럼입니다."); } @@ -243,7 +247,11 @@ public class SchemaMetadataService { } private String qualifiedTable(String tableName) { - return quoteName(OWNER) + "." + quoteName(requireSimpleName(tableName, "table name")); + return quoteName(owner()) + "." + quoteName(requireSimpleName(tableName, "table name")); + } + + private String owner() { + return structuredDataService.owner(); } private String quoteName(String value) { diff --git a/src/main/java/com/cloudhandson/vpdbackoffice/service/StructuredDataCatalogProvider.java b/src/main/java/com/cloudhandson/vpdbackoffice/service/StructuredDataCatalogProvider.java new file mode 100644 index 0000000..4d0166c --- /dev/null +++ b/src/main/java/com/cloudhandson/vpdbackoffice/service/StructuredDataCatalogProvider.java @@ -0,0 +1,115 @@ +package com.cloudhandson.vpdbackoffice.service; + +import com.cloudhandson.vpdbackoffice.domain.structured.StructuredDataCatalog; +import com.cloudhandson.vpdbackoffice.domain.structured.StructuredDataTable; +import com.fasterxml.jackson.databind.ObjectMapper; +import java.io.IOException; +import java.util.HashSet; +import java.util.List; +import java.util.Locale; +import java.util.Set; +import java.util.regex.Pattern; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.core.io.Resource; +import org.springframework.stereotype.Component; + +@Component +public class StructuredDataCatalogProvider { + + private static final Pattern ORACLE_SIMPLE_NAME = Pattern.compile("[A-Z][A-Z0-9_$#]{0,127}"); + private static final Pattern TABLE_KEY = Pattern.compile("[a-z][a-z0-9-]{0,63}"); + private static final int MAX_ROW_LIMIT = 500; + + private final StructuredDataCatalog catalog; + + public StructuredDataCatalogProvider( + ObjectMapper objectMapper, + @Value("${backoffice.structured-data.catalog-location:classpath:/config/structured-data-catalog.json}") + Resource catalogResource + ) { + try (var input = catalogResource.getInputStream()) { + this.catalog = validate(objectMapper.readValue(input, StructuredDataCatalog.class)); + } catch (IOException exception) { + throw new IllegalStateException( + "정형 데이터 카탈로그 JSON을 읽을 수 없습니다: " + catalogResource.getDescription(), exception); + } + } + + public StructuredDataCatalog catalog() { + return catalog; + } + + private StructuredDataCatalog validate(StructuredDataCatalog source) { + if (source == null) { + throw new IllegalStateException("정형 데이터 카탈로그가 비어 있습니다."); + } + String sourceName = requireText(source.sourceName(), "sourceName"); + String owner = requireOracleName(source.owner(), "owner"); + String pageHelp = requireText(source.pageHelp(), "pageHelp"); + String catalogDescription = requireText(source.catalogDescription(), "catalogDescription"); + if (source.rowLimit() < 1 || source.rowLimit() > MAX_ROW_LIMIT) { + throw new IllegalStateException("정형 데이터 카탈로그 rowLimit은 1~" + MAX_ROW_LIMIT + " 범위여야 합니다."); + } + if (source.tables() == null || source.tables().isEmpty()) { + throw new IllegalStateException("정형 데이터 카탈로그에는 테이블이 한 개 이상 필요합니다."); + } + + Set keys = new HashSet<>(); + Set tableNames = new HashSet<>(); + List tables = source.tables().stream().map(table -> { + if (table == null) { + throw new IllegalStateException("정형 데이터 카탈로그에 null 테이블 정의가 있습니다."); + } + String key = requireKey(table.key()); + String tableName = requireOracleName(table.tableName(), "tableName"); + if (!keys.add(key)) { + throw new IllegalStateException("정형 데이터 카탈로그 key가 중복됩니다: " + key); + } + if (!tableNames.add(tableName)) { + throw new IllegalStateException("정형 데이터 카탈로그 tableName이 중복됩니다: " + tableName); + } + List previewColumns = table.previewColumns() == null + ? List.of() + : table.previewColumns().stream() + .map(column -> requireOracleName(column, "previewColumns")) + .distinct() + .toList(); + String maskingPolicyName = table.maskingPolicyName() == null || table.maskingPolicyName().isBlank() + ? null + : requireOracleName(table.maskingPolicyName(), "maskingPolicyName"); + return new StructuredDataTable( + key, + tableName, + requireText(table.businessName(), "businessName"), + requireText(table.description(), "description"), + List.copyOf(previewColumns), + maskingPolicyName); + }).toList(); + + return new StructuredDataCatalog( + sourceName, owner, pageHelp, catalogDescription, source.rowLimit(), List.copyOf(tables)); + } + + private String requireKey(String value) { + String normalized = requireText(value, "key").toLowerCase(Locale.ROOT); + if (!TABLE_KEY.matcher(normalized).matches()) { + throw new IllegalStateException("정형 데이터 카탈로그 key 형식이 올바르지 않습니다: " + value); + } + return normalized; + } + + private String requireOracleName(String value, String field) { + String normalized = requireText(value, field).toUpperCase(Locale.ROOT); + if (!ORACLE_SIMPLE_NAME.matcher(normalized).matches()) { + throw new IllegalStateException("정형 데이터 카탈로그 " + field + " 형식이 올바르지 않습니다: " + value); + } + return normalized; + } + + private String requireText(String value, String field) { + if (value == null || value.isBlank()) { + throw new IllegalStateException("정형 데이터 카탈로그 " + field + " 값은 필수입니다."); + } + return value.trim(); + } +} diff --git a/src/main/java/com/cloudhandson/vpdbackoffice/service/StructuredDataService.java b/src/main/java/com/cloudhandson/vpdbackoffice/service/StructuredDataService.java index 9acc974..d13ad92 100644 --- a/src/main/java/com/cloudhandson/vpdbackoffice/service/StructuredDataService.java +++ b/src/main/java/com/cloudhandson/vpdbackoffice/service/StructuredDataService.java @@ -1,5 +1,6 @@ package com.cloudhandson.vpdbackoffice.service; +import com.cloudhandson.vpdbackoffice.domain.structured.StructuredDataCatalog; import com.cloudhandson.vpdbackoffice.domain.structured.StructuredDataPreview; import com.cloudhandson.vpdbackoffice.domain.structured.StructuredDataTable; import java.util.List; @@ -11,33 +12,35 @@ import org.springframework.stereotype.Service; @Service public class StructuredDataService { - private static final String OWNER = "POC_2"; - private static final int ROW_LIMIT = 50; - private static final List TABLES = List.of( - new StructuredDataTable("customers", "KB_CUSTOMERS", "고객원장", "고객 기본정보"), - new StructuredDataTable("products", "KB_PRODUCTS", "상품원장", "보험상품 마스터"), - new StructuredDataTable("contracts", "KB_CONTRACTS", "계약원장", "보험계약 정보"), - new StructuredDataTable("coverages", "KB_COVERAGES", "담보원장", "보장·특약 정보"), - new StructuredDataTable("claims", "KB_CLAIMS", "청구원장", "보험금 청구·지급 정보"), - new StructuredDataTable("external-holdings", "KB_EXTERNAL_HOLDINGS", "외부보유정보 원장", "타사·외부 가입·보유 정보"), - new StructuredDataTable("stakeholders", "KB_STAKEHOLDERS", "이해관계자 원장", "역할·담당자 매핑")); - private final JdbcTemplate jdbcTemplate; + private final StructuredDataCatalog catalog; - public StructuredDataService(JdbcTemplate jdbcTemplate) { + public StructuredDataService( + JdbcTemplate jdbcTemplate, + StructuredDataCatalogProvider catalogProvider + ) { this.jdbcTemplate = jdbcTemplate; + this.catalog = catalogProvider.catalog(); + } + + public StructuredDataCatalog catalog() { + return catalog; + } + + public String owner() { + return catalog.owner(); } public List tables() { - return TABLES; + return catalog.tables(); } public String defaultKey() { - return TABLES.getFirst().key(); + return catalog.tables().getFirst().key(); } public StructuredDataTable requireTable(String key) { - return TABLES.stream() + return catalog.tables().stream() .filter(table -> table.key().equals(key)) .findFirst() .orElseThrow(() -> new AppException("선택할 수 없는 정형 데이터 테이블입니다.")); @@ -53,17 +56,22 @@ public class StructuredDataService { WHERE owner = ? AND table_name = ? ORDER BY column_id - """, - (resultSet, rowNum) -> resultSet.getString(1), OWNER, table.tableName()); + """, + (resultSet, rowNum) -> resultSet.getString(1), catalog.owner(), table.tableName()); if (columns.isEmpty()) { throw new AppException("정형 데이터 테이블의 컬럼 정보를 찾을 수 없습니다."); } + List previewColumns = table.previewColumns().isEmpty() ? columns : table.previewColumns(); + if (!columns.containsAll(previewColumns)) { + throw new AppException("정형 데이터 JSON의 미리보기 컬럼이 실제 테이블과 일치하지 않습니다."); + } List> rows = jdbcTemplate.queryForList( - previewSql(table), ROW_LIMIT); - return new StructuredDataPreview(table, columns, rows, ROW_LIMIT); + previewSql(table, previewColumns), catalog.rowLimit()); + return new StructuredDataPreview(table, previewColumns, rows, catalog.rowLimit()); } catch (DataAccessException exception) { - throw new AppException("정형 데이터를 조회할 수 없습니다. POC_2 조회 권한과 대상 테이블 상태를 확인하세요."); + throw new AppException("정형 데이터를 조회할 수 없습니다. " + catalog.sourceName() + "의 " + + catalog.owner() + " 조회 권한과 대상 테이블 상태를 확인하세요."); } } @@ -71,16 +79,20 @@ public class StructuredDataService { * The table is selected from a closed application whitelist, so the query * text remains fixed and no request value can become a SQL identifier. */ - private String previewSql(StructuredDataTable table) { - return switch (table.key()) { - case "customers" -> "SELECT * FROM POC_2.KB_CUSTOMERS WHERE ROWNUM <= ?"; - case "products" -> "SELECT * FROM POC_2.KB_PRODUCTS WHERE ROWNUM <= ?"; - case "contracts" -> "SELECT * FROM POC_2.KB_CONTRACTS WHERE ROWNUM <= ?"; - case "coverages" -> "SELECT * FROM POC_2.KB_COVERAGES WHERE ROWNUM <= ?"; - case "claims" -> "SELECT * FROM POC_2.KB_CLAIMS WHERE ROWNUM <= ?"; - case "external-holdings" -> "SELECT * FROM POC_2.KB_EXTERNAL_HOLDINGS WHERE ROWNUM <= ?"; - case "stakeholders" -> "SELECT * FROM POC_2.KB_STAKEHOLDERS WHERE ROWNUM <= ?"; - default -> throw new AppException("선택할 수 없는 정형 데이터 테이블입니다."); - }; + String previewSql(StructuredDataTable table) { + return previewSql(table, table.previewColumns()); + } + + private String previewSql(StructuredDataTable table, List previewColumns) { + StructuredDataTable approved = requireTable(table.key()); + if (!approved.tableName().equals(table.tableName())) { + throw new AppException("선택할 수 없는 정형 데이터 테이블입니다."); + } + String projection = previewColumns == null || previewColumns.isEmpty() + ? "*" + : previewColumns.stream().map(column -> "\"" + column + "\"") + .reduce((left, right) -> left + ", " + right).orElseThrow(); + return "SELECT " + projection + " FROM \"" + catalog.owner() + "\".\"" + approved.tableName() + + "\" WHERE ROWNUM <= ?"; } } diff --git a/src/main/java/com/cloudhandson/vpdbackoffice/web/SchemaMetadataController.java b/src/main/java/com/cloudhandson/vpdbackoffice/web/SchemaMetadataController.java index 2c518e5..f6022bb 100644 --- a/src/main/java/com/cloudhandson/vpdbackoffice/web/SchemaMetadataController.java +++ b/src/main/java/com/cloudhandson/vpdbackoffice/web/SchemaMetadataController.java @@ -22,6 +22,7 @@ public class SchemaMetadataController { @GetMapping("/schema-metadata") public String schemaMetadata(@RequestParam(required = false) String table, Model model) { String selectedKey = table == null || table.isBlank() ? schemaMetadataService.defaultKey() : table; + model.addAttribute("catalog", schemaMetadataService.catalog()); model.addAttribute("tables", schemaMetadataService.tables()); model.addAttribute("selectedKey", selectedKey); try { diff --git a/src/main/java/com/cloudhandson/vpdbackoffice/web/StructuredDataController.java b/src/main/java/com/cloudhandson/vpdbackoffice/web/StructuredDataController.java index 159095e..4486109 100644 --- a/src/main/java/com/cloudhandson/vpdbackoffice/web/StructuredDataController.java +++ b/src/main/java/com/cloudhandson/vpdbackoffice/web/StructuredDataController.java @@ -22,6 +22,7 @@ public class StructuredDataController { Model model ) { String selectedKey = table == null || table.isBlank() ? structuredDataService.defaultKey() : table; + model.addAttribute("catalog", structuredDataService.catalog()); model.addAttribute("tables", structuredDataService.tables()); model.addAttribute("selectedKey", selectedKey); try { diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index 4bed6d3..eaec76e 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -33,6 +33,9 @@ server: same-site: lax backoffice: + structured-data: + # Override with file:/... JSON to reuse the application for another company/data model. + catalog-location: ${BACKOFFICE_STRUCTURED_DATA_CATALOG_LOCATION:classpath:/config/structured-data-catalog.json} security: admin-user: ${BACKOFFICE_ADMIN_USER:admin} admin-password: ${BACKOFFICE_ADMIN_PASSWORD:admin} diff --git a/src/main/resources/config/structured-data-catalog.json b/src/main/resources/config/structured-data-catalog.json new file mode 100644 index 0000000..8a83dcd --- /dev/null +++ b/src/main/resources/config/structured-data-catalog.json @@ -0,0 +1,59 @@ +{ + "sourceName": "HMMAIPOC", + "owner": "ADMIN", + "pageHelp": "HMM HR 데모의 승인된 조직·직원·휴가·근태 원장을 읽기 전용으로 조회합니다. 임의 SQL이나 수정 기능은 제공하지 않습니다.", + "catalogDescription": "HMMAIPOC의 ADMIN 스키마에서 승인된 HMM HR 정형 테이블만 표시합니다.", + "rowLimit": 50, + "tables": [ + { + "key": "teams", + "tableName": "HMM_ORG_TEAMS", + "businessName": "조직 원장", + "description": "HMM HR 조직·팀 기본정보" + }, + { + "key": "employees", + "tableName": "HMM_HR_EMPLOYEES", + "businessName": "직원 원장", + "description": "직원·매니저·소속팀 정보", + "maskingPolicyName": "HMM_EMPLOYEE_PII_REDACT" + }, + { + "key": "leave-balances", + "tableName": "HMM_LEAVE_BALANCES", + "businessName": "휴가 잔여 원장", + "description": "직원별 연도·휴가 유형별 부여·사용·잔여 일수", + "maskingPolicyName": "HMM_LEAVE_BALANCE_REDACT" + }, + { + "key": "leave-requests", + "tableName": "HMM_LEAVE_REQUESTS", + "businessName": "휴가 신청 원장", + "description": "직원별 휴가 신청·승인 상태와 기간", + "maskingPolicyName": "HMM_LEAVE_REQUEST_REDACT" + }, + { + "key": "attendance-daily", + "tableName": "HMM_ATTENDANCE_DAILY", + "businessName": "일별 근태 원장", + "description": "직원별 출퇴근·근무 상태와 근무 시간", + "maskingPolicyName": "HMM_ATTENDANCE_REDACT" + }, + { + "key": "hr-terms", + "tableName": "HMM_HR_TERMS", + "businessName": "HR 표준 용어 원장", + "description": "휴가·근태 표준 코드, 명칭과 유사 표현", + "previewColumns": [ + "TERM_ID", + "TERM_CODE", + "TERM_KIND", + "CANONICAL_NAME", + "TERM_NAME", + "IS_CANONICAL", + "DESCRIPTION", + "EMBEDDED_AT" + ] + } + ] +} diff --git a/src/main/resources/mapper/MaskingRuleMapper.xml b/src/main/resources/mapper/MaskingRuleMapper.xml index 82b1ac4..6fd2bdc 100644 --- a/src/main/resources/mapper/MaskingRuleMapper.xml +++ b/src/main/resources/mapper/MaskingRuleMapper.xml @@ -68,10 +68,11 @@ --> diff --git a/src/main/resources/templates/masking-rules.html b/src/main/resources/templates/masking-rules.html index 2ecd427..acfcc89 100644 --- a/src/main/resources/templates/masking-rules.html +++ b/src/main/resources/templates/masking-rules.html @@ -108,8 +108,8 @@ 보호 객체DB 정책백오피스 활성 컬럼DB Redaction 컬럼레거시 VPD 컬럼 제어DB 정책 활성상태 판단 - POC_2.KB_CUSTOMERS - KB_CUSTOMER_PII_REDACT + ADMIN.HMM_HR_EMPLOYEES + HMM_EMPLOYEE_PII_REDACT 0 0 없음 @@ -142,12 +142,12 @@

컬럼 마스킹 규칙 등록

-

업무용 이름을 붙여 템플릿을 재사용합니다. 예: KB_RRN_STANDARD.

+

업무용 이름을 붙여 템플릿을 재사용합니다. 예: HMM_EMAIL_STANDARD.

@@ -253,7 +253,7 @@ 대상 컬럼규칙템플릿백오피스 설정DB ASO 적용 상태 - POC_2.KB_CUSTOMERS.RRN_MASKED + ADMIN.HMM_HR_EMPLOYEES.EMAIL 주민번호 기본 마스킹 주민등록번호 부분 마스킹 기본 규칙 연결됨 diff --git a/src/main/resources/templates/operation-status.html b/src/main/resources/templates/operation-status.html index c3d0507..37b11f2 100644 --- a/src/main/resources/templates/operation-status.html +++ b/src/main/resources/templates/operation-status.html @@ -159,8 +159,8 @@ 보호 객체DB 정책백오피스 활성 컬럼DB Redaction 컬럼상태확인 결과 - POC_2.KB_CLAIMS - KB_CLAIM_AMOUNT_REDACT + ADMIN.HMM_HR_EMPLOYEES + HMM_EMPLOYEE_PII_REDACT 0 0 적용됨 diff --git a/src/main/resources/templates/schema-metadata.html b/src/main/resources/templates/schema-metadata.html index 79a710f..11ad0fc 100644 --- a/src/main/resources/templates/schema-metadata.html +++ b/src/main/resources/templates/schema-metadata.html @@ -10,10 +10,10 @@
도움말

- POC_2 KB 업무 테이블의 table/column comment와 Oracle annotation을 조회·수정합니다. + OWNER의 승인된 업무 테이블 table/column comment와 Oracle annotation을 조회·수정합니다. Select AI profile의 comments=true, annotations=true 설정에서는 이 값들이 SQL 생성 근거로 들어갑니다.

-

임의 스키마나 임의 테이블은 수정하지 않고, 백오피스가 승인한 7개 업무 테이블만 대상으로 합니다.

+

임의 스키마나 임의 테이블은 수정하지 않고, JSON 카탈로그가 승인한 업무 테이블만 대상으로 합니다.

@@ -24,18 +24,18 @@

테이블 선택

-

정형 MCP/Select AI가 참조하는 KB 업무 원장 7개만 표시합니다.

+

승인된 업무 원장만 표시합니다.

- 7 + 6
@@ -44,9 +44,9 @@
TABLE -

계약원장

+

직원 원장

- POC_2.KB_CONTRACTS + OWNER.TABLE_NAME · 설명

diff --git a/src/main/resources/templates/structured-data.html b/src/main/resources/templates/structured-data.html index 067176a..9e65b55 100644 --- a/src/main/resources/templates/structured-data.html +++ b/src/main/resources/templates/structured-data.html @@ -8,7 +8,8 @@

정형 데이터 조회

도움말 -

보험 원장 7개만 읽기 전용으로 조회합니다. 임의 SQL이나 수정 기능은 제공하지 않으며, 한 번에 최대 50건까지만 표시합니다.

+

승인된 업무 원장만 읽기 전용으로 조회합니다.

+

한 번에 최대 50건까지만 표시합니다.

@@ -20,7 +21,7 @@

조회할 원장 선택

-

KBAIPOC의 POC_2 스키마에서 승인된 정형 테이블만 표시합니다.

+

승인된 정형 테이블만 표시합니다.

@@ -28,9 +29,9 @@ class="structured-table-card" th:classappend="${entry.key() == selectedKey} ? ' is-selected'" th:href="@{/structured-data(table=${entry.key()})}"> - 고객원장 - KB_CUSTOMERS - 고객 기본정보 + 직원 원장 + HMM_HR_EMPLOYEES + 직원·조직 정보
@@ -40,9 +41,9 @@
-

고객원장

+

직원 원장

- POC_2.KB_CUSTOMERS + OWNER.TABLE_NAME · 최대 50건

diff --git a/src/test/java/com/cloudhandson/vpdbackoffice/service/MaskingPolicySynchronizerTest.java b/src/test/java/com/cloudhandson/vpdbackoffice/service/MaskingPolicySynchronizerTest.java index a8ece9b..c91b0c4 100644 --- a/src/test/java/com/cloudhandson/vpdbackoffice/service/MaskingPolicySynchronizerTest.java +++ b/src/test/java/com/cloudhandson/vpdbackoffice/service/MaskingPolicySynchronizerTest.java @@ -1,8 +1,13 @@ package com.cloudhandson.vpdbackoffice.service; import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.mock; +import com.cloudhandson.vpdbackoffice.mapper.MaskingRuleMapper; +import com.fasterxml.jackson.databind.ObjectMapper; import org.junit.jupiter.api.Test; +import org.springframework.core.io.ClassPathResource; +import org.springframework.jdbc.core.JdbcTemplate; class MaskingPolicySynchronizerTest { @@ -23,8 +28,16 @@ class MaskingPolicySynchronizerTest { } @Test - void kbContractsPremiumPolicyIsManagedByBackoffice() { - assertThat(MaskingPolicySynchronizer.managedPolicyName("KB_CONTRACTS")) - .isEqualTo("KB_CONTRACT_PREMIUM_REDACT"); + void hmmEmployeePolicyComesFromTheSharedJsonCatalog() { + var catalogProvider = new StructuredDataCatalogProvider( + new ObjectMapper(), new ClassPathResource("config/structured-data-catalog.json")); + var synchronizer = new MaskingPolicySynchronizer( + mock(JdbcTemplate.class), mock(MaskingRuleMapper.class), catalogProvider); + + assertThat(synchronizer.owner()).isEqualTo("ADMIN"); + assertThat(synchronizer.managedPolicyName("HMM_HR_EMPLOYEES")) + .isEqualTo("HMM_EMPLOYEE_PII_REDACT"); + assertThat(synchronizer.managedObjectNames()) + .doesNotContain("KB_CUSTOMERS", "KB_CLAIMS", "KB_CONTRACTS"); } } diff --git a/src/test/java/com/cloudhandson/vpdbackoffice/service/StructuredDataServiceTest.java b/src/test/java/com/cloudhandson/vpdbackoffice/service/StructuredDataServiceTest.java index 8b549fa..8dd669a 100644 --- a/src/test/java/com/cloudhandson/vpdbackoffice/service/StructuredDataServiceTest.java +++ b/src/test/java/com/cloudhandson/vpdbackoffice/service/StructuredDataServiceTest.java @@ -4,25 +4,34 @@ import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatThrownBy; import static org.mockito.Mockito.mock; +import com.fasterxml.jackson.databind.ObjectMapper; import org.junit.jupiter.api.Test; +import org.springframework.core.io.ByteArrayResource; +import org.springframework.core.io.ClassPathResource; import org.springframework.jdbc.core.JdbcTemplate; class StructuredDataServiceTest { - private final StructuredDataService service = new StructuredDataService(mock(JdbcTemplate.class)); + private final StructuredDataCatalogProvider catalogProvider = new StructuredDataCatalogProvider( + new ObjectMapper(), new ClassPathResource("config/structured-data-catalog.json")); + private final StructuredDataService service = new StructuredDataService( + mock(JdbcTemplate.class), catalogProvider); @Test - void exposesOnlyTheSevenApprovedStructuredTables() { + void exposesOnlyTheHMMTablesFromTheJsonCatalog() { + assertThat(service.catalog().sourceName()).isEqualTo("HMMAIPOC"); + assertThat(service.owner()).isEqualTo("ADMIN"); assertThat(service.tables()) .extracting(table -> table.tableName()) .containsExactly( - "KB_CUSTOMERS", - "KB_PRODUCTS", - "KB_CONTRACTS", - "KB_COVERAGES", - "KB_CLAIMS", - "KB_EXTERNAL_HOLDINGS", - "KB_STAKEHOLDERS"); + "HMM_ORG_TEAMS", + "HMM_HR_EMPLOYEES", + "HMM_LEAVE_BALANCES", + "HMM_LEAVE_REQUESTS", + "HMM_ATTENDANCE_DAILY", + "HMM_HR_TERMS"); + assertThat(service.previewSql(service.requireTable("employees"))) + .isEqualTo("SELECT * FROM \"ADMIN\".\"HMM_HR_EMPLOYEES\" WHERE ROWNUM <= ?"); } @Test @@ -31,4 +40,25 @@ class StructuredDataServiceTest { .isInstanceOf(AppException.class) .hasMessage("선택할 수 없는 정형 데이터 테이블입니다."); } + + @Test + void rejectsUnsafeOracleIdentifiersInAnExternalCatalog() { + String unsafeJson = """ + { + "sourceName": "demo", + "owner": "ADMIN; DROP USER X", + "pageHelp": "help", + "catalogDescription": "description", + "rowLimit": 10, + "tables": [ + {"key":"employees","tableName":"EMPLOYEES","businessName":"직원","description":"직원"} + ] + } + """; + + assertThatThrownBy(() -> new StructuredDataCatalogProvider( + new ObjectMapper(), new ByteArrayResource(unsafeJson.getBytes()))) + .isInstanceOf(IllegalStateException.class) + .hasMessageContaining("owner 형식이 올바르지 않습니다"); + } }