refs #702: externalize HMM structured data catalog
This commit is contained in:
2
deploy/systemd/vpd-backoffice-structured-data.conf
Normal file
2
deploy/systemd/vpd-backoffice-structured-data.conf
Normal file
@@ -0,0 +1,2 @@
|
||||
[Service]
|
||||
Environment=BACKOFFICE_STRUCTURED_DATA_CATALOG_LOCATION=file:/home/opc/apps/vpd-backoffice/config/structured-data-catalog.json
|
||||
@@ -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 메타데이터 관리에서는 전체 컬럼을 유지한다.
|
||||
|
||||
60
docs/reports/2026-07-23-hmm-backoffice-full-browser-audit.md
Normal file
60
docs/reports/2026-07-23-hmm-backoffice-full-browser-audit.md
Normal 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 자체는 용어 검색 기능에서 계속 사용한다.
|
||||
@@ -0,0 +1,7 @@
|
||||
package com.cloudhandson.vpdbackoffice.domain.masking;
|
||||
|
||||
public record ManagedMaskingPolicy(
|
||||
String objectName,
|
||||
String policyName
|
||||
) {
|
||||
}
|
||||
@@ -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
|
||||
) {
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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 <= ?";
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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}
|
||||
|
||||
59
src/main/resources/config/structured-data-catalog.json
Normal file
59
src/main/resources/config/structured-data-catalog.json
Normal 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"
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 형식이 올바르지 않습니다");
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user