From afdb3a1898c70a593013d6f2379712c8d5214a76 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 16 Apr 2026 16:08:10 +0000 Subject: [PATCH 1/6] Initial plan From dba94f8b3f1a51c8185a5e953a7d1c38d78d124e Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 16 Apr 2026 16:24:47 +0000 Subject: [PATCH 2/6] feat: add PII attribute rule mapping system with RedactRule, PartialMaskRule, HashRule, RegexRule Agent-Logs-Url: https://github.com/MaximumTrainer/OpenDataMask/sessions/1f3e4622-ad17-4d5f-b60f-195b61c0a703 Co-authored-by: MaximumTrainer <1376575+MaximumTrainer@users.noreply.github.com> --- .../service/CustomDataMappingService.kt | 8 +- .../service/DefaultRuleRegistry.kt | 36 +++ .../application/service/JobService.kt | 18 +- .../application/service/PIIMaskingService.kt | 76 +++++++ .../domain/model/CustomDataMapping.kt | 5 +- .../domain/model/PIIMaskingRule.kt | 60 +++++ .../port/input/dto/CustomDataMappingDto.kt | 13 +- .../domain/port/output/RuleRegistryPort.kt | 12 + .../service/DefaultRuleRegistryTest.kt | 75 ++++++ .../application/service/JobServiceTest.kt | 14 ++ .../service/PIIMaskingServiceTest.kt | 215 ++++++++++++++++++ .../domain/model/PIIMaskingRuleTest.kt | 133 +++++++++++ docs/pii-masking-rules-sample.json | 44 ++++ docs/user-guide.md | 89 +++++++- frontend/src/types/index.ts | 10 +- 15 files changed, 796 insertions(+), 12 deletions(-) create mode 100644 backend/src/main/kotlin/com/opendatamask/application/service/DefaultRuleRegistry.kt create mode 100644 backend/src/main/kotlin/com/opendatamask/application/service/PIIMaskingService.kt create mode 100644 backend/src/main/kotlin/com/opendatamask/domain/model/PIIMaskingRule.kt create mode 100644 backend/src/main/kotlin/com/opendatamask/domain/port/output/RuleRegistryPort.kt create mode 100644 backend/src/test/kotlin/com/opendatamask/application/service/DefaultRuleRegistryTest.kt create mode 100644 backend/src/test/kotlin/com/opendatamask/application/service/PIIMaskingServiceTest.kt create mode 100644 backend/src/test/kotlin/com/opendatamask/domain/model/PIIMaskingRuleTest.kt create mode 100644 docs/pii-masking-rules-sample.json diff --git a/backend/src/main/kotlin/com/opendatamask/application/service/CustomDataMappingService.kt b/backend/src/main/kotlin/com/opendatamask/application/service/CustomDataMappingService.kt index 581fb4e..34ccaf2 100644 --- a/backend/src/main/kotlin/com/opendatamask/application/service/CustomDataMappingService.kt +++ b/backend/src/main/kotlin/com/opendatamask/application/service/CustomDataMappingService.kt @@ -27,7 +27,8 @@ class CustomDataMappingService( columnName = request.columnName, action = request.action, maskingStrategy = if (request.action == MappingAction.MASK) request.maskingStrategy else null, - fakeGeneratorType = if (request.action == MappingAction.MASK) request.fakeGeneratorType else null + fakeGeneratorType = if (request.action == MappingAction.MASK) request.fakeGeneratorType else null, + piiRuleParams = if (request.action == MappingAction.MASK) request.piiRuleParams else null ) return customDataMappingRepository.save(mapping).toResponse() } @@ -64,6 +65,7 @@ class CustomDataMappingService( mapping.action = request.action mapping.maskingStrategy = if (request.action == MappingAction.MASK) request.maskingStrategy else null mapping.fakeGeneratorType = if (request.action == MappingAction.MASK) request.fakeGeneratorType else null + mapping.piiRuleParams = if (request.action == MappingAction.MASK) request.piiRuleParams else null return customDataMappingRepository.save(mapping).toResponse() } @@ -90,7 +92,8 @@ class CustomDataMappingService( columnName = entry.columnName, action = entry.action, maskingStrategy = if (entry.action == MappingAction.MASK) entry.maskingStrategy else null, - fakeGeneratorType = if (entry.action == MappingAction.MASK) entry.fakeGeneratorType else null + fakeGeneratorType = if (entry.action == MappingAction.MASK) entry.fakeGeneratorType else null, + piiRuleParams = if (entry.action == MappingAction.MASK) entry.piiRuleParams else null ) } return mappings.map { customDataMappingRepository.save(it).toResponse() } @@ -129,6 +132,7 @@ class CustomDataMappingService( action = action, maskingStrategy = maskingStrategy, fakeGeneratorType = fakeGeneratorType, + piiRuleParams = piiRuleParams, createdAt = createdAt, updatedAt = updatedAt ) diff --git a/backend/src/main/kotlin/com/opendatamask/application/service/DefaultRuleRegistry.kt b/backend/src/main/kotlin/com/opendatamask/application/service/DefaultRuleRegistry.kt new file mode 100644 index 0000000..53041fb --- /dev/null +++ b/backend/src/main/kotlin/com/opendatamask/application/service/DefaultRuleRegistry.kt @@ -0,0 +1,36 @@ +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 rules are registered at construction time. +// Call registerCustomRule() to inject additional business-specific rules at runtime. +@Service +class DefaultRuleRegistry : RuleRegistryPort { + + private val registry = ConcurrentHashMap() + + 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 = registry.keys.toSet() + + override fun registerCustomRule(rule: PIIMaskingRule) = register(rule) +} diff --git a/backend/src/main/kotlin/com/opendatamask/application/service/JobService.kt b/backend/src/main/kotlin/com/opendatamask/application/service/JobService.kt index 7d3c5c1..90ccfec 100644 --- a/backend/src/main/kotlin/com/opendatamask/application/service/JobService.kt +++ b/backend/src/main/kotlin/com/opendatamask/application/service/JobService.kt @@ -37,7 +37,8 @@ class JobService( private val destinationSchemaService: DestinationSchemaService, private val postJobActionService: PostJobActionService, private val schemaChangeService: SchemaChangeService, - private val webhookService: WebhookService + private val webhookService: WebhookService, + private val piiMaskingService: PIIMaskingService ) : JobUseCase { @org.springframework.beans.factory.annotation.Autowired(required = false) private var subsetExecutionService: SubsetExecutionService? = null @@ -178,7 +179,7 @@ class JobService( tableConfig.tableName, selectedAttrs ) - processTable(job.id, tableConfig, sourceConnector, destConnector, subsetRows) + processTable(job.id, tableConfig, sourceConnector, destConnector, subsetRows, job.workspaceId, sourceConn.id) } updateJobStatus(job, JobStatus.COMPLETED) @@ -206,7 +207,9 @@ class JobService( tableConfig: TableConfiguration, sourceConnector: DatabaseConnector, destConnector: DatabaseConnector, - preComputedRows: Map>> = emptyMap() + preComputedRows: Map>> = emptyMap(), + workspaceId: Long = 0L, + sourceConnectionId: Long = 0L ) { addLog(jobId, "Processing table: ${tableConfig.tableName} (mode: ${tableConfig.mode})", LogLevel.INFO) @@ -217,7 +220,14 @@ class JobService( TableMode.PASSTHROUGH -> { val data = sourceConnector.fetchData(tableConfig.tableName, tableConfig.rowLimit?.toInt(), null, selectedAttrs) addLog(jobId, "Fetched ${data.size} rows from ${tableConfig.tableName}", LogLevel.INFO) - val written = destConnector.writeData(tableConfig.tableName, data) + val transformed = if (workspaceId != 0L && sourceConnectionId != 0L) { + data.map { row -> + piiMaskingService.applyMappings(workspaceId, sourceConnectionId, tableConfig.tableName, row) + } + } else { + data + } + val written = destConnector.writeData(tableConfig.tableName, transformed) addLog(jobId, "Wrote $written rows to destination ${tableConfig.tableName}", LogLevel.INFO) } TableMode.MASK -> { diff --git a/backend/src/main/kotlin/com/opendatamask/application/service/PIIMaskingService.kt b/backend/src/main/kotlin/com/opendatamask/application/service/PIIMaskingService.kt new file mode 100644 index 0000000..94a56bb --- /dev/null +++ b/backend/src/main/kotlin/com/opendatamask/application/service/PIIMaskingService.kt @@ -0,0 +1,76 @@ +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.springframework.stereotype.Service + +// Transforms a source data row 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 mapper = jacksonObjectMapper() + + // Apply all active CustomDataMappings for the given workspace/connection/table to a single row. + fun applyMappings( + workspaceId: Long, + connectionId: Long, + tableName: String, + row: Map + ): Map { + val mappings = customDataMappingPort + .findByWorkspaceIdAndConnectionIdAndTableName(workspaceId, connectionId, tableName) + .associateBy { it.columnName.lowercase() } + + 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) + } + } + } + + private fun applyStrategy(mapping: CustomDataMapping, value: Any?): Any? { + val params = parseParams(mapping.piiRuleParams) + 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 + } + } + + private fun parseParams(json: String?): Map { + if (json.isNullOrBlank()) return emptyMap() + return runCatching { mapper.readValue>(json) }.getOrDefault(emptyMap()) + } +} diff --git a/backend/src/main/kotlin/com/opendatamask/domain/model/CustomDataMapping.kt b/backend/src/main/kotlin/com/opendatamask/domain/model/CustomDataMapping.kt index 4100d48..2bbe61a 100644 --- a/backend/src/main/kotlin/com/opendatamask/domain/model/CustomDataMapping.kt +++ b/backend/src/main/kotlin/com/opendatamask/domain/model/CustomDataMapping.kt @@ -8,7 +8,7 @@ enum class MappingAction { } enum class MaskingStrategy { - FAKE, HASH, NULL + FAKE, HASH, NULL, REDACT, PARTIAL_MASK, REGEX } @Entity @@ -50,6 +50,9 @@ class CustomDataMapping( @Column var fakeGeneratorType: GeneratorType? = null, + @Column(name = "pii_rule_params", columnDefinition = "TEXT") + var piiRuleParams: String? = null, + @Column(nullable = false) var createdAt: LocalDateTime = LocalDateTime.now(), diff --git a/backend/src/main/kotlin/com/opendatamask/domain/model/PIIMaskingRule.kt b/backend/src/main/kotlin/com/opendatamask/domain/model/PIIMaskingRule.kt new file mode 100644 index 0000000..44fc32e --- /dev/null +++ b/backend/src/main/kotlin/com/opendatamask/domain/model/PIIMaskingRule.kt @@ -0,0 +1,60 @@ +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. +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 maskLen = (str.length - keepFirst - keepLast).coerceAtLeast(0) + return str.take(keepFirst) + maskChar.toString().repeat(maskLen) + str.takeLast(keepLast.coerceAtMost(str.length)) + } +} + +// 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. +class RegexRule(val pattern: String, val replacement: String) : BuiltInPIIRule("regex") { + private val compiledRegex = Regex(pattern) + override fun mask(input: Any?): Any? { + if (input == null) return null + return compiledRegex.replace(input.toString(), replacement) + } +} diff --git a/backend/src/main/kotlin/com/opendatamask/domain/port/input/dto/CustomDataMappingDto.kt b/backend/src/main/kotlin/com/opendatamask/domain/port/input/dto/CustomDataMappingDto.kt index fc6cc97..88d1107 100644 --- a/backend/src/main/kotlin/com/opendatamask/domain/port/input/dto/CustomDataMappingDto.kt +++ b/backend/src/main/kotlin/com/opendatamask/domain/port/input/dto/CustomDataMappingDto.kt @@ -23,7 +23,13 @@ data class CustomDataMappingRequest( val maskingStrategy: MaskingStrategy? = null, - val fakeGeneratorType: GeneratorType? = null + val fakeGeneratorType: GeneratorType? = null, + + // JSON string carrying strategy-specific parameters. + // HashRule: {"salt":"..."} + // PartialMaskRule: {"keepFirst":"2","keepLast":"4","maskChar":"*"} + // RegexRule: {"pattern":"\\d","replacement":"#"} + val piiRuleParams: String? = null ) data class BulkCustomDataMappingRequest( @@ -46,7 +52,9 @@ data class BulkCustomDataMappingRequest( val maskingStrategy: MaskingStrategy? = null, - val fakeGeneratorType: GeneratorType? = null + val fakeGeneratorType: GeneratorType? = null, + + val piiRuleParams: String? = null ) } @@ -59,6 +67,7 @@ data class CustomDataMappingResponse( val action: MappingAction, val maskingStrategy: MaskingStrategy?, val fakeGeneratorType: GeneratorType?, + val piiRuleParams: String?, val createdAt: LocalDateTime, val updatedAt: LocalDateTime ) diff --git a/backend/src/main/kotlin/com/opendatamask/domain/port/output/RuleRegistryPort.kt b/backend/src/main/kotlin/com/opendatamask/domain/port/output/RuleRegistryPort.kt new file mode 100644 index 0000000..5cd1e11 --- /dev/null +++ b/backend/src/main/kotlin/com/opendatamask/domain/port/output/RuleRegistryPort.kt @@ -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 + fun registerCustomRule(rule: PIIMaskingRule) +} diff --git a/backend/src/test/kotlin/com/opendatamask/application/service/DefaultRuleRegistryTest.kt b/backend/src/test/kotlin/com/opendatamask/application/service/DefaultRuleRegistryTest.kt new file mode 100644 index 0000000..576310e --- /dev/null +++ b/backend/src/test/kotlin/com/opendatamask/application/service/DefaultRuleRegistryTest.kt @@ -0,0 +1,75 @@ +package com.opendatamask.application.service + +import com.opendatamask.domain.model.PIIMaskingRule +import com.opendatamask.domain.model.RedactRule +import org.junit.jupiter.api.Assertions.* +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test + +class DefaultRuleRegistryTest { + + private lateinit var registry: DefaultRuleRegistry + + @BeforeEach + fun setUp() { + registry = DefaultRuleRegistry() + } + + @Test + fun `registry contains all built-in rules after initialisation`() { + val ids = registry.getAllRuleIds() + assertTrue(ids.contains("pass_through"), "missing pass_through") + assertTrue(ids.contains("redact"), "missing redact") + assertTrue(ids.contains("partial_mask"), "missing partial_mask") + assertTrue(ids.contains("hash"), "missing hash") + } + + @Test + fun `getRule returns rule for known ID`() { + val rule = registry.getRule("redact") + assertNotNull(rule) + assertEquals("[REDACTED]", rule!!.mask("sensitive")) + } + + @Test + fun `getRule returns null for unknown ID`() { + assertNull(registry.getRule("unknown_rule")) + } + + @Test + fun `registerCustomRule makes rule available via getRule`() { + val customRule = object : PIIMaskingRule { + override val ruleId = "eu_gdpr_mask" + override fun mask(input: Any?): Any? = if (input == null) null else "EU_MASKED" + } + + registry.registerCustomRule(customRule) + + val found = registry.getRule("eu_gdpr_mask") + assertNotNull(found) + assertEquals("EU_MASKED", found!!.mask("personal_data")) + } + + @Test + fun `registerCustomRule overrides built-in rule when same ID is used`() { + val override = object : PIIMaskingRule { + override val ruleId = "redact" + override fun mask(input: Any?): Any? = "[CUSTOM_REDACTED]" + } + + registry.registerCustomRule(override) + + assertEquals("[CUSTOM_REDACTED]", registry.getRule("redact")!!.mask("x")) + } + + @Test + fun `getAllRuleIds includes custom rules after registration`() { + val customRule = object : PIIMaskingRule { + override val ruleId = "custom_biz_rule" + override fun mask(input: Any?): Any? = null + } + registry.registerCustomRule(customRule) + + assertTrue(registry.getAllRuleIds().contains("custom_biz_rule")) + } +} diff --git a/backend/src/test/kotlin/com/opendatamask/application/service/JobServiceTest.kt b/backend/src/test/kotlin/com/opendatamask/application/service/JobServiceTest.kt index 118e498..ab13f16 100644 --- a/backend/src/test/kotlin/com/opendatamask/application/service/JobServiceTest.kt +++ b/backend/src/test/kotlin/com/opendatamask/application/service/JobServiceTest.kt @@ -4,17 +4,21 @@ import com.opendatamask.domain.port.output.EncryptionPort import com.opendatamask.domain.port.output.* import com.opendatamask.domain.model.* import org.junit.jupiter.api.Assertions.* +import org.junit.jupiter.api.BeforeEach import org.junit.jupiter.api.Test import org.junit.jupiter.api.assertThrows import org.junit.jupiter.api.extension.ExtendWith import org.mockito.InjectMocks import org.mockito.Mock import org.mockito.junit.jupiter.MockitoExtension +import org.mockito.junit.jupiter.MockitoSettings import org.mockito.kotlin.* +import org.mockito.quality.Strictness import java.time.LocalDateTime import java.util.Optional @ExtendWith(MockitoExtension::class) +@MockitoSettings(strictness = Strictness.LENIENT) class JobServiceTest { @Mock private lateinit var jobRepository: JobPort @@ -31,10 +35,20 @@ class JobServiceTest { @Mock private lateinit var postJobActionService: PostJobActionService @Mock private lateinit var schemaChangeService: SchemaChangeService @Mock private lateinit var webhookService: WebhookService + @Mock private lateinit var piiMaskingService: PIIMaskingService @InjectMocks private lateinit var jobService: JobService + @BeforeEach + fun setUpDefaults() { + // By default piiMaskingService is a pass-through so existing PASSTHROUGH-mode + // tests continue to see unmodified rows at the destination. + @Suppress("UNCHECKED_CAST") + whenever(piiMaskingService.applyMappings(any(), any(), any(), any>())) + .thenAnswer { it.arguments[3] as Map } + } + private fun makeWorkspace(id: Long = 1L) = Workspace( id = id, name = "WS", ownerId = 1L, createdAt = LocalDateTime.now(), updatedAt = LocalDateTime.now() diff --git a/backend/src/test/kotlin/com/opendatamask/application/service/PIIMaskingServiceTest.kt b/backend/src/test/kotlin/com/opendatamask/application/service/PIIMaskingServiceTest.kt new file mode 100644 index 0000000..0f6ad2e --- /dev/null +++ b/backend/src/test/kotlin/com/opendatamask/application/service/PIIMaskingServiceTest.kt @@ -0,0 +1,215 @@ +package com.opendatamask.application.service + +import com.opendatamask.domain.model.CustomDataMapping +import com.opendatamask.domain.model.GeneratorType +import com.opendatamask.domain.model.MappingAction +import com.opendatamask.domain.model.MaskingStrategy +import com.opendatamask.domain.port.output.CustomDataMappingPort +import com.opendatamask.domain.port.output.RuleRegistryPort +import org.junit.jupiter.api.Assertions.* +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.extension.ExtendWith +import org.mockito.Mock +import org.mockito.junit.jupiter.MockitoExtension +import org.mockito.junit.jupiter.MockitoSettings +import org.mockito.kotlin.whenever +import org.mockito.quality.Strictness + +@ExtendWith(MockitoExtension::class) +@MockitoSettings(strictness = Strictness.LENIENT) +class PIIMaskingServiceTest { + + @Mock private lateinit var ruleRegistry: RuleRegistryPort + @Mock private lateinit var customDataMappingPort: CustomDataMappingPort + + private lateinit var service: PIIMaskingService + + @BeforeEach + fun setUp() { + service = PIIMaskingService(ruleRegistry, customDataMappingPort) + // Wire the real built-in registry so REDACT tests use the actual rule + val builtInRegistry = DefaultRuleRegistry() + whenever(ruleRegistry.getRule("redact")).thenReturn(builtInRegistry.getRule("redact")) + } + + private fun mapping( + column: String, + action: MappingAction, + strategy: MaskingStrategy? = null, + params: String? = null + ) = CustomDataMapping( + id = 1L, workspaceId = 1L, connectionId = 2L, + tableName = "users", columnName = column, + action = action, maskingStrategy = strategy, + fakeGeneratorType = null, piiRuleParams = params + ) + + // ── no mappings ────────────────────────────────────────────────────────── + + @Test + fun `applyMappings returns row unchanged when no mappings exist`() { + whenever(customDataMappingPort.findByWorkspaceIdAndConnectionIdAndTableName(1L, 2L, "users")) + .thenReturn(emptyList()) + + val row = mapOf("email" to "john@example.com", "id" to 1L) + val result = service.applyMappings(1L, 2L, "users", row) + + assertEquals(row, result) + } + + // ── MIGRATE_AS_IS ──────────────────────────────────────────────────────── + + @Test + fun `applyMappings passes through columns with MIGRATE_AS_IS action`() { + whenever(customDataMappingPort.findByWorkspaceIdAndConnectionIdAndTableName(1L, 2L, "users")) + .thenReturn(listOf(mapping("email", MappingAction.MIGRATE_AS_IS))) + + val row = mapOf("email" to "john@example.com") + val result = service.applyMappings(1L, 2L, "users", row) + + assertEquals("john@example.com", result["email"]) + } + + // ── NULL strategy ──────────────────────────────────────────────────────── + + @Test + fun `applyMappings nullifies column with NULL strategy`() { + whenever(customDataMappingPort.findByWorkspaceIdAndConnectionIdAndTableName(1L, 2L, "users")) + .thenReturn(listOf(mapping("ssn", MappingAction.MASK, MaskingStrategy.NULL))) + + val row = mapOf("ssn" to "123-45-6789") + val result = service.applyMappings(1L, 2L, "users", row) + + assertNull(result["ssn"]) + } + + // ── REDACT strategy ────────────────────────────────────────────────────── + + @Test + fun `applyMappings redacts column with REDACT strategy`() { + whenever(customDataMappingPort.findByWorkspaceIdAndConnectionIdAndTableName(1L, 2L, "users")) + .thenReturn(listOf(mapping("email", MappingAction.MASK, MaskingStrategy.REDACT))) + + val row = mapOf("email" to "john@example.com") + val result = service.applyMappings(1L, 2L, "users", row) + + assertEquals("[REDACTED]", result["email"]) + } + + // ── HASH strategy ──────────────────────────────────────────────────────── + + @Test + fun `applyMappings hashes column with HASH strategy`() { + whenever(customDataMappingPort.findByWorkspaceIdAndConnectionIdAndTableName(1L, 2L, "users")) + .thenReturn(listOf(mapping("email", MappingAction.MASK, MaskingStrategy.HASH))) + + val row = mapOf("email" to "hello") + val result = service.applyMappings(1L, 2L, "users", row) + + // SHA-256 of "hello" with no salt + assertEquals("2cf24dba5fb0a30e26e83b2ac5b9e29e1b161e5c1fa7425e73043362938b9824", result["email"]) + } + + @Test + fun `applyMappings hashes with salt when piiRuleParams contains salt`() { + whenever(customDataMappingPort.findByWorkspaceIdAndConnectionIdAndTableName(1L, 2L, "users")) + .thenReturn(listOf(mapping("email", MappingAction.MASK, MaskingStrategy.HASH, """{"salt":"pepper"}"""))) + + val row = mapOf("email" to "hello") + val result = service.applyMappings(1L, 2L, "users", row) + + // Must differ from the unsalted hash + assertNotEquals("2cf24dba5fb0a30e26e83b2ac5b9e29e1b161e5c1fa7425e73043362938b9824", result["email"]) + assertNotNull(result["email"]) + } + + // ── PARTIAL_MASK strategy ──────────────────────────────────────────────── + + @Test + fun `applyMappings applies partial mask with default params`() { + whenever(customDataMappingPort.findByWorkspaceIdAndConnectionIdAndTableName(1L, 2L, "users")) + .thenReturn(listOf(mapping("credit_card", MappingAction.MASK, MaskingStrategy.PARTIAL_MASK))) + + val row = mapOf("credit_card" to "4111111111111234") + val result = service.applyMappings(1L, 2L, "users", row) + + // Default: keepFirst=0, keepLast=4 → ************1234 + assertEquals("************1234", result["credit_card"]) + } + + @Test + fun `applyMappings applies partial mask with custom params`() { + whenever(customDataMappingPort.findByWorkspaceIdAndConnectionIdAndTableName(1L, 2L, "users")) + .thenReturn(listOf( + mapping("name", MappingAction.MASK, MaskingStrategy.PARTIAL_MASK, """{"keepFirst":"1","keepLast":"0"}""") + )) + + val row = mapOf("name" to "Alice") + val result = service.applyMappings(1L, 2L, "users", row) + + assertEquals("A****", result["name"]) + } + + // ── REGEX strategy ─────────────────────────────────────────────────────── + + @Test + fun `applyMappings applies regex replacement`() { + whenever(customDataMappingPort.findByWorkspaceIdAndConnectionIdAndTableName(1L, 2L, "users")) + .thenReturn(listOf( + mapping("phone", MappingAction.MASK, MaskingStrategy.REGEX, """{"pattern":"\\d","replacement":"#"}""") + )) + + val row = mapOf("phone" to "123-456-7890") + val result = service.applyMappings(1L, 2L, "users", row) + + assertEquals("###-###-####", result["phone"]) + } + + // ── FAKE strategy (handled by GeneratorService, not PIIMaskingService) ─── + + @Test + fun `applyMappings leaves FAKE strategy columns to GeneratorService`() { + whenever(customDataMappingPort.findByWorkspaceIdAndConnectionIdAndTableName(1L, 2L, "users")) + .thenReturn(listOf(mapping("email", MappingAction.MASK, MaskingStrategy.FAKE))) + + val row = mapOf("email" to "original@example.com") + val result = service.applyMappings(1L, 2L, "users", row) + + // FAKE strategy is a no-op in PIIMaskingService; value is left for GeneratorService + assertEquals("original@example.com", result["email"]) + } + + // ── column name case-insensitivity ─────────────────────────────────────── + + @Test + fun `applyMappings matches columns case-insensitively`() { + whenever(customDataMappingPort.findByWorkspaceIdAndConnectionIdAndTableName(1L, 2L, "users")) + .thenReturn(listOf(mapping("EMAIL", MappingAction.MASK, MaskingStrategy.REDACT))) + + val row = mapOf("email" to "john@example.com") + val result = service.applyMappings(1L, 2L, "users", row) + + assertEquals("[REDACTED]", result["email"]) + } + + // ── mixed row ──────────────────────────────────────────────────────────── + + @Test + fun `applyMappings handles mixed actions in same row`() { + whenever(customDataMappingPort.findByWorkspaceIdAndConnectionIdAndTableName(1L, 2L, "users")) + .thenReturn(listOf( + mapping("id", MappingAction.MIGRATE_AS_IS), + mapping("email", MappingAction.MASK, MaskingStrategy.REDACT), + mapping("ssn", MappingAction.MASK, MaskingStrategy.NULL) + )) + + val row = mapOf("id" to 42L, "email" to "jane@example.com", "ssn" to "000-00-0000", "name" to "Jane") + val result = service.applyMappings(1L, 2L, "users", row) + + assertEquals(42L, result["id"]) + assertEquals("[REDACTED]", result["email"]) + assertNull(result["ssn"]) + assertEquals("Jane", result["name"]) // unmapped column passes through + } +} diff --git a/backend/src/test/kotlin/com/opendatamask/domain/model/PIIMaskingRuleTest.kt b/backend/src/test/kotlin/com/opendatamask/domain/model/PIIMaskingRuleTest.kt new file mode 100644 index 0000000..136e471 --- /dev/null +++ b/backend/src/test/kotlin/com/opendatamask/domain/model/PIIMaskingRuleTest.kt @@ -0,0 +1,133 @@ +package com.opendatamask.domain.model + +import org.junit.jupiter.api.Assertions.* +import org.junit.jupiter.api.Test + +class PIIMaskingRuleTest { + + // ── PassThroughRule ────────────────────────────────────────────────────── + + @Test + fun `PassThroughRule returns input unchanged`() { + val rule = PassThroughRule() + assertEquals("hello", rule.mask("hello")) + assertEquals(42, rule.mask(42)) + } + + @Test + fun `PassThroughRule returns null for null input`() { + assertNull(PassThroughRule().mask(null)) + } + + // ── RedactRule ─────────────────────────────────────────────────────────── + + @Test + fun `RedactRule replaces non-null value with REDACTED token`() { + val rule = RedactRule() + assertEquals("[REDACTED]", rule.mask("john.doe@example.com")) + assertEquals("[REDACTED]", rule.mask(12345)) + assertEquals("[REDACTED]", rule.mask("")) + } + + @Test + fun `RedactRule returns null for null input`() { + assertNull(RedactRule().mask(null)) + } + + // ── PartialMaskRule ────────────────────────────────────────────────────── + + @Test + fun `PartialMaskRule masks middle characters keeping last 4`() { + val rule = PartialMaskRule(keepFirst = 0, keepLast = 4) + assertEquals("****1234", rule.mask("12341234")) + } + + @Test + fun `PartialMaskRule keeps first and last characters`() { + val rule = PartialMaskRule(keepFirst = 1, keepLast = 1) + assertEquals("j***e", rule.mask("johne")) + } + + @Test + fun `PartialMaskRule with short input returns input unchanged`() { + val rule = PartialMaskRule(keepFirst = 2, keepLast = 3) + assertEquals("hello", PartialMaskRule(keepFirst = 0, keepLast = 10).mask("hello")) + } + + @Test + fun `PartialMaskRule returns null for null input`() { + assertNull(PartialMaskRule().mask(null)) + } + + @Test + fun `PartialMaskRule uses custom mask character`() { + val rule = PartialMaskRule(keepFirst = 0, keepLast = 4, maskChar = '#') + assertEquals("####5678", rule.mask("12345678")) + } + + // ── HashRule ───────────────────────────────────────────────────────────── + + @Test + fun `HashRule produces consistent SHA-256 hex digest`() { + val rule = HashRule() + val result = rule.mask("hello") + assertNotNull(result) + // SHA-256 of "hello" is well-known + assertEquals("2cf24dba5fb0a30e26e83b2ac5b9e29e1b161e5c1fa7425e73043362938b9824", result) + } + + @Test + fun `HashRule with salt produces different digest`() { + val noSalt = HashRule().mask("hello") + val withSalt = HashRule(salt = "pepper").mask("hello") + assertNotEquals(noSalt, withSalt) + } + + @Test + fun `HashRule returns null for null input`() { + assertNull(HashRule().mask(null)) + } + + @Test + fun `HashRule output is always 64 hex characters`() { + val result = HashRule().mask("test value") as String + assertEquals(64, result.length) + assertTrue(result.all { it in '0'..'9' || it in 'a'..'f' }) + } + + // ── RegexRule ──────────────────────────────────────────────────────────── + + @Test + fun `RegexRule replaces all pattern matches`() { + val rule = RegexRule(pattern = "\\d", replacement = "#") + assertEquals("###-##-####", rule.mask("123-45-6789")) + } + + @Test + fun `RegexRule with capture group replacement`() { + val rule = RegexRule(pattern = "(\\w+)@(\\w+\\.\\w+)", replacement = "***@$2") + assertEquals("***@example.com", rule.mask("john@example.com")) + } + + @Test + fun `RegexRule returns null for null input`() { + assertNull(RegexRule(".*", "").mask(null)) + } + + @Test + fun `RegexRule with no match returns original string`() { + val rule = RegexRule(pattern = "\\d+", replacement = "") + assertEquals("hello", rule.mask("hello")) + } + + // ── ruleId contract ────────────────────────────────────────────────────── + + @Test + fun `built-in rules have expected stable rule IDs`() { + assertEquals("pass_through", PassThroughRule().ruleId) + assertEquals("redact", RedactRule().ruleId) + assertEquals("partial_mask", PartialMaskRule().ruleId) + assertEquals("hash", HashRule().ruleId) + assertEquals("regex", RegexRule("", "").ruleId) + } +} diff --git a/docs/pii-masking-rules-sample.json b/docs/pii-masking-rules-sample.json new file mode 100644 index 0000000..fc157be --- /dev/null +++ b/docs/pii-masking-rules-sample.json @@ -0,0 +1,44 @@ +{ + "description": "Sample PII attribute rule mapping for a Customer dataset", + "workspaceId": 1, + "connectionId": 2, + "tableName": "customers", + "columnMappings": [ + { + "columnName": "id", + "action": "MIGRATE_AS_IS" + }, + { + "columnName": "full_name", + "action": "MASK", + "maskingStrategy": "PARTIAL_MASK", + "piiRuleParams": { + "keepFirst": "1", + "keepLast": "0", + "maskChar": "*" + }, + "description": "Keeps only the first initial; e.g. 'Alice Smith' → 'A**********'" + }, + { + "columnName": "email", + "action": "MASK", + "maskingStrategy": "REGEX", + "piiRuleParams": { + "pattern": "(\\w+)@(\\w+\\.\\w+)", + "replacement": "***@$2" + }, + "description": "Masks the local part of the email address; e.g. 'alice@example.com' → '***@example.com'" + }, + { + "columnName": "ssn", + "action": "MASK", + "maskingStrategy": "REDACT", + "description": "Fully redacts the SSN; e.g. '123-45-6789' → '[REDACTED]'" + }, + { + "columnName": "transaction_amount", + "action": "MIGRATE_AS_IS", + "description": "Transaction amounts are not PII and pass through unchanged" + } + ] +} diff --git a/docs/user-guide.md b/docs/user-guide.md index 0d2daf1..b57d476 100644 --- a/docs/user-guide.md +++ b/docs/user-guide.md @@ -11,7 +11,7 @@ Core capabilities: - **Privacy intelligence**: Automatic sensitive column detection, privacy hub dashboards, and compliance reports - **Job scheduling**: Cron-based automated masking runs - **Webhook integration**: Post-job notifications via custom HTTP webhooks or GitHub Actions triggers -- **REST API + CLI**: Full programmatic access and a Go-based CLI tool +- **PII attribute rule mapping**: Column-level masking rules (Redact, Partial Mask, Hash, Regex) with a registry pattern for custom business rules --- @@ -648,7 +648,92 @@ See [verification/README.md](../verification/README.md) for the full reference, --- -## Contributing +## PII Attribute Rule Mapping + +OpenDataMask provides a flexible, configuration-driven system to apply fine-grained masking rules to individual source columns as data moves to the target destination. + +### Built-in Masking Strategies + +| `maskingStrategy` | Description | Required `piiRuleParams` keys | +|---|---|---| +| `FAKE` | Replaces the value with realistic synthetic data using the configured `fakeGeneratorType` | — | +| `HASH` | Deterministic SHA-256 hex digest of the value | `salt` (optional) | +| `NULL` | Replaces the value with `null` / `NULL` | — | +| `REDACT` | Replaces the value with the literal token `[REDACTED]` | — | +| `PARTIAL_MASK` | Keeps a configurable number of leading/trailing characters and masks the middle | `keepFirst` (default `0`), `keepLast` (default `4`), `maskChar` (default `*`) | +| `REGEX` | Applies a regular expression replacement to the string representation of the value | `pattern` (required), `replacement` (required) | + +### Mapping a column via REST API + +```http +POST /api/workspaces/{workspaceId}/mappings +Content-Type: application/json + +{ + "connectionId": 2, + "tableName": "customers", + "columnName": "ssn", + "action": "MASK", + "maskingStrategy": "REDACT" +} +``` + +### Bulk mapping (all columns of a table at once) + +```http +POST /api/workspaces/{workspaceId}/mappings/bulk +Content-Type: application/json + +{ + "connectionId": 2, + "tableName": "customers", + "columnMappings": [ + { "columnName": "id", "action": "MIGRATE_AS_IS" }, + { "columnName": "full_name", "action": "MASK", "maskingStrategy": "PARTIAL_MASK", + "piiRuleParams": "{\"keepFirst\":\"1\",\"keepLast\":\"0\"}" }, + { "columnName": "email", "action": "MASK", "maskingStrategy": "REGEX", + "piiRuleParams": "{\"pattern\":\"(\\\\w+)@(\\\\w+\\\\.\\\\w+)\",\"replacement\":\"***@$2\"}" }, + { "columnName": "ssn", "action": "MASK", "maskingStrategy": "REDACT" }, + { "columnName": "transaction_amount", "action": "MIGRATE_AS_IS" } + ] +} +``` + +A complete JSON example is available in [`docs/pii-masking-rules-sample.json`](pii-masking-rules-sample.json). + +### How rules are applied during a job + +Mappings are evaluated during every **PASSTHROUGH** table run: for each row fetched from the source, OpenDataMask looks up any active `CustomDataMapping` entries for that workspace/connection/table combination and applies the configured strategy before writing to the destination. Columns without a mapping pass through unchanged. + +### Registering a custom business rule at runtime + +The `RuleRegistryPort` / `DefaultRuleRegistry` bean accepts runtime-registered `PIIMaskingRule` implementations. Inject `RuleRegistryPort` into your Spring component and call `registerCustomRule()`: + +```kotlin +@Component +class EuGdprRuleRegistrar(private val ruleRegistry: RuleRegistryPort) { + + @PostConstruct + fun register() { + ruleRegistry.registerCustomRule(object : PIIMaskingRule { + override val ruleId = "eu_gdpr_conditional" + override fun mask(input: Any?): Any? { + // Example: if the value looks like an EU phone prefix, redact it fully; + // otherwise apply partial masking + val str = input?.toString() ?: return null + return if (str.startsWith("+3") || str.startsWith("+4")) + "[REDACTED]" + else + str.take(0) + "*".repeat((str.length - 4).coerceAtLeast(0)) + str.takeLast(4) + } + }) + } +} +``` + +After registering the rule, create a mapping that uses `maskingStrategy: "REGEX"` and set `piiRuleParams` to `{"ruleId": "eu_gdpr_conditional"}`, or simply apply the rule programmatically via `PIIMaskingService.applyMappings()`. + + 1. Fork the repository 2. Create a feature branch: `git checkout -b feature/my-feature` diff --git a/frontend/src/types/index.ts b/frontend/src/types/index.ts index 8d98aa8..44b2a32 100644 --- a/frontend/src/types/index.ts +++ b/frontend/src/types/index.ts @@ -383,7 +383,10 @@ export enum MappingAction { export enum MaskingStrategy { FAKE = 'FAKE', HASH = 'HASH', - NULL = 'NULL' + NULL = 'NULL', + REDACT = 'REDACT', + PARTIAL_MASK = 'PARTIAL_MASK', + REGEX = 'REGEX' } export interface CustomDataMapping { @@ -395,6 +398,7 @@ export interface CustomDataMapping { action: MappingAction maskingStrategy: MaskingStrategy | null fakeGeneratorType: GeneratorType | null + piiRuleParams: string | null createdAt: string updatedAt: string } @@ -406,6 +410,9 @@ export interface CustomDataMappingRequest { action: MappingAction maskingStrategy?: MaskingStrategy | null fakeGeneratorType?: GeneratorType | null + // JSON string with strategy-specific params (e.g. {"salt":"..."} for HASH, + // {"keepFirst":"0","keepLast":"4"} for PARTIAL_MASK, {"pattern":"...","replacement":"..."} for REGEX) + piiRuleParams?: string | null } export interface ColumnMappingEntry { @@ -413,6 +420,7 @@ export interface ColumnMappingEntry { action: MappingAction maskingStrategy?: MaskingStrategy | null fakeGeneratorType?: GeneratorType | null + piiRuleParams?: string | null } export interface BulkCustomDataMappingRequest { From a15c990648c3737d036a48e7630c7c743faf8c47 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 16 Apr 2026 16:30:19 +0000 Subject: [PATCH 3/6] fix: address code review issues - PartialMaskRule overlap edge case, nullable sentinel params, docs fixes Agent-Logs-Url: https://github.com/MaximumTrainer/OpenDataMask/sessions/1f3e4622-ad17-4d5f-b60f-195b61c0a703 Co-authored-by: MaximumTrainer <1376575+MaximumTrainer@users.noreply.github.com> --- .../com/opendatamask/application/service/JobService.kt | 6 +++--- .../kotlin/com/opendatamask/domain/model/PIIMaskingRule.kt | 6 ++++-- .../com/opendatamask/domain/model/PIIMaskingRuleTest.kt | 2 +- docs/user-guide.md | 3 ++- 4 files changed, 10 insertions(+), 7 deletions(-) diff --git a/backend/src/main/kotlin/com/opendatamask/application/service/JobService.kt b/backend/src/main/kotlin/com/opendatamask/application/service/JobService.kt index 90ccfec..95cfb2b 100644 --- a/backend/src/main/kotlin/com/opendatamask/application/service/JobService.kt +++ b/backend/src/main/kotlin/com/opendatamask/application/service/JobService.kt @@ -208,8 +208,8 @@ class JobService( sourceConnector: DatabaseConnector, destConnector: DatabaseConnector, preComputedRows: Map>> = emptyMap(), - workspaceId: Long = 0L, - sourceConnectionId: Long = 0L + workspaceId: Long? = null, + sourceConnectionId: Long? = null ) { addLog(jobId, "Processing table: ${tableConfig.tableName} (mode: ${tableConfig.mode})", LogLevel.INFO) @@ -220,7 +220,7 @@ class JobService( TableMode.PASSTHROUGH -> { val data = sourceConnector.fetchData(tableConfig.tableName, tableConfig.rowLimit?.toInt(), null, selectedAttrs) addLog(jobId, "Fetched ${data.size} rows from ${tableConfig.tableName}", LogLevel.INFO) - val transformed = if (workspaceId != 0L && sourceConnectionId != 0L) { + val transformed = if (workspaceId != null && sourceConnectionId != null) { data.map { row -> piiMaskingService.applyMappings(workspaceId, sourceConnectionId, tableConfig.tableName, row) } diff --git a/backend/src/main/kotlin/com/opendatamask/domain/model/PIIMaskingRule.kt b/backend/src/main/kotlin/com/opendatamask/domain/model/PIIMaskingRule.kt index 44fc32e..9fc6ba1 100644 --- a/backend/src/main/kotlin/com/opendatamask/domain/model/PIIMaskingRule.kt +++ b/backend/src/main/kotlin/com/opendatamask/domain/model/PIIMaskingRule.kt @@ -26,6 +26,7 @@ class RedactRule : BuiltInPIIRule("redact") { // 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. class PartialMaskRule( val keepFirst: Int = 0, val keepLast: Int = 4, @@ -34,8 +35,9 @@ class PartialMaskRule( override fun mask(input: Any?): Any? { if (input == null) return null val str = input.toString() - val maskLen = (str.length - keepFirst - keepLast).coerceAtLeast(0) - return str.take(keepFirst) + maskChar.toString().repeat(maskLen) + str.takeLast(keepLast.coerceAtMost(str.length)) + if (str.length <= keepFirst + keepLast) return str + val maskLen = str.length - keepFirst - keepLast + return str.take(keepFirst) + maskChar.toString().repeat(maskLen) + str.takeLast(keepLast) } } diff --git a/backend/src/test/kotlin/com/opendatamask/domain/model/PIIMaskingRuleTest.kt b/backend/src/test/kotlin/com/opendatamask/domain/model/PIIMaskingRuleTest.kt index 136e471..d74d6c4 100644 --- a/backend/src/test/kotlin/com/opendatamask/domain/model/PIIMaskingRuleTest.kt +++ b/backend/src/test/kotlin/com/opendatamask/domain/model/PIIMaskingRuleTest.kt @@ -51,7 +51,7 @@ class PIIMaskingRuleTest { @Test fun `PartialMaskRule with short input returns input unchanged`() { val rule = PartialMaskRule(keepFirst = 2, keepLast = 3) - assertEquals("hello", PartialMaskRule(keepFirst = 0, keepLast = 10).mask("hello")) + assertEquals("hello", rule.mask("hello")) } @Test diff --git a/docs/user-guide.md b/docs/user-guide.md index b57d476..5ba0984 100644 --- a/docs/user-guide.md +++ b/docs/user-guide.md @@ -731,9 +731,10 @@ class EuGdprRuleRegistrar(private val ruleRegistry: RuleRegistryPort) { } ``` -After registering the rule, create a mapping that uses `maskingStrategy: "REGEX"` and set `piiRuleParams` to `{"ruleId": "eu_gdpr_conditional"}`, or simply apply the rule programmatically via `PIIMaskingService.applyMappings()`. +After registering the rule, invoke it programmatically via `PIIMaskingService.applyMappings()`, or create a standard mapping with any built-in strategy that routes through `PIIMaskingService`. +## Contributing 1. Fork the repository 2. Create a feature branch: `git checkout -b feature/my-feature` From 6e1ce60d2ee17ce6f8ce67b88b13b36458b681b4 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 16 Apr 2026 16:47:56 +0000 Subject: [PATCH 4/6] fix: address PR review - batch API, safe defaults, registry dispatch, negative param guards, invalid regex handling, docs/sample JSON corrections Agent-Logs-Url: https://github.com/MaximumTrainer/OpenDataMask/sessions/2767f87d-a0e1-4191-8b45-6d3cfc40f59a Co-authored-by: MaximumTrainer <1376575+MaximumTrainer@users.noreply.github.com> --- .../service/DefaultRuleRegistry.kt | 6 +- .../application/service/JobService.kt | 4 +- .../application/service/PIIMaskingService.kt | 85 +++++++++++++++--- .../domain/model/PIIMaskingRule.kt | 19 +++- .../application/service/JobServiceTest.kt | 4 +- .../service/PIIMaskingServiceTest.kt | 90 ++++++++++++++++++- docs/pii-masking-rules-sample.json | 11 +-- docs/user-guide.md | 23 ++++- 8 files changed, 206 insertions(+), 36 deletions(-) diff --git a/backend/src/main/kotlin/com/opendatamask/application/service/DefaultRuleRegistry.kt b/backend/src/main/kotlin/com/opendatamask/application/service/DefaultRuleRegistry.kt index 53041fb..bbe2d22 100644 --- a/backend/src/main/kotlin/com/opendatamask/application/service/DefaultRuleRegistry.kt +++ b/backend/src/main/kotlin/com/opendatamask/application/service/DefaultRuleRegistry.kt @@ -10,7 +10,11 @@ import org.springframework.stereotype.Service import java.util.concurrent.ConcurrentHashMap // Holds all known PIIMaskingRule implementations. -// Built-in rules are registered at construction time. +// 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 { diff --git a/backend/src/main/kotlin/com/opendatamask/application/service/JobService.kt b/backend/src/main/kotlin/com/opendatamask/application/service/JobService.kt index 95cfb2b..c0ac210 100644 --- a/backend/src/main/kotlin/com/opendatamask/application/service/JobService.kt +++ b/backend/src/main/kotlin/com/opendatamask/application/service/JobService.kt @@ -221,9 +221,7 @@ class JobService( val data = sourceConnector.fetchData(tableConfig.tableName, tableConfig.rowLimit?.toInt(), null, selectedAttrs) addLog(jobId, "Fetched ${data.size} rows from ${tableConfig.tableName}", LogLevel.INFO) val transformed = if (workspaceId != null && sourceConnectionId != null) { - data.map { row -> - piiMaskingService.applyMappings(workspaceId, sourceConnectionId, tableConfig.tableName, row) - } + piiMaskingService.applyMappingsToRows(workspaceId, sourceConnectionId, tableConfig.tableName, data) } else { data } diff --git a/backend/src/main/kotlin/com/opendatamask/application/service/PIIMaskingService.kt b/backend/src/main/kotlin/com/opendatamask/application/service/PIIMaskingService.kt index 94a56bb..86326d6 100644 --- a/backend/src/main/kotlin/com/opendatamask/application/service/PIIMaskingService.kt +++ b/backend/src/main/kotlin/com/opendatamask/application/service/PIIMaskingService.kt @@ -10,30 +10,48 @@ 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 a source data row by applying the PIIMaskingRule selected by each +// 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() - // Apply all active CustomDataMappings for the given workspace/connection/table to a single row. - fun applyMappings( + // Load mappings once and apply to every row in a batch. Callers should + // prefer this over the per-row overload to avoid N+1 persistence queries. + fun applyMappingsToRows( workspaceId: Long, connectionId: Long, tableName: String, - row: Map - ): Map { - val mappings = customDataMappingPort + rows: List> + ): List> { + 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 = + 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, + row: Map + ): Map { if (mappings.isEmpty()) return row - return row.mapValues { (column, value) -> val mapping = mappings[column.lowercase()] ?: return@mapValues value when (mapping.action) { @@ -43,8 +61,29 @@ class PIIMaskingService( } } + // Convenience overload that fetches mappings from persistence. + // Use applyMappingsToRows() instead when processing multiple rows for the same table. + fun applyMappings( + workspaceId: Long, + connectionId: Long, + tableName: String, + row: Map + ): Map = + applyMappings(loadMappings(workspaceId, connectionId, tableName), row) + private fun applyStrategy(mapping: CustomDataMapping, value: Any?): Any? { - val params = parseParams(mapping.piiRuleParams) + 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]" @@ -59,9 +98,23 @@ class PIIMaskingService( PartialMaskRule(keepFirst, keepLast, maskChar).mask(value) } MaskingStrategy.REGEX -> { - val pattern = params["pattern"] ?: ".*" - val replacement = params["replacement"] ?: "" - RegexRule(pattern, replacement).mask(value) + 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 + } } // FAKE strategy is handled upstream by the GeneratorService; return value unchanged. MaskingStrategy.FAKE -> value @@ -69,8 +122,14 @@ class PIIMaskingService( } } - private fun parseParams(json: String?): Map { + // Returns null (and logs a warning) when piiRuleParams is present but cannot be parsed as JSON. + // Returns an empty map when piiRuleParams is absent/blank (treated as "no extra config"). + private fun parseParams(json: String?): Map? { if (json.isNullOrBlank()) return emptyMap() - return runCatching { mapper.readValue>(json) }.getOrDefault(emptyMap()) + return runCatching { mapper.readValue>(json) } + .getOrElse { cause -> + logger.warn("Failed to parse piiRuleParams JSON '{}': {} — passing value through unchanged", json, cause.message) + null + } } } diff --git a/backend/src/main/kotlin/com/opendatamask/domain/model/PIIMaskingRule.kt b/backend/src/main/kotlin/com/opendatamask/domain/model/PIIMaskingRule.kt index 9fc6ba1..78f6d2c 100644 --- a/backend/src/main/kotlin/com/opendatamask/domain/model/PIIMaskingRule.kt +++ b/backend/src/main/kotlin/com/opendatamask/domain/model/PIIMaskingRule.kt @@ -27,6 +27,7 @@ class RedactRule : BuiltInPIIRule("redact") { // 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, @@ -35,9 +36,11 @@ class PartialMaskRule( override fun mask(input: Any?): Any? { if (input == null) return null val str = input.toString() - if (str.length <= keepFirst + keepLast) return str - val maskLen = str.length - keepFirst - keepLast - return str.take(keepFirst) + maskChar.toString().repeat(maskLen) + str.takeLast(keepLast) + 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) } } @@ -53,8 +56,16 @@ class HashRule(val salt: String = "") : BuiltInPIIRule("hash") { // 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(pattern) + private val compiledRegex: Regex = runCatching { Regex(pattern) } + .getOrElse { cause -> + throw IllegalArgumentException( + "Invalid regex pattern for rule '$ruleId': '$pattern'", + cause + ) + } + override fun mask(input: Any?): Any? { if (input == null) return null return compiledRegex.replace(input.toString(), replacement) diff --git a/backend/src/test/kotlin/com/opendatamask/application/service/JobServiceTest.kt b/backend/src/test/kotlin/com/opendatamask/application/service/JobServiceTest.kt index ab13f16..0abdfae 100644 --- a/backend/src/test/kotlin/com/opendatamask/application/service/JobServiceTest.kt +++ b/backend/src/test/kotlin/com/opendatamask/application/service/JobServiceTest.kt @@ -45,8 +45,8 @@ class JobServiceTest { // By default piiMaskingService is a pass-through so existing PASSTHROUGH-mode // tests continue to see unmodified rows at the destination. @Suppress("UNCHECKED_CAST") - whenever(piiMaskingService.applyMappings(any(), any(), any(), any>())) - .thenAnswer { it.arguments[3] as Map } + whenever(piiMaskingService.applyMappingsToRows(any(), any(), any(), any>>())) + .thenAnswer { it.arguments[3] as List> } } private fun makeWorkspace(id: Long = 1L) = Workspace( diff --git a/backend/src/test/kotlin/com/opendatamask/application/service/PIIMaskingServiceTest.kt b/backend/src/test/kotlin/com/opendatamask/application/service/PIIMaskingServiceTest.kt index 0f6ad2e..47448c9 100644 --- a/backend/src/test/kotlin/com/opendatamask/application/service/PIIMaskingServiceTest.kt +++ b/backend/src/test/kotlin/com/opendatamask/application/service/PIIMaskingServiceTest.kt @@ -1,9 +1,9 @@ package com.opendatamask.application.service import com.opendatamask.domain.model.CustomDataMapping -import com.opendatamask.domain.model.GeneratorType import com.opendatamask.domain.model.MappingAction import com.opendatamask.domain.model.MaskingStrategy +import com.opendatamask.domain.model.RedactRule import com.opendatamask.domain.port.output.CustomDataMappingPort import com.opendatamask.domain.port.output.RuleRegistryPort import org.junit.jupiter.api.Assertions.* @@ -29,8 +29,7 @@ class PIIMaskingServiceTest { fun setUp() { service = PIIMaskingService(ruleRegistry, customDataMappingPort) // Wire the real built-in registry so REDACT tests use the actual rule - val builtInRegistry = DefaultRuleRegistry() - whenever(ruleRegistry.getRule("redact")).thenReturn(builtInRegistry.getRule("redact")) + whenever(ruleRegistry.getRule("redact")).thenReturn(RedactRule()) } private fun mapping( @@ -166,6 +165,62 @@ class PIIMaskingServiceTest { assertEquals("###-###-####", result["phone"]) } + @Test + fun `applyMappings passes value through unchanged when REGEX pattern is missing`() { + whenever(customDataMappingPort.findByWorkspaceIdAndConnectionIdAndTableName(1L, 2L, "users")) + .thenReturn(listOf(mapping("phone", MappingAction.MASK, MaskingStrategy.REGEX))) + + val row = mapOf("phone" to "123-456-7890") + val result = service.applyMappings(1L, 2L, "users", row) + + // Missing params → no-op, not data loss + assertEquals("123-456-7890", result["phone"]) + } + + @Test + fun `applyMappings passes value through unchanged when piiRuleParams JSON is invalid`() { + whenever(customDataMappingPort.findByWorkspaceIdAndConnectionIdAndTableName(1L, 2L, "users")) + .thenReturn(listOf(mapping("phone", MappingAction.MASK, MaskingStrategy.HASH, "not-json"))) + + val row = mapOf("phone" to "123-456-7890") + val result = service.applyMappings(1L, 2L, "users", row) + + // Invalid JSON → parseParams returns null → value passes through unchanged + assertEquals("123-456-7890", result["phone"]) + } + + // ── custom ruleId dispatch via piiRuleParams ────────────────────────────── + + @Test + fun `applyMappings dispatches to registry when ruleId is set in piiRuleParams`() { + val customRule = com.opendatamask.domain.model.RedactRule() + whenever(ruleRegistry.getRule("my_custom_rule")).thenReturn(customRule) + whenever(customDataMappingPort.findByWorkspaceIdAndConnectionIdAndTableName(1L, 2L, "users")) + .thenReturn(listOf( + mapping("email", MappingAction.MASK, MaskingStrategy.REDACT, """{"ruleId":"my_custom_rule"}""") + )) + + val row = mapOf("email" to "john@example.com") + val result = service.applyMappings(1L, 2L, "users", row) + + assertEquals("[REDACTED]", result["email"]) + } + + @Test + fun `applyMappings passes value through unchanged when custom ruleId is not found in registry`() { + whenever(ruleRegistry.getRule("unknown_rule")).thenReturn(null) + whenever(customDataMappingPort.findByWorkspaceIdAndConnectionIdAndTableName(1L, 2L, "users")) + .thenReturn(listOf( + mapping("email", MappingAction.MASK, MaskingStrategy.REDACT, """{"ruleId":"unknown_rule"}""") + )) + + val row = mapOf("email" to "john@example.com") + val result = service.applyMappings(1L, 2L, "users", row) + + // Unknown rule → pass through with warning log + assertEquals("john@example.com", result["email"]) + } + // ── FAKE strategy (handled by GeneratorService, not PIIMaskingService) ─── @Test @@ -212,4 +267,33 @@ class PIIMaskingServiceTest { assertNull(result["ssn"]) assertEquals("Jane", result["name"]) // unmapped column passes through } + + // ── batch API ───────────────────────────────────────────────────────────── + + @Test + fun `applyMappingsToRows fetches mappings once and applies to all rows`() { + whenever(customDataMappingPort.findByWorkspaceIdAndConnectionIdAndTableName(1L, 2L, "users")) + .thenReturn(listOf(mapping("email", MappingAction.MASK, MaskingStrategy.REDACT))) + + val rows = listOf( + mapOf("email" to "alice@example.com"), + mapOf("email" to "bob@example.com") + ) + val results = service.applyMappingsToRows(1L, 2L, "users", rows) + + assertEquals(2, results.size) + assertEquals("[REDACTED]", results[0]["email"]) + assertEquals("[REDACTED]", results[1]["email"]) + } + + @Test + fun `applyMappingsToRows returns original list unchanged when no mappings exist`() { + whenever(customDataMappingPort.findByWorkspaceIdAndConnectionIdAndTableName(1L, 2L, "users")) + .thenReturn(emptyList()) + + val rows = listOf(mapOf("email" to "alice@example.com")) + val results = service.applyMappingsToRows(1L, 2L, "users", rows) + + assertEquals(rows, results) + } } diff --git a/docs/pii-masking-rules-sample.json b/docs/pii-masking-rules-sample.json index fc157be..59c887a 100644 --- a/docs/pii-masking-rules-sample.json +++ b/docs/pii-masking-rules-sample.json @@ -12,21 +12,14 @@ "columnName": "full_name", "action": "MASK", "maskingStrategy": "PARTIAL_MASK", - "piiRuleParams": { - "keepFirst": "1", - "keepLast": "0", - "maskChar": "*" - }, + "piiRuleParams": "{\"keepFirst\":\"1\",\"keepLast\":\"0\",\"maskChar\":\"*\"}", "description": "Keeps only the first initial; e.g. 'Alice Smith' → 'A**********'" }, { "columnName": "email", "action": "MASK", "maskingStrategy": "REGEX", - "piiRuleParams": { - "pattern": "(\\w+)@(\\w+\\.\\w+)", - "replacement": "***@$2" - }, + "piiRuleParams": "{\"pattern\":\"(\\\\w+)@(\\\\w+\\\\.\\\\w+)\",\"replacement\":\"***@$2\"}", "description": "Masks the local part of the email address; e.g. 'alice@example.com' → '***@example.com'" }, { diff --git a/docs/user-guide.md b/docs/user-guide.md index 5ba0984..090292f 100644 --- a/docs/user-guide.md +++ b/docs/user-guide.md @@ -11,6 +11,7 @@ Core capabilities: - **Privacy intelligence**: Automatic sensitive column detection, privacy hub dashboards, and compliance reports - **Job scheduling**: Cron-based automated masking runs - **Webhook integration**: Post-job notifications via custom HTTP webhooks or GitHub Actions triggers +- **REST API + CLI**: Full programmatic access and a Go-based CLI tool - **PII attribute rule mapping**: Column-level masking rules (Redact, Partial Mask, Hash, Regex) with a registry pattern for custom business rules --- @@ -731,7 +732,27 @@ class EuGdprRuleRegistrar(private val ruleRegistry: RuleRegistryPort) { } ``` -After registering the rule, invoke it programmatically via `PIIMaskingService.applyMappings()`, or create a standard mapping with any built-in strategy that routes through `PIIMaskingService`. +After registering the rule, invoke it via any column mapping by adding `"ruleId": ""` to `piiRuleParams`. The engine checks for a `ruleId` key first and, if found, delegates to the registry before falling back to the built-in strategy logic: + +```http +POST /api/workspaces/{workspaceId}/mappings/bulk +Content-Type: application/json + +{ + "connectionId": 2, + "tableName": "customers", + "columnMappings": [ + { + "columnName": "phone", + "action": "MASK", + "maskingStrategy": "REDACT", + "piiRuleParams": "{\"ruleId\":\"eu_gdpr_conditional\"}" + } + ] +} +``` + +The `maskingStrategy` field still controls the UI label and fallback type; the `ruleId` in `piiRuleParams` takes precedence for actual masking logic. ## Contributing From 80d4e022a48eef9ff4f3732757b57bfd744cf066 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 16 Apr 2026 16:51:09 +0000 Subject: [PATCH 5/6] fix: minor style improvements from second validation pass Agent-Logs-Url: https://github.com/MaximumTrainer/OpenDataMask/sessions/2767f87d-a0e1-4191-8b45-6d3cfc40f59a Co-authored-by: MaximumTrainer <1376575+MaximumTrainer@users.noreply.github.com> --- .../opendatamask/application/service/PIIMaskingService.kt | 7 ++++--- .../kotlin/com/opendatamask/domain/model/PIIMaskingRule.kt | 2 +- .../application/service/PIIMaskingServiceTest.kt | 4 ++-- 3 files changed, 7 insertions(+), 6 deletions(-) diff --git a/backend/src/main/kotlin/com/opendatamask/application/service/PIIMaskingService.kt b/backend/src/main/kotlin/com/opendatamask/application/service/PIIMaskingService.kt index 86326d6..08de6c5 100644 --- a/backend/src/main/kotlin/com/opendatamask/application/service/PIIMaskingService.kt +++ b/backend/src/main/kotlin/com/opendatamask/application/service/PIIMaskingService.kt @@ -23,8 +23,9 @@ class PIIMaskingService( private val logger = LoggerFactory.getLogger(PIIMaskingService::class.java) private val mapper = jacksonObjectMapper() - // Load mappings once and apply to every row in a batch. Callers should - // prefer this over the per-row overload to avoid N+1 persistence queries. + // 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, @@ -61,7 +62,7 @@ class PIIMaskingService( } } - // Convenience overload that fetches mappings from persistence. + // 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, diff --git a/backend/src/main/kotlin/com/opendatamask/domain/model/PIIMaskingRule.kt b/backend/src/main/kotlin/com/opendatamask/domain/model/PIIMaskingRule.kt index 78f6d2c..051357f 100644 --- a/backend/src/main/kotlin/com/opendatamask/domain/model/PIIMaskingRule.kt +++ b/backend/src/main/kotlin/com/opendatamask/domain/model/PIIMaskingRule.kt @@ -61,7 +61,7 @@ class RegexRule(val pattern: String, val replacement: String) : BuiltInPIIRule(" private val compiledRegex: Regex = runCatching { Regex(pattern) } .getOrElse { cause -> throw IllegalArgumentException( - "Invalid regex pattern for rule '$ruleId': '$pattern'", + "Invalid regex pattern: \"$pattern\"", cause ) } diff --git a/backend/src/test/kotlin/com/opendatamask/application/service/PIIMaskingServiceTest.kt b/backend/src/test/kotlin/com/opendatamask/application/service/PIIMaskingServiceTest.kt index 47448c9..30b8cb8 100644 --- a/backend/src/test/kotlin/com/opendatamask/application/service/PIIMaskingServiceTest.kt +++ b/backend/src/test/kotlin/com/opendatamask/application/service/PIIMaskingServiceTest.kt @@ -193,8 +193,8 @@ class PIIMaskingServiceTest { @Test fun `applyMappings dispatches to registry when ruleId is set in piiRuleParams`() { - val customRule = com.opendatamask.domain.model.RedactRule() - whenever(ruleRegistry.getRule("my_custom_rule")).thenReturn(customRule) + val registeredRule = com.opendatamask.domain.model.RedactRule() + whenever(ruleRegistry.getRule("my_custom_rule")).thenReturn(registeredRule) whenever(customDataMappingPort.findByWorkspaceIdAndConnectionIdAndTableName(1L, 2L, "users")) .thenReturn(listOf( mapping("email", MappingAction.MASK, MaskingStrategy.REDACT, """{"ruleId":"my_custom_rule"}""") From 174a8f579307c27cf6dffc964b4717dd92784637 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 16 Apr 2026 16:57:41 +0000 Subject: [PATCH 6/6] =?UTF-8?q?fix:=20update=20DataMappingView=20test=20fo?= =?UTF-8?q?r=20expanded=20MaskingStrategy=20enum=20(3=E2=86=926=20values)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Agent-Logs-Url: https://github.com/MaximumTrainer/OpenDataMask/sessions/d8d4e537-ec28-43cf-9833-c5878af52d50 Co-authored-by: MaximumTrainer <1376575+MaximumTrainer@users.noreply.github.com> --- frontend/src/views/__tests__/DataMappingView.test.ts | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/frontend/src/views/__tests__/DataMappingView.test.ts b/frontend/src/views/__tests__/DataMappingView.test.ts index 7f07ebe..d77a316 100644 --- a/frontend/src/views/__tests__/DataMappingView.test.ts +++ b/frontend/src/views/__tests__/DataMappingView.test.ts @@ -19,8 +19,14 @@ describe('MaskingStrategy enum', () => { expect(MaskingStrategy.NULL).toBe('NULL') }) - it('has exactly 3 values', () => { - expect(Object.keys(MaskingStrategy).length).toBe(3) + it('has REDACT, PARTIAL_MASK and REGEX values', () => { + expect(MaskingStrategy.REDACT).toBe('REDACT') + expect(MaskingStrategy.PARTIAL_MASK).toBe('PARTIAL_MASK') + expect(MaskingStrategy.REGEX).toBe('REGEX') + }) + + it('has exactly 6 values', () => { + expect(Object.keys(MaskingStrategy).length).toBe(6) }) })