refs #702: externalize HMM structured data catalog

This commit is contained in:
devmrko
2026-07-23 10:43:54 +09:00
parent eb5105fe72
commit 9e3840a4d7
23 changed files with 495 additions and 136 deletions

View File

@@ -0,0 +1,2 @@
[Service]
Environment=BACKOFFICE_STRUCTURED_DATA_CATALOG_LOCATION=file:/home/opc/apps/vpd-backoffice/config/structured-data-catalog.json

View File

@@ -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 메타데이터 관리에서는 전체 컬럼을 유지한다.

View File

@@ -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 자체는 용어 검색 기능에서 계속 사용한다.

View File

@@ -0,0 +1,7 @@
package com.cloudhandson.vpdbackoffice.domain.masking;
public record ManagedMaskingPolicy(
String objectName,
String policyName
) {
}

View File

@@ -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<StructuredDataTable> tables
) {
}

View File

@@ -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<String> previewColumns,
String maskingPolicyName
) {
public StructuredDataTable(String key, String tableName, String businessName, String description) {
this(key, tableName, businessName, description, List.of(), null);
}
}

View File

@@ -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<ColumnMaskingRule> findColumnRules();
List<MaskingPolicyStatus> findPolicyStatuses();
List<MaskingPolicyStatus> findPolicyStatuses(
@Param("owner") String owner,
@Param("policies") List<ManagedMaskingPolicy> policies
);
ColumnMaskingRule findColumnRule(@Param("columnId") long columnId);

View File

@@ -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<String, String> MANAGED_POLICIES = managedPolicyMap();
private final JdbcTemplate jdbcTemplate;
private final MaskingRuleMapper mapper;
private final StructuredDataCatalog catalog;
private final Map<String, String> managedPolicies;
public MaskingPolicySynchronizer(JdbcTemplate jdbcTemplate, MaskingRuleMapper mapper) {
public MaskingPolicySynchronizer(
JdbcTemplate jdbcTemplate,
MaskingRuleMapper mapper,
StructuredDataCatalogProvider catalogProvider
) {
this.jdbcTemplate = jdbcTemplate;
this.mapper = mapper;
}
private static Map<String, String> managedPolicyMap() {
this.catalog = catalogProvider.catalog();
Map<String, String> 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<String> 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<String, List<ColumnMaskingRule>> 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<String, String> policy : MANAGED_POLICIES.entrySet()) {
for (Map.Entry<String, String> policy : managedPolicies.entrySet()) {
String objectName = policy.getKey();
String policyName = policy.getValue();
List<ColumnMaskingRule> 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);
}
}

View File

@@ -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<MaskingPolicyStatus> findPolicyStatuses() {
return mapper.findPolicyStatuses();
List<ManagedMaskingPolicy> policies = maskingPolicySynchronizer.managedObjectNames().stream()
.map(objectName -> new ManagedMaskingPolicy(
objectName, maskingPolicySynchronizer.managedPolicyName(objectName)))
.toList();
return mapper.findPolicyStatuses(maskingPolicySynchronizer.owner(), policies);
}
public Set<String> managedObjectNames() {

View File

@@ -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<String, List<SchemaAnnotation>> 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) {

View File

@@ -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<String> keys = new HashSet<>();
Set<String> tableNames = new HashSet<>();
List<StructuredDataTable> 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<String> 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();
}
}

View File

@@ -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<StructuredDataTable> 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<StructuredDataTable> 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<String> previewColumns = table.previewColumns().isEmpty() ? columns : table.previewColumns();
if (!columns.containsAll(previewColumns)) {
throw new AppException("정형 데이터 JSON의 미리보기 컬럼이 실제 테이블과 일치하지 않습니다.");
}
List<Map<String, Object>> 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<String> 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 <= ?";
}
}

View File

@@ -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 {

View File

@@ -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 {

View File

@@ -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}

View File

@@ -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"
]
}
]
}

View File

