Consolidate data access control backoffice updates
This commit is contained in:
@@ -0,0 +1,359 @@
|
||||
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.Collections;
|
||||
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 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;
|
||||
|
||||
public MaskingPolicySynchronizer(JdbcTemplate jdbcTemplate, MaskingRuleMapper mapper) {
|
||||
this.jdbcTemplate = jdbcTemplate;
|
||||
this.mapper = mapper;
|
||||
}
|
||||
|
||||
private static Map<String, String> managedPolicyMap() {
|
||||
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);
|
||||
}
|
||||
|
||||
public Set<String> managedObjectNames() {
|
||||
return MANAGED_POLICIES.keySet();
|
||||
}
|
||||
|
||||
public boolean isManagedObject(String objectName) {
|
||||
return objectName != null && MANAGED_POLICIES.containsKey(objectName.trim().toUpperCase(Locale.ROOT));
|
||||
}
|
||||
|
||||
static String managedPolicyName(String objectName) {
|
||||
return MANAGED_POLICIES.get(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 (OWNER.equalsIgnoreCase(rule.owner())
|
||||
&& rule.ruleEnabled()
|
||||
&& MANAGED_POLICIES.containsKey(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 (Map.Entry<String, String> policy : MANAGED_POLICIES.entrySet()) {
|
||||
String objectName = policy.getKey();
|
||||
String policyName = policy.getValue();
|
||||
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, 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, 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;
|
||||
""", OWNER, objectName, policyName);
|
||||
}
|
||||
|
||||
private void enablePolicy(String objectName, String policyName) {
|
||||
jdbcTemplate.update("""
|
||||
BEGIN
|
||||
DBMS_REDACT.ENABLE_POLICY(object_schema => ?, object_name => ?, policy_name => ?);
|
||||
END;
|
||||
""", 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;
|
||||
""", 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, OWNER, objectName, policyName, columnName);
|
||||
} else {
|
||||
jdbcTemplate.update(sql, 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, OWNER, objectName, policyName, columnName);
|
||||
} else {
|
||||
jdbcTemplate.update(sql, 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;
|
||||
""", 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 + "건";
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user