package com.cloudhandson.vpdbackoffice.service; import com.cloudhandson.vpdbackoffice.domain.audit.AuditEvent; 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.MaskingTemplate; import com.cloudhandson.vpdbackoffice.domain.masking.UserMaskingRule; import com.cloudhandson.vpdbackoffice.domain.protectedobject.ProtectedObject; import com.cloudhandson.vpdbackoffice.mapper.MaskingRuleMapper; import com.cloudhandson.vpdbackoffice.mapper.UserMapper; import java.util.List; import java.util.Locale; import java.util.Set; import java.util.regex.Pattern; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @Service public class MaskingRuleService { private static final Pattern RULE_CODE = Pattern.compile("[A-Z][A-Z0-9_]{2,63}"); private static final Set DECISIONS = Set.of("MASK", "UNMASK"); private final MaskingRuleMapper mapper; private final UserMapper userMapper; private final ProtectedObjectService protectedObjectService; private final AuditService auditService; private final MaskingPolicySynchronizer maskingPolicySynchronizer; public MaskingRuleService( MaskingRuleMapper mapper, UserMapper userMapper, ProtectedObjectService protectedObjectService, AuditService auditService, MaskingPolicySynchronizer maskingPolicySynchronizer ) { this.mapper = mapper; this.userMapper = userMapper; this.protectedObjectService = protectedObjectService; this.auditService = auditService; this.maskingPolicySynchronizer = maskingPolicySynchronizer; } public List findAllRules() { return mapper.findAllRules(); } public List findEnabledRules() { return mapper.findEnabledRules(); } public List findColumnRules() { return mapper.findColumnRules(); } /** Reads the actual Oracle Data Redaction state for the managed Smilegate game-data objects. */ public List findPolicyStatuses() { return mapper.findPolicyStatuses(); } public Set managedObjectNames() { return maskingPolicySynchronizer.managedObjectNames(); } public List findUserRules() { return mapper.findUserRules(); } @Transactional public void createRule(MaskingRuleCreateCommand command) { String code = normalizeCode(command.ruleCode()); String name = normalizeRequired(command.ruleName(), 100, "규칙명"); String templateCode = normalizeTemplate(command.templateCode()); String description = normalizeOptional(command.description(), 400, "설명"); if (mapper.findRuleByCode(code) != null) { throw new AppException("이미 등록된 컬럼 마스킹 규칙 코드입니다: " + code); } long ruleId = mapper.nextRuleId(); mapper.insertRule(ruleId, new MaskingRuleCreateCommand(code, name, templateCode, description)); auditService.record(new AuditEvent("MASKING_RULE_CREATED", null, null, "SUCCESS", null, null, "ruleId=" + ruleId + ",code=" + code + ",template=" + templateCode)); } @Transactional public MaskingPolicySynchronizer.MaskingPolicySyncResult setRuleActive(long ruleId, boolean active) { if (mapper.updateRuleActive(ruleId, active ? "Y" : "N") == 0) { throw new AppException("컬럼 마스킹 규칙을 찾을 수 없습니다."); } auditService.record(new AuditEvent("MASKING_RULE_ACTIVE_CHANGED", null, null, "SUCCESS", null, null, "ruleId=" + ruleId + ",active=" + active)); return synchronizeDatabasePolicies(); } @Transactional public MaskingPolicySynchronizer.MaskingPolicySyncResult assignRuleToColumn(long columnId, long ruleId) { var column = protectedObjectService.findColumn(columnId); if (column == null) { throw new AppException("보호 컬럼을 찾을 수 없습니다."); } if (!column.sensitive()) { throw new AppException("민감 표시 보호 컬럼에만 컬럼 마스킹 규칙을 연결할 수 있습니다."); } MaskingRule rule = requireEnabledRule(ruleId); mapper.upsertColumnRule(columnId, ruleId); auditService.record(new AuditEvent("COLUMN_MASKING_RULE_ASSIGNED", null, column.objectId(), "SUCCESS", null, null, "columnId=" + columnId + ",rule=" + rule.ruleCode())); return synchronizeDatabasePolicies(); } @Transactional public MaskingPolicySynchronizer.MaskingPolicySyncResult addTargetColumn(long objectId, String columnName) { ProtectedObject object = protectedObjectService.assertEnabled(objectId); if (!maskingPolicySynchronizer.isManagedObject(object.objectName())) { throw new AppException("ASO 마스킹 정책 관리 대상 객체가 아닙니다: " + object.objectName()); } var column = protectedObjectService.addSensitiveColumnTarget(objectId, columnName); auditService.record(new AuditEvent("MASKING_TARGET_COLUMN_REGISTERED", null, objectId, "SUCCESS", null, null, object.objectName() + "." + column.columnName())); return synchronizeDatabasePolicies(); } @Transactional public MaskingPolicySynchronizer.MaskingPolicySyncResult removeRuleFromColumn(long columnId) { mapper.deleteUserRulesForColumn(columnId); if (mapper.deleteColumnRule(columnId) == 0) { throw new AppException("해제할 컬럼 마스킹 규칙을 찾을 수 없습니다."); } auditService.record(new AuditEvent("COLUMN_MASKING_RULE_REMOVED", null, null, "SUCCESS", null, null, "columnId=" + columnId)); return synchronizeDatabasePolicies(); } /** * Reconciles the current metadata with Oracle Data Redaction. This is exposed for the one-time * repair of settings saved before automatic synchronization was introduced. */ @Transactional public MaskingPolicySynchronizer.MaskingPolicySyncResult synchronizeDatabasePolicies() { MaskingPolicySynchronizer.MaskingPolicySyncResult result = maskingPolicySynchronizer.synchronize(); auditService.record(new AuditEvent("MASKING_POLICY_SYNCHRONIZED", null, null, "SUCCESS", null, null, "disabled=" + result.disabledPolicies() + ",enabled=" + result.enabledPolicies() + ",added=" + result.addedColumns() + ",modified=" + result.modifiedColumns() + ",dropped=" + result.droppedColumns())); return result; } @Transactional public void assignUserRule(long userId, long columnId, String decision) { if (userMapper.findById(userId) == null) { throw new AppException("사용자를 찾을 수 없습니다."); } ColumnMaskingRule columnRule = mapper.findColumnRule(columnId); if (columnRule == null || !columnRule.ruleEnabled()) { throw new AppException("먼저 활성 컬럼 마스킹 규칙을 민감 컬럼에 연결하세요."); } String normalizedDecision = normalizeDecision(decision); mapper.upsertUserRule(userId, columnId, normalizedDecision); auditService.record(new AuditEvent("USER_MASKING_RULE_ASSIGNED", userId, columnRule.objectId(), "SUCCESS", null, null, "columnId=" + columnId + ",decision=" + normalizedDecision)); } @Transactional public void removeUserRule(long userId, long columnId) { if (mapper.deleteUserRule(userId, columnId) == 0) { throw new AppException("해제할 사용자별 컬럼 마스킹 규칙을 찾을 수 없습니다."); } auditService.record(new AuditEvent("USER_MASKING_RULE_REMOVED", userId, null, "SUCCESS", null, null, "columnId=" + columnId)); } private MaskingRule requireEnabledRule(long ruleId) { MaskingRule rule = mapper.findRuleById(ruleId); if (rule == null || !rule.enabled()) { throw new AppException("활성 컬럼 마스킹 규칙을 선택하세요."); } return rule; } private String normalizeCode(String value) { String normalized = normalizeRequired(value, 64, "규칙 코드").toUpperCase(Locale.ROOT); if (!RULE_CODE.matcher(normalized).matches()) { throw new AppException("규칙 코드는 영문 대문자·숫자·밑줄로 3~64자여야 합니다."); } return normalized; } private String normalizeTemplate(String value) { try { return MaskingTemplate.from(normalizeRequired(value, 30, "마스킹 템플릿")).code(); } catch (IllegalArgumentException exception) { throw new AppException(exception.getMessage()); } } private String normalizeDecision(String value) { String normalized = normalizeRequired(value, 10, "사용자별 적용 방식").toUpperCase(Locale.ROOT); if (!DECISIONS.contains(normalized)) { throw new AppException("사용자별 적용 방식은 MASK 또는 UNMASK만 가능합니다."); } return normalized; } private String normalizeRequired(String value, int maxLength, String label) { String normalized = value == null ? "" : value.trim(); if (normalized.isEmpty()) { throw new AppException(label + "은(는) 필수입니다."); } if (normalized.length() > maxLength) { throw new AppException(label + "은(는) " + maxLength + "자 이내여야 합니다."); } return normalized; } private String normalizeOptional(String value, int maxLength, String label) { String normalized = value == null ? "" : value.trim(); if (normalized.length() > maxLength) { throw new AppException(label + "은(는) " + maxLength + "자 이내여야 합니다."); } return normalized.isEmpty() ? null : normalized; } }