@@ -68,10 +68,11 @@
-->
<select id="findPolicyStatuses" resultType="com.cloudhandson.vpdbackoffice.domain.masking.MaskingPolicyStatus">
WITH managed_policy AS (
SELECT 'KB_CUSTOMERS' AS object_name, 'KB_CUSTOMER_PII_REDACT' AS policy_name FROM dual
UNION ALL SELECT 'KB_CLAIMS', 'KB_CLAIM_AMOUNT_REDACT' FROM dual
UNION ALL SELECT 'KB_CONTRACTS', 'KB_CONTRACT_PREMIUM_REDACT' FROM dual
UNION ALL SELECT 'KB_EXTERNAL_HOLDINGS', 'KB_EXT_HOLDING_REDACT' FROM dual
<foreach collection="policies" item="policy" separator=" UNION ALL ">
SELECT #{policy.objectName} AS object_name,
#{policy.policyName} AS policy_name
FROM dual
</foreach>
),
configured AS (
SELECT protected_object.object_name,
@@ -80,7 +81,7 @@
JOIN cb_masking_rule rule ON rule.rule_id = link.rule_id
JOIN cb_protected_column protected_column ON protected_column.column_id = link.column_id
JOIN cb_protected_object protected_object ON protected_object.object_id = protected_column.object_id
WHERE protected_object.owner = 'POC_2'
WHERE protected_object.owner = #{owner}
AND rule.enabled_yn = 'Y'
GROUP BY protected_object.object_name
),
@@ -96,7 +97,7 @@
LEFT JOIN redaction_columns policy_column
ON policy_column.object_owner = policy.object_owner
AND policy_column.object_name = policy.object_name
WHERE policy.object_owner = 'POC_2'
WHERE policy.object_owner = #{owner}
GROUP BY policy.object_name, policy.policy_name, policy.enable
),
missing_columns AS (
@@ -110,7 +111,7 @@
ON policy_column.object_owner = protected_object.owner
AND policy_column.object_name = protected_object.object_name
AND policy_column.column_name = protected_column.column_name
WHERE protected_object.owner = 'POC_2'
WHERE protected_object.owner = #{owner}
AND rule.enabled_yn = 'Y'
AND policy_column.column_name IS NULL
GROUP BY protected_object.object_name
@@ -120,36 +121,21 @@
COUNT(*) AS extra_column_count
FROM redaction_columns policy_column
JOIN managed_policy managed ON managed.object_name = policy_column.object_name
WHERE policy_column.object_owner = 'POC_2'
WHERE policy_column.object_owner = #{owner}
AND NOT EXISTS (
SELECT 1
FROM cb_column_masking_rule link
JOIN cb_masking_rule rule ON rule.rule_id = link.rule_id
JOIN cb_protected_column protected_column ON protected_column.column_id = link.column_id
JOIN cb_protected_object protected_object ON protected_object.object_id = protected_column.object_id
WHERE protected_object.owner = 'POC_2'
WHERE protected_object.owner = #{owner}
AND protected_object.object_name = policy_column.object_name
AND protected_column.column_name = policy_column.column_name
AND rule.enabled_yn = 'Y'
)
GROUP BY policy_column.object_name
),
legacy_vpd_column_policy AS (
SELECT policy.object_name,
COUNT(*) AS legacy_vpd_column_policy_count
FROM all_policies policy
WHERE policy.object_owner = 'POC_2'
AND policy.enable = 'YES'
AND policy.policy_name IN (
'KB_CUST_NM_CLS_POLICY', 'KB_RRN_CLS_POLICY',
'KB_PREMIUM_CLS_POLICY',
'KB_CLAIM_AMT_CLS_POLICY', 'KB_PAID_AMT_CLS_POLICY',
'KB_EXT_INSURER_CLS_POLICY', 'KB_EXT_PRODUCT_GRP_CLS_POLICY',
'KB_EXT_PRODUCT_TYPE_CLS_POLICY'
)
GROUP BY policy.object_name
)
SELECT 'POC_2' AS owner,
SELECT #{owner} AS owner,
managed.object_name,
managed.policy_name,
database_policy.enable AS enabled,
@@ -157,15 +143,12 @@
NVL(database_policy.applied_column_count, 0) AS applied_column_count,
NVL(missing_columns.missing_column_count, 0) + NVL(extra_columns.extra_column_count, 0)
AS mismatched_column_count,
NVL(legacy_vpd_column_policy.legacy_vpd_column_policy_count, 0)
AS legacy_vpd_column_policy_count
0 AS legacy_vpd_column_policy_count
FROM managed_policy managed
LEFT JOIN configured ON configured.object_name = managed.object_name
LEFT JOIN database_policy ON database_policy.object_name = managed.object_name
LEFT JOIN missing_columns ON missing_columns.object_name = managed.object_name
LEFT JOIN extra_columns ON extra_columns.object_name = managed.object_name
LEFT JOIN legacy_vpd_column_policy
ON legacy_vpd_column_policy.object_name = managed.object_name
ORDER BY managed.object_name
</select>

