353 lines
13 KiB
Java
353 lines
13 KiB
Java
package com.cloudhandson.vpdbackoffice.service;
|
|
|
|
import com.cloudhandson.vpdbackoffice.domain.masking.ColumnMaskingRule;
|
|
import com.cloudhandson.vpdbackoffice.domain.masking.MaskingTemplate;
|
|
import com.cloudhandson.vpdbackoffice.mapper.MaskingRuleMapper;
|
|
import java.util.ArrayList;
|
|
import java.util.LinkedHashMap;
|
|
import java.util.LinkedHashSet;
|
|
import java.util.List;
|
|
import java.util.Locale;
|
|
import java.util.Map;
|
|
import java.util.Set;
|
|
import java.util.regex.Pattern;
|
|
import org.springframework.jdbc.core.JdbcTemplate;
|
|
import org.springframework.stereotype.Service;
|
|
|
|
/**
|
|
* Reconciles the backoffice masking metadata with the managed Oracle
|
|
* Data Redaction policies. Metadata is the source of truth: a table with no
|
|
* active linked columns has its managed policy disabled, rather than silently
|
|
* retaining redaction after its UI configuration was removed.
|
|
*/
|
|
@Service
|
|
public class MaskingPolicySynchronizer {
|
|
|
|
private static final Pattern COLUMN_NAME = Pattern.compile("[A-Z][A-Z0-9_$#]{0,127}");
|
|
|
|
private final JdbcTemplate jdbcTemplate;
|
|
private final MaskingRuleMapper mapper;
|
|
private final DataCatalog dataCatalog;
|
|
private final MaskingPolicyCatalog policyCatalog;
|
|
|
|
public MaskingPolicySynchronizer(
|
|
JdbcTemplate jdbcTemplate,
|
|
MaskingRuleMapper mapper,
|
|
DataCatalog dataCatalog,
|
|
MaskingPolicyCatalog policyCatalog
|
|
) {
|
|
this.jdbcTemplate = jdbcTemplate;
|
|
this.mapper = mapper;
|
|
this.dataCatalog = dataCatalog;
|
|
this.policyCatalog = policyCatalog;
|
|
}
|
|
|
|
public Set<String> managedObjectNames() {
|
|
return policyCatalog.objectNames();
|
|
}
|
|
|
|
public boolean isManagedObject(String objectName) {
|
|
return policyCatalog.containsObject(objectName);
|
|
}
|
|
|
|
/**
|
|
* Applies the current active column-rule metadata to managed DBMS_REDACT policies.
|
|
*
|
|
* <p>The backoffice metadata is the source of truth. In particular, when an object has no
|
|
* active linked column rule, its policy is disabled. This avoids an old redaction policy
|
|
* continuing to mask data after an operator removed every rule from the UI.</p>
|
|
*/
|
|
public MaskingPolicySyncResult synchronize() {
|
|
Map<String, List<ColumnMaskingRule>> desiredByObject = new LinkedHashMap<>();
|
|
for (ColumnMaskingRule rule : mapper.findColumnRules()) {
|
|
if (dataCatalog.owner().equalsIgnoreCase(rule.owner())
|
|
&& rule.ruleEnabled()
|
|
&& policyCatalog.containsObject(rule.objectName())) {
|
|
desiredByObject.computeIfAbsent(rule.objectName(), ignored -> new ArrayList<>()).add(rule);
|
|
}
|
|
}
|
|
|
|
int disabledPolicies = 0;
|
|
int enabledPolicies = 0;
|
|
int addedColumns = 0;
|
|
int modifiedColumns = 0;
|
|
int droppedColumns = 0;
|
|
for (MaskingPolicyTarget policy : policyCatalog.targets()) {
|
|
String objectName = policy.objectName();
|
|
String policyName = policy.policyName();
|
|
List<ColumnMaskingRule> desired = desiredByObject.getOrDefault(objectName, List.of());
|
|
String enableStatus = policyEnableStatus(objectName, policyName);
|
|
if (desired.isEmpty()) {
|
|
if ("YES".equals(enableStatus)) {
|
|
disablePolicy(objectName, policyName);
|
|
disabledPolicies++;
|
|
}
|
|
continue;
|
|
}
|
|
|
|
boolean exists = enableStatus != null;
|
|
if (exists) {
|
|
if (!"YES".equals(enableStatus)) {
|
|
enablePolicy(objectName, policyName);
|
|
enabledPolicies++;
|
|
}
|
|
}
|
|
Set<String> desiredColumns = desired.stream()
|
|
.map(ColumnMaskingRule::columnName)
|
|
.map(this::requiredColumnName)
|
|
.collect(LinkedHashSet::new, Set::add, Set::addAll);
|
|
Set<String> actualColumns = exists
|
|
? new LinkedHashSet<>(redactionColumns(objectName))
|
|
: new LinkedHashSet<>();
|
|
|
|
for (String actualColumn : actualColumns) {
|
|
if (!desiredColumns.contains(actualColumn)) {
|
|
dropColumn(objectName, policyName, actualColumn);
|
|
droppedColumns++;
|
|
}
|
|
}
|
|
|
|
boolean firstColumn = !exists;
|
|
for (ColumnMaskingRule desiredColumn : desired) {
|
|
String columnName = requiredColumnName(desiredColumn.columnName());
|
|
if (firstColumn) {
|
|
addPolicy(objectName, policyName, columnName, desiredColumn.template());
|
|
firstColumn = false;
|
|
addedColumns++;
|
|
} else if (actualColumns.contains(columnName)) {
|
|
modifyColumn(objectName, policyName, columnName, desiredColumn.template());
|
|
modifiedColumns++;
|
|
} else {
|
|
addColumn(objectName, policyName, columnName, desiredColumn.template());
|
|
addedColumns++;
|
|
}
|
|
upsertColumnExpression(objectName, columnName, desiredColumn.columnId());
|
|
}
|
|
}
|
|
return new MaskingPolicySyncResult(
|
|
disabledPolicies, enabledPolicies, addedColumns, modifiedColumns, droppedColumns
|
|
);
|
|
}
|
|
|
|
private String policyEnableStatus(String objectName, String policyName) {
|
|
List<String> statuses = jdbcTemplate.queryForList("""
|
|
SELECT enable
|
|
FROM redaction_policies
|
|
WHERE object_owner = ? AND object_name = ? AND policy_name = ?
|
|
""", String.class, dataCatalog.owner(), objectName, policyName);
|
|
return statuses.isEmpty() ? null : statuses.getFirst();
|
|
}
|
|
|
|
private List<String> redactionColumns(String objectName) {
|
|
return jdbcTemplate.queryForList("""
|
|
SELECT column_name
|
|
FROM redaction_columns
|
|
WHERE object_owner = ? AND object_name = ?
|
|
""", String.class, dataCatalog.owner(), objectName).stream()
|
|
.map(this::requiredColumnName)
|
|
.toList();
|
|
}
|
|
|
|
private void disablePolicy(String objectName, String policyName) {
|
|
jdbcTemplate.update("""
|
|
BEGIN
|
|
DBMS_REDACT.DISABLE_POLICY(object_schema => ?, object_name => ?, policy_name => ?);
|
|
END;
|
|
""", dataCatalog.owner(), objectName, policyName);
|
|
}
|
|
|
|
private void enablePolicy(String objectName, String policyName) {
|
|
jdbcTemplate.update("""
|
|
BEGIN
|
|
DBMS_REDACT.ENABLE_POLICY(object_schema => ?, object_name => ?, policy_name => ?);
|
|
END;
|
|
""", dataCatalog.owner(), objectName, policyName);
|
|
}
|
|
|
|
private void dropColumn(String objectName, String policyName, String columnName) {
|
|
jdbcTemplate.update("""
|
|
BEGIN
|
|
DBMS_REDACT.ALTER_POLICY(
|
|
object_schema => ?, object_name => ?, policy_name => ?,
|
|
action => DBMS_REDACT.DROP_COLUMN, column_name => ?
|
|
);
|
|
END;
|
|
""", dataCatalog.owner(), objectName, policyName, columnName);
|
|
}
|
|
|
|
private void addPolicy(
|
|
String objectName, String policyName, String columnName, MaskingTemplate template
|
|
) {
|
|
callTemplate("ADD_POLICY", objectName, policyName, columnName, template);
|
|
}
|
|
|
|
private void addColumn(
|
|
String objectName, String policyName, String columnName, MaskingTemplate template
|
|
) {
|
|
callTemplate("ADD_COLUMN", objectName, policyName, columnName, template);
|
|
}
|
|
|
|
private void modifyColumn(
|
|
String objectName, String policyName, String columnName, MaskingTemplate template
|
|
) {
|
|
callTemplate("MODIFY_COLUMN", objectName, policyName, columnName, template);
|
|
}
|
|
|
|
/**
|
|
* Template choice is an enum, so DBMS_REDACT constants are rendered only from trusted source
|
|
* code. They cannot be supplied from a request parameter or backoffice table value.
|
|
*/
|
|
private void callTemplate(
|
|
String operation, String objectName, String policyName, String columnName, MaskingTemplate template
|
|
) {
|
|
String functionConstant = switch (template) {
|
|
case NULLIFY -> "DBMS_REDACT.NULLIFY";
|
|
case FULL -> "DBMS_REDACT.FULL";
|
|
case TEXT_PARTIAL, RRN_PARTIAL -> "DBMS_REDACT.REGEXP";
|
|
};
|
|
String actionConstant = switch (operation) {
|
|
case "ADD_POLICY" -> null;
|
|
case "ADD_COLUMN" -> "DBMS_REDACT.ADD_COLUMN";
|
|
case "MODIFY_COLUMN" -> "DBMS_REDACT.MODIFY_COLUMN";
|
|
default -> throw new IllegalArgumentException("Unsupported redaction operation");
|
|
};
|
|
String regexPattern = switch (template) {
|
|
case TEXT_PARTIAL -> "(^.).*$";
|
|
case RRN_PARTIAL -> "(^[0-9]{6})-?[0-9]{7}$";
|
|
default -> null;
|
|
};
|
|
String regexReplacement = switch (template) {
|
|
case TEXT_PARTIAL -> "\\1***";
|
|
case RRN_PARTIAL -> "\\1-*******";
|
|
default -> null;
|
|
};
|
|
|
|
if ("ADD_POLICY".equals(operation)) {
|
|
String sql = template == MaskingTemplate.TEXT_PARTIAL || template == MaskingTemplate.RRN_PARTIAL
|
|
? """
|
|
BEGIN
|
|
DBMS_REDACT.ADD_POLICY(
|
|
object_schema => ?, object_name => ?, policy_name => ?,
|
|
policy_description => 'Managed by VPD masking backoffice',
|
|
column_name => ?, function_type => %s, expression => '1=1',
|
|
regexp_pattern => ?, regexp_replace_string => ?, enable => TRUE
|
|
);
|
|
END;
|
|
""".formatted(functionConstant)
|
|
: """
|
|
BEGIN
|
|
DBMS_REDACT.ADD_POLICY(
|
|
object_schema => ?, object_name => ?, policy_name => ?,
|
|
policy_description => 'Managed by VPD masking backoffice',
|
|
column_name => ?, function_type => %s, expression => '1=1', enable => TRUE
|
|
);
|
|
END;
|
|
""".formatted(functionConstant);
|
|
if (regexPattern == null) {
|
|
jdbcTemplate.update(sql, dataCatalog.owner(), objectName, policyName, columnName);
|
|
} else {
|
|
jdbcTemplate.update(sql, dataCatalog.owner(), objectName, policyName, columnName, regexPattern, regexReplacement);
|
|
}
|
|
return;
|
|
}
|
|
|
|
String sql = template == MaskingTemplate.TEXT_PARTIAL || template == MaskingTemplate.RRN_PARTIAL
|
|
? """
|
|
BEGIN
|
|
DBMS_REDACT.ALTER_POLICY(
|
|
object_schema => ?, object_name => ?, policy_name => ?, action => %s,
|
|
column_name => ?, function_type => %s, regexp_pattern => ?, regexp_replace_string => ?
|
|
);
|
|
END;
|
|
""".formatted(actionConstant, functionConstant)
|
|
: """
|
|
BEGIN
|
|
DBMS_REDACT.ALTER_POLICY(
|
|
object_schema => ?, object_name => ?, policy_name => ?, action => %s,
|
|
column_name => ?, function_type => %s
|
|
);
|
|
END;
|
|
""".formatted(actionConstant, functionConstant);
|
|
if (regexPattern == null) {
|
|
jdbcTemplate.update(sql, dataCatalog.owner(), objectName, policyName, columnName);
|
|
} else {
|
|
jdbcTemplate.update(sql, dataCatalog.owner(), objectName, policyName, columnName, regexPattern, regexReplacement);
|
|
}
|
|
}
|
|
|
|
private void upsertColumnExpression(String objectName, String columnName, long columnId) {
|
|
String expressionName = "CBMR_" + columnId;
|
|
String expression = maskingExpression(columnId);
|
|
Integer count = jdbcTemplate.queryForObject("""
|
|
SELECT COUNT(*) FROM redaction_expressions WHERE policy_expression_name = ?
|
|
""", Integer.class, expressionName);
|
|
if (count != null && count > 0) {
|
|
jdbcTemplate.update("""
|
|
BEGIN
|
|
DBMS_REDACT.UPDATE_POLICY_EXPRESSION(
|
|
policy_expression_name => ?, expression => ?,
|
|
policy_expression_description => 'Mask unless trusted context allows original value'
|
|
);
|
|
END;
|
|
""", expressionName, expression);
|
|
} else {
|
|
jdbcTemplate.update("""
|
|
BEGIN
|
|
DBMS_REDACT.CREATE_POLICY_EXPRESSION(
|
|
policy_expression_name => ?, expression => ?,
|
|
policy_expression_description => 'Mask unless trusted context allows original value'
|
|
);
|
|
END;
|
|
""", expressionName, expression);
|
|
}
|
|
if (!expressionAppliedToColumn(expressionName, objectName, columnName)) {
|
|
jdbcTemplate.update("""
|
|
BEGIN
|
|
DBMS_REDACT.APPLY_POLICY_EXPR_TO_COL(
|
|
object_schema => ?, object_name => ?, column_name => ?, policy_expression_name => ?
|
|
);
|
|
END;
|
|
""", dataCatalog.owner(), objectName, columnName, expressionName);
|
|
}
|
|
}
|
|
|
|
static String maskingExpression(long columnId) {
|
|
return "SYS_CONTEXT('CB_AGENT_CTX', 'MR_" + columnId
|
|
+ "') IS NULL OR SYS_CONTEXT('CB_AGENT_CTX', 'MR_" + columnId + "') <> 'Y'";
|
|
}
|
|
|
|
private boolean expressionAppliedToColumn(String expressionName, String objectName, String columnName) {
|
|
Integer count = jdbcTemplate.queryForObject("""
|
|
SELECT COUNT(*)
|
|
FROM redaction_expressions
|
|
WHERE policy_expression_name = ?
|
|
AND object_name = ?
|
|
AND column_name = ?
|
|
""", Integer.class, expressionName, objectName, columnName);
|
|
return count != null && count > 0;
|
|
}
|
|
|
|
private String requiredColumnName(String value) {
|
|
String normalized = value == null ? "" : value.trim().toUpperCase();
|
|
if (!COLUMN_NAME.matcher(normalized).matches()) {
|
|
throw new AppException("동기화할 보호 컬럼명이 유효하지 않습니다.");
|
|
}
|
|
return normalized;
|
|
}
|
|
|
|
public record MaskingPolicySyncResult(
|
|
int disabledPolicies,
|
|
int enabledPolicies,
|
|
int addedColumns,
|
|
int modifiedColumns,
|
|
int droppedColumns
|
|
) {
|
|
|
|
public String summary() {
|
|
return "DB ASO 정책 동기화 완료: 비활성 " + disabledPolicies + "건, 활성 " + enabledPolicies
|
|
+ "건, 컬럼 추가 " + addedColumns + "건, 변경 " + modifiedColumns + "건, 해제 "
|
|
+ droppedColumns + "건";
|
|
}
|
|
}
|
|
}
|