Files
vpd-permission-poc/src/main/java/com/cloudhandson/vpdbackoffice/service/MaskingPolicySynchronizer.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 + "";
}
}
}