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 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. * *

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.

*/ public MaskingPolicySyncResult synchronize() { Map> 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 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 desiredColumns = desired.stream() .map(ColumnMaskingRule::columnName) .map(this::requiredColumnName) .collect(LinkedHashSet::new, Set::add, Set::addAll); Set 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 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 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 + "건"; } } }