View File

@@ -108,8 +108,8 @@
<thead><tr><th>보호 객체</th><th>DB 정책</th><th>백오피스 활성 컬럼</th><th>DB Redaction 컬럼</th><th>레거시 VPD 컬럼 제어</th><th>DB 정책 활성</th><th>상태 판단</th></tr></thead>
<tbody>
<tr th:each="status : ${policyStatuses}">
<td><code th:text="${status.targetLabel()}">POC_2.KB_CUSTOMERS</code></td>
<td><code th:text="${status.policyName()}">KB_CUSTOMER_PII_REDACT</code></td>
<td><code th:text="${status.targetLabel()}">ADMIN.HMM_HR_EMPLOYEES</code></td>
<td><code th:text="${status.policyName()}">HMM_EMPLOYEE_PII_REDACT</code></td>
<td th:text="${status.configuredColumnCount()}">0</td>
<td th:text="${status.appliedColumnCount()}">0</td>
<td><span class="badge" th:classappend="${status.legacyVpdColumnPolicyCount() == 0} ? ' text-bg-secondary' : ' text-bg-danger'" th:text="${status.legacyVpdColumnPolicyCount() == 0} ? '없음' : ${status.legacyVpdColumnPolicyCount() + '건 활성'}">없음</span></td>
@@ -142,12 +142,12 @@
<section class="content-band">
<h2>컬럼 마스킹 규칙 등록</h2>
<p class="section-subtitle">업무용 이름을 붙여 템플릿을 재사용합니다. 예: <code>KB_RRN_STANDARD</code>.</p>
<p class="section-subtitle">업무용 이름을 붙여 템플릿을 재사용합니다. 예: <code>HMM_EMAIL_STANDARD</code>.</p>
<form method="post" action="/masking-rules" class="form-grid">
<input type="hidden" th:name="${_csrf.parameterName}" th:value="${_csrf.token}">
<label>
규칙 코드
<input class="form-control" name="ruleCode" maxlength="64" pattern="[A-Za-z][A-Za-z0-9_]{2,63}" required placeholder="KB_RRN_STANDARD">
<input class="form-control" name="ruleCode" maxlength="64" pattern="[A-Za-z][A-Za-z0-9_]{2,63}" required placeholder="HMM_EMAIL_STANDARD">
<span class="form-hint">영문·숫자·밑줄만 사용합니다.</span>
</label>
<label>
@@ -176,7 +176,7 @@
<thead><tr><th>코드</th><th>규칙명</th><th>템플릿</th><th>설명</th><th>상태</th><th></th></tr></thead>
<tbody>
<tr th:each="rule : ${rules}">
<td><code th:text="${rule.ruleCode()}">KB_RRN_STANDARD</code></td>
<td><code th:text="${rule.ruleCode()}">HMM_EMAIL_STANDARD</code></td>
<td th:text="${rule.ruleName()}">주민번호 기본 마스킹</td>
<td th:text="${rule.templateLabel()}">값 숨김(NULL)</td>
<td th:text="${rule.description() ?: '-'}">설명</td>
@@ -209,11 +209,11 @@
<optgroup th:label="${object.displayName()}" th:if="${!#lists.isEmpty(availableMaskingColumnsByObject[object.objectId()])}">
<option th:each="columnName : ${availableMaskingColumnsByObject[object.objectId()]}"
th:value="|${object.objectId()}:${columnName}|"
th:text="${object.displayName() + '.' + columnName}">POC_2.KB_CONTRACTS.PREMIUM</option>
th:text="${object.displayName() + '.' + columnName}">ADMIN.HMM_HR_EMPLOYEES.EMAIL</option>
</optgroup>
</th:block>
</select>
<span class="form-hint">현재 관리 대상 ASO 정책이 있는 객체만 표시됩니다. 예: <code>POC_2.KB_CONTRACTS.PREMIUM</code>.</span>
<span class="form-hint">현재 JSON 카탈로그에 관리 대상 ASO 정책이 있는 객체만 표시됩니다. 예: <code>ADMIN.HMM_HR_EMPLOYEES.EMAIL</code>.</span>
</label>
<button class="btn btn-outline-primary" type="submit">대상 컬럼 추가</button>
</form>
@@ -253,7 +253,7 @@
<thead><tr><th>대상 컬럼</th><th>규칙</th><th>템플릿</th><th>백오피스 설정</th><th>DB ASO 적용 상태</th><th></th></tr></thead>
<tbody>
<tr th:each="columnRule : ${columnRules}">
<td><code th:text="${columnRule.targetLabel()}">POC_2.KB_CUSTOMERS.RRN_MASKED</code></td>
<td><code th:text="${columnRule.targetLabel()}">ADMIN.HMM_HR_EMPLOYEES.EMAIL</code></td>
<td th:text="${columnRule.ruleName()}">주민번호 기본 마스킹</td>
<td th:text="${columnRule.template().label()}">주민등록번호 부분 마스킹</td>
<td><span class="badge" th:classappend="${columnRule.ruleEnabled()} ? ' text-bg-success' : ' text-bg-warning'" th:text="${columnRule.ruleEnabled()} ? '기본 규칙 연결됨' : '규칙 비활성'">기본 규칙 연결됨</span></td>

