-
Notifications
You must be signed in to change notification settings - Fork 0
feat: PII attribute rule mapping system with extensible rule registry #73
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
afdb3a1
dba94f8
a15c990
6e1ce60
80d4e02
174a8f5
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,40 @@ | ||
| package com.opendatamask.application.service | ||
|
|
||
| import com.opendatamask.domain.model.HashRule | ||
| import com.opendatamask.domain.model.PIIMaskingRule | ||
| import com.opendatamask.domain.model.PartialMaskRule | ||
| import com.opendatamask.domain.model.PassThroughRule | ||
| import com.opendatamask.domain.model.RedactRule | ||
| import com.opendatamask.domain.port.output.RuleRegistryPort | ||
| import org.springframework.stereotype.Service | ||
| import java.util.concurrent.ConcurrentHashMap | ||
|
|
||
| // Holds all known PIIMaskingRule implementations. | ||
| // Built-in non-parameterized rules are registered at construction time. | ||
| // RegexRule is not registered as a default instance because it requires caller-supplied | ||
| // pattern and replacement parameters; PIIMaskingService constructs RegexRule instances | ||
| // directly from piiRuleParams. To override regex behavior globally, registerCustomRule() | ||
| // can be used with a concrete PIIMaskingRule that has ruleId "regex" or any custom ID. | ||
| // Call registerCustomRule() to inject additional business-specific rules at runtime. | ||
| @Service | ||
| class DefaultRuleRegistry : RuleRegistryPort { | ||
|
|
||
| private val registry = ConcurrentHashMap<String, PIIMaskingRule>() | ||
|
|
||
| init { | ||
| register(PassThroughRule()) | ||
| register(RedactRule()) | ||
| register(PartialMaskRule()) | ||
| register(HashRule()) | ||
| } | ||
|
|
||
| private fun register(rule: PIIMaskingRule) { | ||
| registry[rule.ruleId] = rule | ||
| } | ||
|
|
||
| override fun getRule(ruleId: String): PIIMaskingRule? = registry[ruleId] | ||
|
|
||
| override fun getAllRuleIds(): Set<String> = registry.keys.toSet() | ||
|
|
||
| override fun registerCustomRule(rule: PIIMaskingRule) = register(rule) | ||
| } | ||
| Original file line number | Diff line number | Diff line change | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| @@ -0,0 +1,136 @@ | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| package com.opendatamask.application.service | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| import com.fasterxml.jackson.module.kotlin.readValue | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| import com.opendatamask.domain.model.CustomDataMapping | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| import com.opendatamask.domain.model.HashRule | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| import com.opendatamask.domain.model.MappingAction | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| import com.opendatamask.domain.model.MaskingStrategy | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| import com.opendatamask.domain.model.PartialMaskRule | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| import com.opendatamask.domain.model.RegexRule | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| import com.opendatamask.domain.port.output.CustomDataMappingPort | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| import com.opendatamask.domain.port.output.RuleRegistryPort | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| import org.slf4j.LoggerFactory | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| import org.springframework.stereotype.Service | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| // Transforms source data rows by applying the PIIMaskingRule selected by each | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| // CustomDataMapping entry. Columns without a mapping pass through unchanged. | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| @Service | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| class PIIMaskingService( | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| private val ruleRegistry: RuleRegistryPort, | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| private val customDataMappingPort: CustomDataMappingPort | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| ) { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| private val logger = LoggerFactory.getLogger(PIIMaskingService::class.java) | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| private val mapper = jacksonObjectMapper() | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| // Load mappings once and apply to every row in a batch. Prefer this over | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| // the convenience overload (applyMappings with workspaceId/connectionId/tableName/row) | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| // to avoid N+1 persistence queries when processing multiple rows for the same table. | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| fun applyMappingsToRows( | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| workspaceId: Long, | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| connectionId: Long, | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| tableName: String, | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| rows: List<Map<String, Any?>> | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| ): List<Map<String, Any?>> { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| val mappings = loadMappings(workspaceId, connectionId, tableName) | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| if (mappings.isEmpty()) return rows | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| return rows.map { row -> applyMappings(mappings, row) } | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| // Load the per-column mapping index for a table; result can be reused across rows. | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| fun loadMappings( | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| workspaceId: Long, | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| connectionId: Long, | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| tableName: String | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| ): Map<String, CustomDataMapping> = | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| customDataMappingPort | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| .findByWorkspaceIdAndConnectionIdAndTableName(workspaceId, connectionId, tableName) | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| .associateBy { it.columnName.lowercase() } | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| // Apply pre-fetched mappings to a single row. Column name matching is case-insensitive. | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| fun applyMappings( | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| mappings: Map<String, CustomDataMapping>, | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| row: Map<String, Any?> | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| ): Map<String, Any?> { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| if (mappings.isEmpty()) return row | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| return row.mapValues { (column, value) -> | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| val mapping = mappings[column.lowercase()] ?: return@mapValues value | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| when (mapping.action) { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| MappingAction.MIGRATE_AS_IS -> value | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| MappingAction.MASK -> applyStrategy(mapping, value) | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| // Convenience overload that fetches mappings from persistence on each call. | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| // Use applyMappingsToRows() instead when processing multiple rows for the same table. | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| fun applyMappings( | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| workspaceId: Long, | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| connectionId: Long, | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| tableName: String, | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| row: Map<String, Any?> | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| ): Map<String, Any?> = | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| applyMappings(loadMappings(workspaceId, connectionId, tableName), row) | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| private fun applyStrategy(mapping: CustomDataMapping, value: Any?): Any? { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| val params = parseParams(mapping.piiRuleParams) ?: return value | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| // Allow a custom rule to be invoked by specifying its ruleId in params. | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| // This enables runtime-registered rules to be used from any mapping strategy slot. | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| val customRuleId = params["ruleId"]?.takeIf { it.isNotBlank() } | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| if (customRuleId != null) { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| val customRule = ruleRegistry.getRule(customRuleId) | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| if (customRule != null) return customRule.mask(value) | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| logger.warn("PII rule '{}' not found in registry — passing value through unchanged", customRuleId) | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| return value | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| return when (mapping.maskingStrategy) { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| MaskingStrategy.NULL -> null | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| MaskingStrategy.REDACT -> ruleRegistry.getRule("redact")?.mask(value) ?: "[REDACTED]" | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| MaskingStrategy.HASH -> { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| val salt = params["salt"] ?: "" | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| HashRule(salt).mask(value) | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| MaskingStrategy.PARTIAL_MASK -> { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| val keepFirst = params["keepFirst"]?.toIntOrNull() ?: 0 | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| val keepLast = params["keepLast"]?.toIntOrNull() ?: 4 | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| val maskChar = params["maskChar"]?.firstOrNull() ?: '*' | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| PartialMaskRule(keepFirst, keepLast, maskChar).mask(value) | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| MaskingStrategy.REGEX -> { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| val pattern = params["pattern"] | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| val replacement = params["replacement"] | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| if (pattern.isNullOrBlank() || replacement == null) { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| logger.warn( | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| "REGEX strategy for column '{}' is missing 'pattern' or 'replacement' in piiRuleParams — passing value through unchanged", | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| mapping.columnName | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| ) | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| return value | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| runCatching { RegexRule(pattern, replacement).mask(value) } | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| .getOrElse { cause -> | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| logger.warn( | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| "REGEX strategy for column '{}' has invalid pattern '{}' — passing value through unchanged: {}", | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| mapping.columnName, pattern, cause.message | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| ) | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| value | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
Comment on lines
+101
to
+119
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| // FAKE strategy is handled upstream by the GeneratorService; return value unchanged. | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| MaskingStrategy.FAKE -> value | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| null -> value | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
Comment on lines
+88
to
+125
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| return when (mapping.maskingStrategy) { | |
| MaskingStrategy.NULL -> null | |
| MaskingStrategy.REDACT -> ruleRegistry.getRule("redact")?.mask(value) ?: "[REDACTED]" | |
| MaskingStrategy.HASH -> { | |
| val salt = params["salt"] ?: "" | |
| HashRule(salt).mask(value) | |
| } | |
| MaskingStrategy.PARTIAL_MASK -> { | |
| val keepFirst = params["keepFirst"]?.toIntOrNull() ?: 0 | |
| val keepLast = params["keepLast"]?.toIntOrNull() ?: 4 | |
| val maskChar = params["maskChar"]?.firstOrNull() ?: '*' | |
| PartialMaskRule(keepFirst, keepLast, maskChar).mask(value) | |
| } | |
| MaskingStrategy.REGEX -> { | |
| val pattern = params["pattern"] ?: ".*" | |
| val replacement = params["replacement"] ?: "" | |
| RegexRule(pattern, replacement).mask(value) | |
| } | |
| // FAKE strategy is handled upstream by the GeneratorService; return value unchanged. | |
| MaskingStrategy.FAKE -> value | |
| null -> value | |
| } | |
| } | |
| params["ruleId"] | |
| ?.takeIf { it.isNotBlank() } | |
| ?.let { ruleId -> | |
| applyRegistryRule(ruleId, value)?.let { return it } | |
| } | |
| return when (mapping.maskingStrategy) { | |
| MaskingStrategy.NULL -> null | |
| MaskingStrategy.REDACT -> applyRegistryRule("redact", value) ?: "[REDACTED]" | |
| MaskingStrategy.HASH -> applyRegistryRule("hash", value) ?: applyHashRule(params, value) | |
| MaskingStrategy.PARTIAL_MASK -> applyRegistryRule("partial_mask", value) ?: applyPartialMaskRule(params, value) | |
| MaskingStrategy.REGEX -> applyRegistryRule("regex", value) ?: applyRegexRule(params, value) | |
| // FAKE strategy is handled upstream by the GeneratorService; return value unchanged. | |
| MaskingStrategy.FAKE -> value | |
| null -> value | |
| } | |
| } | |
| private fun applyRegistryRule(ruleId: String, value: Any?): Any? = | |
| ruleRegistry.getRule(ruleId)?.mask(value) | |
| private fun applyHashRule(params: Map<String, String>, value: Any?): Any? { | |
| val salt = params["salt"] ?: "" | |
| return HashRule(salt).mask(value) | |
| } | |
| private fun applyPartialMaskRule(params: Map<String, String>, value: Any?): Any? { | |
| val keepFirst = params["keepFirst"]?.toIntOrNull() ?: 0 | |
| val keepLast = params["keepLast"]?.toIntOrNull() ?: 4 | |
| val maskChar = params["maskChar"]?.firstOrNull() ?: '*' | |
| return PartialMaskRule(keepFirst, keepLast, maskChar).mask(value) | |
| } | |
| private fun applyRegexRule(params: Map<String, String>, value: Any?): Any? { | |
| val pattern = params["pattern"] ?: ".*" | |
| val replacement = params["replacement"] ?: "" | |
| return RegexRule(pattern, replacement).mask(value) | |
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,73 @@ | ||
| package com.opendatamask.domain.model | ||
|
|
||
| import java.security.MessageDigest | ||
|
|
||
| // Core interface every PII masking rule must implement. | ||
| // Each implementation must carry a stable, unique ruleId that identifies it | ||
| // in the registry and in JSON mapping configurations. | ||
| interface PIIMaskingRule { | ||
| val ruleId: String | ||
| fun mask(input: Any?): Any? | ||
| } | ||
|
|
||
| // Sealed base class for built-in rules so exhaustive when-expressions can be used | ||
| // when dispatch on rule type is needed in the application layer. | ||
| sealed class BuiltInPIIRule(override val ruleId: String) : PIIMaskingRule | ||
|
|
||
| // Passes the value through unchanged. Used as the default for unmapped columns. | ||
| class PassThroughRule : BuiltInPIIRule("pass_through") { | ||
| override fun mask(input: Any?): Any? = input | ||
| } | ||
|
|
||
| // Replaces every non-null value with the literal token [REDACTED]. | ||
| class RedactRule : BuiltInPIIRule("redact") { | ||
| override fun mask(input: Any?): Any? = if (input == null) null else "[REDACTED]" | ||
| } | ||
|
|
||
| // Masks the middle characters of a string, preserving a configurable number of | ||
| // leading and trailing characters. Useful for partial credit-card or email masking. | ||
| // When the input is shorter than keepFirst + keepLast, the original value is returned unchanged. | ||
| // Negative values for keepFirst or keepLast are treated as zero. | ||
| class PartialMaskRule( | ||
| val keepFirst: Int = 0, | ||
| val keepLast: Int = 4, | ||
| val maskChar: Char = '*' | ||
| ) : BuiltInPIIRule("partial_mask") { | ||
| override fun mask(input: Any?): Any? { | ||
| if (input == null) return null | ||
| val str = input.toString() | ||
| val safeKeepFirst = keepFirst.coerceAtLeast(0) | ||
| val safeKeepLast = keepLast.coerceAtLeast(0) | ||
| if (str.length <= safeKeepFirst + safeKeepLast) return str | ||
| val maskLen = str.length - safeKeepFirst - safeKeepLast | ||
| return str.take(safeKeepFirst) + maskChar.toString().repeat(maskLen) + str.takeLast(safeKeepLast) | ||
| } | ||
| } | ||
|
|
||
| // Produces a deterministic SHA-256 hex digest of the input, with an optional salt. | ||
| class HashRule(val salt: String = "") : BuiltInPIIRule("hash") { | ||
| override fun mask(input: Any?): Any? { | ||
| if (input == null) return null | ||
| val digest = MessageDigest.getInstance("SHA-256") | ||
| val bytes = digest.digest(("$salt${input}").toByteArray(Charsets.UTF_8)) | ||
| return bytes.joinToString("") { "%02x".format(it) } | ||
| } | ||
| } | ||
|
|
||
| // Applies a user-supplied regular expression to the string representation of the value, | ||
| // replacing every match with the given replacement string. | ||
| // Throws IllegalArgumentException at construction time if pattern is not a valid regex. | ||
| class RegexRule(val pattern: String, val replacement: String) : BuiltInPIIRule("regex") { | ||
| private val compiledRegex: Regex = runCatching { Regex(pattern) } | ||
| .getOrElse { cause -> | ||
| throw IllegalArgumentException( | ||
| "Invalid regex pattern: \"$pattern\"", | ||
| cause | ||
| ) | ||
| } | ||
|
|
||
| override fun mask(input: Any?): Any? { | ||
| if (input == null) return null | ||
| return compiledRegex.replace(input.toString(), replacement) | ||
| } | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,12 @@ | ||
| package com.opendatamask.domain.port.output | ||
|
|
||
| import com.opendatamask.domain.model.PIIMaskingRule | ||
|
|
||
| // Driven port: a registry that maps rule IDs to PIIMaskingRule implementations. | ||
| // The default implementation is provided by DefaultRuleRegistry in the application layer. | ||
| // Custom rules can be registered at runtime via registerCustomRule(). | ||
| interface RuleRegistryPort { | ||
| fun getRule(ruleId: String): PIIMaskingRule? | ||
| fun getAllRuleIds(): Set<String> | ||
| fun registerCustomRule(rule: PIIMaskingRule) | ||
| } |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
DefaultRuleRegistrydoes not register aregexrule ID, even thoughRegexRuleis modeled as a built-in rule (and docs/UI may expect discoverability viagetAllRuleIds()). Either includeregexin the registry (which likely requires changing the registry to handle parameterized rules) or document/rename the registry so it’s clearly only for non-parameterized/custom rules.