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