View File

@@ -159,8 +159,8 @@
<thead><tr><th>보호 객체</th><th>DB 정책</th><th>백오피스 활성 컬럼</th><th>DB Redaction 컬럼</th><th>상태</th><th>확인 결과</th></tr></thead>
<tbody>
<tr th:each="status : ${maskingPolicyStatuses}">
<td><code th:text="${status.targetLabel()}">POC_2.KB_CLAIMS</code></td>
<td><code th:text="${status.policyName()}">KB_CLAIM_AMOUNT_REDACT</code></td>
<td><code th:text="${status.targetLabel()}">ADMIN.HMM_HR_EMPLOYEES</code></td>
<td><code th:text="${status.policyName()}">HMM_EMPLOYEE_PII_REDACT</code></td>
<td th:text="${status.configuredColumnCount()}">0</td>
<td th:text="${status.appliedColumnCount()}">0</td>
<td><span class="badge" th:classappend="${' ' + status.badgeClass()}" th:text="${status.statusLabel()}">적용됨</span></td>

View File

@@ -10,10 +10,10 @@
<details class="explanation-details">
<summary>도움말</summary>
<p>
<code>POC_2</code> KB 업무 테이블 table/column comment와 Oracle annotation을 조회·수정합니다.
<code th:text="${catalog.owner()}">OWNER</code>의 승인된 업무 테이블 table/column comment와 Oracle annotation을 조회·수정합니다.
Select AI profile의 <code>comments=true</code>, <code>annotations=true</code> 설정에서는 이 값들이 SQL 생성 근거로 들어갑니다.
</p>
<p class="mb-0">임의 스키마나 임의 테이블은 수정하지 않고, 백오피스가 승인한 7개 업무 테이블만 대상으로 합니다.</p>
<p class="mb-0">임의 스키마나 임의 테이블은 수정하지 않고, JSON 카탈로그가 승인한 업무 테이블만 대상으로 합니다.</p>
</details>
</section>
@@ -24,18 +24,18 @@
<div class="section-heading">
<div>
<h2>테이블 선택</h2>
<p class="section-subtitle">정형 MCP/Select AI가 참조하는 KB 업무 원장 7개만 표시합니다.</p>
<p class="section-subtitle" th:text="${catalog.catalogDescription()}">승인된 업무 원장만 표시합니다.</p>
</div>
<span class="badge text-bg-secondary" th:text="${#lists.size(tables)}">7</span>
<span class="badge text-bg-secondary" th:text="${#lists.size(tables)}">6</span>
</div>
<div class="structured-table-grid">
<a th:each="entry : ${tables}"
class="structured-table-card"
th:classappend="${entry.key() == selectedKey} ? ' is-selected'"
th:href="@{/schema-metadata(table=${entry.key()})}">
<strong th:text="${entry.businessName()}">고객원장</strong>
<code th:text="${entry.tableName()}">KB_CUSTOMERS</code>
<small th:text="${entry.description()}">고객 기본정보</small>
<strong th:text="${entry.businessName()}">직원 원장</strong>
<code th:text="${entry.tableName()}">HMM_HR_EMPLOYEES</code>
<small th:text="${entry.description()}">직원·조직 정보</small>
</a>
</div>
</section>
@@ -44,9 +44,9 @@
<div class="section-heading">
<div>
<span class="badge text-bg-secondary">TABLE</span>
<h2 class="mt-2" th:text="${metadata.table().businessName()}">계약원장</h2>
<h2 class="mt-2" th:text="${metadata.table().businessName()}">직원 원장</h2>
<p class="section-subtitle">
<code th:text="${'POC_2.' + metadata.table().tableName()}">POC_2.KB_CONTRACTS</code>
<code th:text="${catalog.owner() + '.' + metadata.table().tableName()}">OWNER.TABLE_NAME</code>
<span th:text="${' · ' + metadata.table().description()}"> · 설명</span>
</p>
</div>

View File

@@ -8,7 +8,8 @@
<h1>정형 데이터 조회</h1>
<details class="explanation-details">
<summary>도움말</summary>
<p>보험 원장 7개만 읽기 전용으로 조회합니다. 임의 SQL이나 수정 기능은 제공하지 않으며, 한 번에 최대 50건까지만 표시합니다.</p>
<p th:text="${catalog.pageHelp()}">승인된 업무 원장만 읽기 전용으로 조회합니다.</p>
<p class="mb-0" th:text="${'한 번에 최대 ' + catalog.rowLimit() + '건까지만 표시합니다.'}">한 번에 최대 50건까지만 표시합니다.</p>
</details>
</div>
@@ -20,7 +21,7 @@
<div class="section-heading">
<div>
<h2>조회할 원장 선택</h2>
<p class="section-subtitle">KBAIPOC의 <code>POC_2</code> 스키마에서 승인된 정형 테이블만 표시합니다.</p>
<p class="section-subtitle" th:text="${catalog.catalogDescription()}">승인된 정형 테이블만 표시합니다.</p>
</div>
</div>
<div class="structured-table-grid">
@@ -28,9 +29,9 @@
class="structured-table-card"
th:classappend="${entry.key() == selectedKey} ? ' is-selected'"
th:href="@{/structured-data(table=${entry.key()})}">
<strong th:text="${entry.businessName()}">고객원장</strong>
<code th:text="${entry.tableName()}">KB_CUSTOMERS</code>
<small th:text="${entry.description()}">고객 기본정보</small>
<strong th:text="${entry.businessName()}">직원 원장</strong>
<code th:text="${entry.tableName()}">HMM_HR_EMPLOYEES</code>
<small th:text="${entry.description()}">직원·조직 정보</small>
</a>
</div>
</section>
@@ -40,9 +41,9 @@
<section class="content-band" th:if="${preview}">
<div class="section-heading">
<div>
<h2 th:text="${preview.table().businessName()}">고객원장</h2>
<h2 th:text="${preview.table().businessName()}">직원 원장</h2>
<p class="section-subtitle">
<code th:text="${'POC_2.' + preview.table().tableName()}">POC_2.KB_CUSTOMERS</code>
<code th:text="${catalog.owner() + '.' + preview.table().tableName()}">OWNER.TABLE_NAME</code>
<span th:text="${' · 최대 ' + preview.rowLimit() + '건'}"> · 최대 50건</span>
</p>
</div>

View File

@@ -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");
}
}

View File

@@ -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 형식이 올바르지 않습니다");
}
}