From c45f3ab717888a244a9ebb3cfa7e9144061d417d Mon Sep 17 00:00:00 2001
From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com>
Date: Mon, 13 Apr 2026 13:40:35 +0000
Subject: [PATCH 1/4] Initial plan
From ddc9925cfda661fb0361eaba537ffe9defb443d3 Mon Sep 17 00:00:00 2001
From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com>
Date: Mon, 13 Apr 2026 14:00:31 +0000
Subject: [PATCH 2/4] Implement Custom Sensitivity Rules feature (backend +
frontend)
Agent-Logs-Url: https://github.com/MaximumTrainer/OpenDataMask/sessions/0ae7fbcf-757d-49e5-a44f-db80e367ae06
Co-authored-by: MaximumTrainer <1376575+MaximumTrainer@users.noreply.github.com>
---
.../rest/CustomSensitivityRuleController.kt | 51 ++
.../CustomSensitivityRuleRepository.kt | 17 +
.../service/CustomSensitivityRuleService.kt | 186 ++++++
.../application/service/PrivacyHubService.kt | 61 +-
.../service/SensitivityScanService.kt | 116 +++-
.../domain/model/ColumnSensitivity.kt | 8 +
.../domain/model/CustomSensitivityRule.kt | 46 ++
.../domain/model/GenericDataType.kt | 5 +
.../input/CustomSensitivityRuleUseCase.kt | 15 +
.../input/dto/CustomSensitivityRuleDto.kt | 44 ++
.../domain/port/input/dto/PrivacyHubDto.kt | 3 +-
.../port/output/CustomSensitivityRulePort.kt | 12 +
.../CustomSensitivityRuleControllerTest.kt | 149 +++++
.../CustomSensitivityRuleServiceTest.kt | 219 +++++++
.../service/PrivacyHubServiceTest.kt | 2 +
.../service/SensitivityScanLogServiceTest.kt | 2 +
.../service/SensitivityScanServiceTest.kt | 2 +
frontend/src/api/sensitivityRules.ts | 46 ++
frontend/src/components/NavBar.vue | 7 +
frontend/src/router/index.ts | 7 +
frontend/src/types/index.ts | 57 ++
frontend/src/views/SensitivityRulesView.vue | 542 ++++++++++++++++++
22 files changed, 1560 insertions(+), 37 deletions(-)
create mode 100644 backend/src/main/kotlin/com/opendatamask/adapter/input/rest/CustomSensitivityRuleController.kt
create mode 100644 backend/src/main/kotlin/com/opendatamask/adapter/output/persistence/CustomSensitivityRuleRepository.kt
create mode 100644 backend/src/main/kotlin/com/opendatamask/application/service/CustomSensitivityRuleService.kt
create mode 100644 backend/src/main/kotlin/com/opendatamask/domain/model/CustomSensitivityRule.kt
create mode 100644 backend/src/main/kotlin/com/opendatamask/domain/model/GenericDataType.kt
create mode 100644 backend/src/main/kotlin/com/opendatamask/domain/port/input/CustomSensitivityRuleUseCase.kt
create mode 100644 backend/src/main/kotlin/com/opendatamask/domain/port/input/dto/CustomSensitivityRuleDto.kt
create mode 100644 backend/src/main/kotlin/com/opendatamask/domain/port/output/CustomSensitivityRulePort.kt
create mode 100644 backend/src/test/kotlin/com/opendatamask/adapter/input/rest/CustomSensitivityRuleControllerTest.kt
create mode 100644 backend/src/test/kotlin/com/opendatamask/application/service/CustomSensitivityRuleServiceTest.kt
create mode 100644 frontend/src/api/sensitivityRules.ts
create mode 100644 frontend/src/views/SensitivityRulesView.vue
diff --git a/backend/src/main/kotlin/com/opendatamask/adapter/input/rest/CustomSensitivityRuleController.kt b/backend/src/main/kotlin/com/opendatamask/adapter/input/rest/CustomSensitivityRuleController.kt
new file mode 100644
index 0000000..9ecfbd3
--- /dev/null
+++ b/backend/src/main/kotlin/com/opendatamask/adapter/input/rest/CustomSensitivityRuleController.kt
@@ -0,0 +1,51 @@
+package com.opendatamask.adapter.input.rest
+
+import com.opendatamask.domain.port.input.dto.CustomRulePreviewRequest
+import com.opendatamask.domain.port.input.dto.CustomRulePreviewResult
+import com.opendatamask.domain.port.input.dto.CustomSensitivityRuleRequest
+import com.opendatamask.domain.port.input.dto.CustomSensitivityRuleResponse
+import com.opendatamask.application.service.CustomSensitivityRuleService
+import org.springframework.http.HttpStatus
+import org.springframework.http.ResponseEntity
+import org.springframework.web.bind.annotation.*
+
+@RestController
+@RequestMapping("/api/sensitivity-rules")
+class CustomSensitivityRuleController(
+ private val customSensitivityRuleService: CustomSensitivityRuleService
+) {
+
+ @GetMapping
+ fun listRules(): ResponseEntity> =
+ ResponseEntity.ok(customSensitivityRuleService.listRules())
+
+ @GetMapping("/{id}")
+ fun getRule(@PathVariable id: Long): ResponseEntity =
+ ResponseEntity.ok(customSensitivityRuleService.getRule(id))
+
+ @PostMapping
+ fun createRule(
+ @RequestBody request: CustomSensitivityRuleRequest
+ ): ResponseEntity =
+ ResponseEntity.status(HttpStatus.CREATED)
+ .body(customSensitivityRuleService.createRule(request))
+
+ @PutMapping("/{id}")
+ fun updateRule(
+ @PathVariable id: Long,
+ @RequestBody request: CustomSensitivityRuleRequest
+ ): ResponseEntity =
+ ResponseEntity.ok(customSensitivityRuleService.updateRule(id, request))
+
+ @DeleteMapping("/{id}")
+ fun deleteRule(@PathVariable id: Long): ResponseEntity {
+ customSensitivityRuleService.deleteRule(id)
+ return ResponseEntity.noContent().build()
+ }
+
+ @PostMapping("/preview")
+ fun previewRule(
+ @RequestBody request: CustomRulePreviewRequest
+ ): ResponseEntity> =
+ ResponseEntity.ok(customSensitivityRuleService.previewRule(request))
+}
diff --git a/backend/src/main/kotlin/com/opendatamask/adapter/output/persistence/CustomSensitivityRuleRepository.kt b/backend/src/main/kotlin/com/opendatamask/adapter/output/persistence/CustomSensitivityRuleRepository.kt
new file mode 100644
index 0000000..7df9e2b
--- /dev/null
+++ b/backend/src/main/kotlin/com/opendatamask/adapter/output/persistence/CustomSensitivityRuleRepository.kt
@@ -0,0 +1,17 @@
+package com.opendatamask.adapter.output.persistence
+
+import com.opendatamask.domain.model.CustomSensitivityRule
+import com.opendatamask.domain.port.output.CustomSensitivityRulePort
+import org.springframework.data.jpa.repository.JpaRepository
+import org.springframework.stereotype.Repository
+import java.util.Optional
+
+@Repository
+interface CustomSensitivityRuleRepository : JpaRepository, CustomSensitivityRulePort {
+ override fun findAll(): List
+ override fun findByIsActiveTrue(): List
+ override fun findById(id: Long): Optional
+ override fun save(rule: CustomSensitivityRule): CustomSensitivityRule
+ override fun deleteById(id: Long)
+ override fun existsByName(name: String): Boolean
+}
diff --git a/backend/src/main/kotlin/com/opendatamask/application/service/CustomSensitivityRuleService.kt b/backend/src/main/kotlin/com/opendatamask/application/service/CustomSensitivityRuleService.kt
new file mode 100644
index 0000000..d959668
--- /dev/null
+++ b/backend/src/main/kotlin/com/opendatamask/application/service/CustomSensitivityRuleService.kt
@@ -0,0 +1,186 @@
+package com.opendatamask.application.service
+
+import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper
+import com.fasterxml.jackson.module.kotlin.readValue
+import com.opendatamask.domain.model.CustomRuleMatcher
+import com.opendatamask.domain.model.CustomSensitivityRule
+import com.opendatamask.domain.model.GenericDataType
+import com.opendatamask.domain.model.MatcherType
+import com.opendatamask.domain.port.input.CustomSensitivityRuleUseCase
+import com.opendatamask.domain.port.input.dto.CustomRuleMatcherDto
+import com.opendatamask.domain.port.input.dto.CustomRulePreviewRequest
+import com.opendatamask.domain.port.input.dto.CustomRulePreviewResult
+import com.opendatamask.domain.port.input.dto.CustomSensitivityRuleRequest
+import com.opendatamask.domain.port.input.dto.CustomSensitivityRuleResponse
+import com.opendatamask.domain.port.output.CustomSensitivityRulePort
+import com.opendatamask.domain.port.output.SchemaSnapshotPort
+import org.springframework.stereotype.Service
+import java.time.Instant
+
+@Service
+class CustomSensitivityRuleService(
+ private val customRuleRepository: CustomSensitivityRulePort,
+ private val schemaSnapshotRepository: SchemaSnapshotPort
+) : CustomSensitivityRuleUseCase {
+
+ private val mapper = jacksonObjectMapper()
+
+ override fun listRules(): List =
+ customRuleRepository.findAll().map { it.toResponse() }
+
+ override fun getRule(id: Long): CustomSensitivityRuleResponse =
+ customRuleRepository.findById(id)
+ .orElseThrow { NoSuchElementException("Custom sensitivity rule not found: $id") }
+ .toResponse()
+
+ override fun createRule(request: CustomSensitivityRuleRequest): CustomSensitivityRuleResponse {
+ if (customRuleRepository.existsByName(request.name)) {
+ throw IllegalArgumentException("A rule with name '${request.name}' already exists")
+ }
+ val rule = CustomSensitivityRule(
+ name = request.name,
+ description = request.description,
+ dataTypeFilter = request.dataTypeFilter,
+ matchersJson = mapper.writeValueAsString(request.matchers.map { it.toModel() }),
+ linkedPresetId = request.linkedPresetId,
+ isActive = request.isActive
+ )
+ return customRuleRepository.save(rule).toResponse()
+ }
+
+ override fun updateRule(id: Long, request: CustomSensitivityRuleRequest): CustomSensitivityRuleResponse {
+ val existing = customRuleRepository.findById(id)
+ .orElseThrow { NoSuchElementException("Custom sensitivity rule not found: $id") }
+ if (existing.name != request.name && customRuleRepository.existsByName(request.name)) {
+ throw IllegalArgumentException("A rule with name '${request.name}' already exists")
+ }
+ existing.name = request.name
+ existing.description = request.description
+ existing.dataTypeFilter = request.dataTypeFilter
+ existing.matchersJson = mapper.writeValueAsString(request.matchers.map { it.toModel() })
+ existing.linkedPresetId = request.linkedPresetId
+ existing.isActive = request.isActive
+ existing.updatedAt = Instant.now()
+ return customRuleRepository.save(existing).toResponse()
+ }
+
+ override fun deleteRule(id: Long) {
+ if (!customRuleRepository.findById(id).isPresent) {
+ throw NoSuchElementException("Custom sensitivity rule not found: $id")
+ }
+ customRuleRepository.deleteById(id)
+ }
+
+ override fun previewRule(request: CustomRulePreviewRequest): List {
+ val snapshot = schemaSnapshotRepository
+ .findTopByWorkspaceIdOrderBySnapshotAtDesc(request.workspaceId)
+ ?: return emptyList()
+
+ val workspaceSchema = try {
+ mapper.readValue(snapshot.schemaJson)
+ } catch (e: Exception) {
+ return emptyList()
+ }
+
+ val matchers = request.matchers.map { it.toModel() }
+ return workspaceSchema.tables.flatMap { table ->
+ table.columns
+ .filter { col ->
+ matchesDataType(col.type, request.dataTypeFilter) &&
+ matchesColumnName(col.name, matchers)
+ }
+ .map { col ->
+ CustomRulePreviewResult(
+ tableName = table.tableName,
+ columnName = col.name,
+ columnType = col.type
+ )
+ }
+ }
+ }
+
+ // ── Internal matching helpers ──────────────────────────────────────────
+
+ fun matchesColumnName(columnName: String, matchers: List): Boolean {
+ if (matchers.isEmpty()) return false
+ return matchers.any { matcher ->
+ val col = if (matcher.caseSensitive) columnName else columnName.lowercase()
+ val value = if (matcher.caseSensitive) matcher.value else matcher.value.lowercase()
+ when (matcher.matcherType) {
+ MatcherType.CONTAINS -> col.contains(value)
+ MatcherType.STARTS_WITH -> col.startsWith(value)
+ MatcherType.ENDS_WITH -> col.endsWith(value)
+ MatcherType.REGEX -> Regex(
+ matcher.value,
+ if (matcher.caseSensitive) emptySet() else setOf(RegexOption.IGNORE_CASE)
+ ).containsMatchIn(columnName)
+ }
+ }
+ }
+
+ fun matchesDataType(columnType: String, filter: GenericDataType): Boolean {
+ if (filter == GenericDataType.ANY) return true
+ return toGenericDataType(columnType) == filter
+ }
+
+ fun toGenericDataType(dbType: String): GenericDataType {
+ val t = dbType.lowercase().replace(Regex("\\s*\\(.*\\)"), "").trim()
+ return when {
+ t.startsWith("varchar") || t.startsWith("nvarchar") || t.startsWith("char") ||
+ t == "text" || t == "tinytext" || t == "mediumtext" || t == "longtext" ||
+ t == "clob" || t == "bpchar" || t == "string" || t == "str" ||
+ t == "uuid" || t == "enum" -> GenericDataType.TEXT
+
+ t == "int" || t == "integer" || t == "bigint" || t == "smallint" || t == "tinyint" ||
+ t.startsWith("decimal") || t.startsWith("numeric") || t.startsWith("float") ||
+ t.startsWith("double") || t == "real" || t == "money" || t == "smallmoney" ||
+ t == "int4" || t == "int8" || t == "int2" || t == "number" || t == "serial" ||
+ t == "bigserial" || t == "smallserial" -> GenericDataType.NUMERIC
+
+ t == "date" || t == "timestamp" || t == "timestamptz" || t == "datetime" ||
+ t == "time" || t == "timetz" || t == "datetime2" || t == "datetimeoffset" -> GenericDataType.DATE
+
+ t == "boolean" || t == "bool" || t == "bit" -> GenericDataType.BOOLEAN
+
+ else -> GenericDataType.ANY
+ }
+ }
+
+ // ── Mapping helpers ────────────────────────────────────────────────────
+
+ private fun CustomRuleMatcherDto.toModel() = CustomRuleMatcher(
+ matcherType = matcherType,
+ value = value,
+ caseSensitive = caseSensitive
+ )
+
+ private fun CustomSensitivityRule.toResponse(): CustomSensitivityRuleResponse {
+ val matchers: List = try {
+ mapper.readValue(matchersJson)
+ } catch (e: Exception) {
+ emptyList()
+ }
+ return CustomSensitivityRuleResponse(
+ id = id ?: 0L,
+ name = name,
+ description = description,
+ dataTypeFilter = dataTypeFilter,
+ matchers = matchers.map { m ->
+ CustomRuleMatcherDto(
+ matcherType = m.matcherType,
+ value = m.value,
+ caseSensitive = m.caseSensitive
+ )
+ },
+ linkedPresetId = linkedPresetId,
+ isActive = isActive,
+ createdAt = createdAt,
+ updatedAt = updatedAt
+ )
+ }
+
+ // Internal data classes for schema JSON parsing
+ private data class SchemaSnapshotSchema(val tables: List)
+ private data class SchemaSnapshotTable(val tableName: String, val columns: List)
+ private data class SchemaSnapshotColumn(val name: String, val type: String, val nullable: Boolean = true)
+}
diff --git a/backend/src/main/kotlin/com/opendatamask/application/service/PrivacyHubService.kt b/backend/src/main/kotlin/com/opendatamask/application/service/PrivacyHubService.kt
index 3e1009d..31ab615 100644
--- a/backend/src/main/kotlin/com/opendatamask/application/service/PrivacyHubService.kt
+++ b/backend/src/main/kotlin/com/opendatamask/application/service/PrivacyHubService.kt
@@ -9,6 +9,7 @@ import com.opendatamask.domain.model.ColumnGenerator
import com.opendatamask.domain.model.GeneratorType
import com.opendatamask.domain.port.output.ColumnGeneratorPort
import com.opendatamask.domain.port.output.ColumnSensitivityPort
+import com.opendatamask.domain.port.output.GeneratorPresetPort
import com.opendatamask.domain.port.output.TableConfigurationPort
import org.springframework.stereotype.Service
@@ -16,7 +17,8 @@ import org.springframework.stereotype.Service
class PrivacyHubService(
private val columnSensitivityRepository: ColumnSensitivityPort,
private val columnGeneratorRepository: ColumnGeneratorPort,
- private val tableConfigurationRepository: TableConfigurationPort
+ private val tableConfigurationRepository: TableConfigurationPort,
+ private val generatorPresetRepository: GeneratorPresetPort
) : PrivacyHubUseCase {
override fun getSummary(workspaceId: Long): PrivacyHubSummary {
@@ -66,9 +68,10 @@ class PrivacyHubService(
PrivacyRecommendation(
tableName = col.tableName,
columnName = col.columnName,
- sensitivityType = col.sensitivityType.name,
+ sensitivityType = col.customSensitivityLabel ?: col.sensitivityType.name,
confidenceLevel = col.confidenceLevel.name,
- recommendedGenerator = col.recommendedGeneratorType?.name ?: ""
+ recommendedGenerator = col.recommendedGeneratorType?.name ?: "",
+ recommendedPresetId = col.recommendedPresetId
)
}
}
@@ -81,20 +84,45 @@ class PrivacyHubService(
var count = 0
for (rec in recommendations) {
val tableConfig = tableConfigMap[rec.tableName] ?: continue
- if (rec.recommendedGenerator.isBlank()) continue
- val generatorType = try {
- GeneratorType.valueOf(rec.recommendedGenerator)
- } catch (e: IllegalArgumentException) {
- continue
- }
- columnGeneratorRepository.save(
- ColumnGenerator(
- tableConfigurationId = tableConfig.id,
- columnName = rec.columnName,
- generatorType = generatorType
+
+ if (rec.recommendedPresetId != null) {
+ // Apply linked preset from a custom sensitivity rule
+ val preset = generatorPresetRepository.findById(rec.recommendedPresetId).orElse(null) ?: continue
+ val existingGenerator = columnGeneratorRepository.findByTableConfigurationId(tableConfig.id)
+ .find { it.columnName == rec.columnName }
+ if (existingGenerator != null) {
+ existingGenerator.presetId = preset.id
+ existingGenerator.generatorType = preset.generatorType
+ existingGenerator.generatorParams = preset.generatorParams
+ columnGeneratorRepository.save(existingGenerator)
+ } else {
+ columnGeneratorRepository.save(
+ ColumnGenerator(
+ tableConfigurationId = tableConfig.id,
+ columnName = rec.columnName,
+ generatorType = preset.generatorType,
+ generatorParams = preset.generatorParams,
+ presetId = preset.id
+ )
+ )
+ }
+ count++
+ } else {
+ if (rec.recommendedGenerator.isBlank()) continue
+ val generatorType = try {
+ GeneratorType.valueOf(rec.recommendedGenerator)
+ } catch (e: IllegalArgumentException) {
+ continue
+ }
+ columnGeneratorRepository.save(
+ ColumnGenerator(
+ tableConfigurationId = tableConfig.id,
+ columnName = rec.columnName,
+ generatorType = generatorType
+ )
)
- )
- count++
+ count++
+ }
}
return count
}
@@ -118,3 +146,4 @@ class PrivacyHubService(
else -> "NOT_SENSITIVE"
}
}
+
diff --git a/backend/src/main/kotlin/com/opendatamask/application/service/SensitivityScanService.kt b/backend/src/main/kotlin/com/opendatamask/application/service/SensitivityScanService.kt
index f20ec37..924838d 100644
--- a/backend/src/main/kotlin/com/opendatamask/application/service/SensitivityScanService.kt
+++ b/backend/src/main/kotlin/com/opendatamask/application/service/SensitivityScanService.kt
@@ -1,11 +1,14 @@
package com.opendatamask.application.service
+import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper
+import com.fasterxml.jackson.module.kotlin.readValue
import com.opendatamask.domain.port.input.SensitivityScanUseCase
import com.opendatamask.domain.port.output.EncryptionPort
import com.opendatamask.domain.port.output.ConnectorFactoryPort
import com.opendatamask.domain.model.*
import com.opendatamask.domain.port.output.ColumnSensitivityPort
+import com.opendatamask.domain.port.output.CustomSensitivityRulePort
import com.opendatamask.domain.port.output.SensitivityScanLogPort
import com.opendatamask.domain.port.output.SensitivityScanLogEntryPort
import com.opendatamask.domain.port.output.WorkspacePort
@@ -23,9 +26,12 @@ class SensitivityScanService(
private val workspaceRepository: WorkspacePort,
private val dataConnectionRepository: DataConnectionPort,
private val connectorFactory: ConnectorFactoryPort,
- private val encryptionPort: EncryptionPort
+ private val encryptionPort: EncryptionPort,
+ private val customSensitivityRuleRepository: CustomSensitivityRulePort,
+ private val customSensitivityRuleService: CustomSensitivityRuleService
) : SensitivityScanUseCase {
- private val rules: List = buildRules()
+ private val builtInRules: List = buildRules()
+ private val mapper = jacksonObjectMapper()
override fun scanWorkspace(workspaceId: Long): SensitivityScanLog {
val log = sensitivityScanLogRepository.save(SensitivityScanLog(workspaceId = workspaceId))
@@ -50,6 +56,16 @@ class SensitivityScanService(
database = sourceConnection.database
)
+ val activeCustomRules = customSensitivityRuleRepository.findByIsActiveTrue()
+ .map { rule ->
+ val matchers: List = try {
+ mapper.readValue(rule.matchersJson)
+ } catch (e: Exception) {
+ emptyList()
+ }
+ rule to matchers
+ }
+
val tables = connector.listTables()
log.tablesScanned = tables.size
@@ -63,8 +79,8 @@ class SensitivityScanService(
} catch (e: Exception) {
emptyList()
}
- val result = detectSensitivity(column, samples)
- if (result != null) {
+ val builtInResult = detectSensitivity(column, samples)
+ if (builtInResult != null) {
log.sensitiveColumnsFound++
val existing = columnSensitivityRepository
.findByWorkspaceIdAndTableNameAndColumnName(workspaceId, table, column)
@@ -74,22 +90,66 @@ class SensitivityScanService(
columnName = column
)
entity.isSensitive = true
- entity.sensitivityType = result.sensitivityType
- entity.confidenceLevel = result.confidence
- entity.recommendedGeneratorType = result.recommendedGenerator
+ entity.sensitivityType = builtInResult.sensitivityType
+ entity.confidenceLevel = builtInResult.confidence
+ entity.recommendedGeneratorType = builtInResult.recommendedGenerator
+ entity.customSensitivityLabel = null
+ entity.recommendedPresetId = null
columnSensitivityRepository.save(entity)
- }
- sensitivityScanLogEntryRepository.save(
- SensitivityScanLogEntry(
- scanLogId = log.id!!,
- tableName = table,
- columnName = column,
- detectedType = result?.sensitivityType?.name,
- confidenceLevel = result?.confidence?.name,
- recommendedGenerator = result?.recommendedGenerator?.name,
- scannedAt = LocalDateTime.now()
+ sensitivityScanLogEntryRepository.save(
+ SensitivityScanLogEntry(
+ scanLogId = log.id!!,
+ tableName = table,
+ columnName = column,
+ detectedType = builtInResult.sensitivityType.name,
+ confidenceLevel = builtInResult.confidence.name,
+ recommendedGenerator = builtInResult.recommendedGenerator.name,
+ scannedAt = LocalDateTime.now()
+ )
)
- )
+ } else {
+ val customMatch = detectCustomRuleSensitivity(column, columnInfo.type, activeCustomRules)
+ if (customMatch != null) {
+ log.sensitiveColumnsFound++
+ val existing = columnSensitivityRepository
+ .findByWorkspaceIdAndTableNameAndColumnName(workspaceId, table, column)
+ val entity = existing ?: ColumnSensitivity(
+ workspaceId = workspaceId,
+ tableName = table,
+ columnName = column
+ )
+ entity.isSensitive = true
+ entity.sensitivityType = SensitivityType.UNKNOWN
+ entity.confidenceLevel = ConfidenceLevel.HIGH
+ entity.recommendedGeneratorType = null
+ entity.customSensitivityLabel = customMatch.first.name
+ entity.recommendedPresetId = customMatch.first.linkedPresetId
+ columnSensitivityRepository.save(entity)
+ sensitivityScanLogEntryRepository.save(
+ SensitivityScanLogEntry(
+ scanLogId = log.id!!,
+ tableName = table,
+ columnName = column,
+ detectedType = customMatch.first.name,
+ confidenceLevel = ConfidenceLevel.HIGH.name,
+ recommendedGenerator = null,
+ scannedAt = LocalDateTime.now()
+ )
+ )
+ } else {
+ sensitivityScanLogEntryRepository.save(
+ SensitivityScanLogEntry(
+ scanLogId = log.id!!,
+ tableName = table,
+ columnName = column,
+ detectedType = null,
+ confidenceLevel = null,
+ recommendedGenerator = null,
+ scannedAt = LocalDateTime.now()
+ )
+ )
+ }
+ }
}
}
log.status = "COMPLETED"
@@ -105,7 +165,7 @@ class SensitivityScanService(
val lowerCol = columnName.lowercase()
// First pass: column name matches (takes priority over value-only matches)
- for (rule in rules) {
+ for (rule in builtInRules) {
val colMatch = rule.columnNamePatterns.any { it.containsMatchIn(lowerCol) }
if (!colMatch) continue
val valMatch = rule.valuePatterns.isNotEmpty() &&
@@ -115,7 +175,7 @@ class SensitivityScanService(
}
// Second pass: value-only matches (lower confidence, no column name signal)
- for (rule in rules) {
+ for (rule in builtInRules) {
if (rule.valuePatterns.isEmpty()) continue
val valMatch = sampleValues.any { sample ->
rule.valuePatterns.any { it.containsMatchIn(sample) }
@@ -126,6 +186,21 @@ class SensitivityScanService(
return null
}
+ /** Returns the matching custom rule and its matchers, or null if no match. */
+ private fun detectCustomRuleSensitivity(
+ columnName: String,
+ columnType: String,
+ activeCustomRules: List>>
+ ): Pair>? {
+ for ((rule, matchers) in activeCustomRules) {
+ if (!customSensitivityRuleService.matchesDataType(columnType, rule.dataTypeFilter)) continue
+ if (customSensitivityRuleService.matchesColumnName(columnName, matchers)) {
+ return rule to matchers
+ }
+ }
+ return null
+ }
+
override fun getLatestLog(workspaceId: Long): SensitivityScanLog? =
sensitivityScanLogRepository.findTopByWorkspaceIdOrderByStartedAtDesc(workspaceId)
@@ -146,3 +221,4 @@ class SensitivityScanService(
}
}
}
+
diff --git a/backend/src/main/kotlin/com/opendatamask/domain/model/ColumnSensitivity.kt b/backend/src/main/kotlin/com/opendatamask/domain/model/ColumnSensitivity.kt
index 5e72063..c88eca5 100644
--- a/backend/src/main/kotlin/com/opendatamask/domain/model/ColumnSensitivity.kt
+++ b/backend/src/main/kotlin/com/opendatamask/domain/model/ColumnSensitivity.kt
@@ -36,6 +36,14 @@ class ColumnSensitivity(
@Enumerated(EnumType.STRING)
var recommendedGeneratorType: GeneratorType? = null,
+ /** Set when this column was matched by a custom sensitivity rule (stores the rule's name). */
+ @Column(name = "custom_sensitivity_label")
+ var customSensitivityLabel: String? = null,
+
+ /** Set when the matched custom rule has a linked Generator Preset. */
+ @Column(name = "recommended_preset_id")
+ var recommendedPresetId: Long? = null,
+
@Column(nullable = false)
val detectedAt: Instant = Instant.now()
)
diff --git a/backend/src/main/kotlin/com/opendatamask/domain/model/CustomSensitivityRule.kt b/backend/src/main/kotlin/com/opendatamask/domain/model/CustomSensitivityRule.kt
new file mode 100644
index 0000000..326faac
--- /dev/null
+++ b/backend/src/main/kotlin/com/opendatamask/domain/model/CustomSensitivityRule.kt
@@ -0,0 +1,46 @@
+package com.opendatamask.domain.model
+
+import jakarta.persistence.*
+import java.time.Instant
+
+enum class MatcherType {
+ CONTAINS, STARTS_WITH, ENDS_WITH, REGEX
+}
+
+data class CustomRuleMatcher(
+ val matcherType: MatcherType,
+ val value: String,
+ val caseSensitive: Boolean = false
+)
+
+@Entity
+@Table(name = "custom_sensitivity_rules")
+class CustomSensitivityRule(
+ @Id
+ @GeneratedValue(strategy = GenerationType.IDENTITY)
+ val id: Long? = null,
+
+ @Column(nullable = false, unique = true)
+ var name: String,
+
+ @Column(columnDefinition = "TEXT")
+ var description: String? = null,
+
+ @Enumerated(EnumType.STRING)
+ @Column(nullable = false)
+ var dataTypeFilter: GenericDataType = GenericDataType.ANY,
+
+ @Column(columnDefinition = "TEXT", nullable = false)
+ var matchersJson: String = "[]",
+
+ var linkedPresetId: Long? = null,
+
+ @Column(nullable = false)
+ var isActive: Boolean = true,
+
+ @Column(nullable = false)
+ val createdAt: Instant = Instant.now(),
+
+ @Column(nullable = false)
+ var updatedAt: Instant = Instant.now()
+)
diff --git a/backend/src/main/kotlin/com/opendatamask/domain/model/GenericDataType.kt b/backend/src/main/kotlin/com/opendatamask/domain/model/GenericDataType.kt
new file mode 100644
index 0000000..7c3b008
--- /dev/null
+++ b/backend/src/main/kotlin/com/opendatamask/domain/model/GenericDataType.kt
@@ -0,0 +1,5 @@
+package com.opendatamask.domain.model
+
+enum class GenericDataType {
+ TEXT, NUMERIC, DATE, BOOLEAN, ANY
+}
diff --git a/backend/src/main/kotlin/com/opendatamask/domain/port/input/CustomSensitivityRuleUseCase.kt b/backend/src/main/kotlin/com/opendatamask/domain/port/input/CustomSensitivityRuleUseCase.kt
new file mode 100644
index 0000000..3b3294b
--- /dev/null
+++ b/backend/src/main/kotlin/com/opendatamask/domain/port/input/CustomSensitivityRuleUseCase.kt
@@ -0,0 +1,15 @@
+package com.opendatamask.domain.port.input
+
+import com.opendatamask.domain.port.input.dto.CustomRulePreviewRequest
+import com.opendatamask.domain.port.input.dto.CustomRulePreviewResult
+import com.opendatamask.domain.port.input.dto.CustomSensitivityRuleRequest
+import com.opendatamask.domain.port.input.dto.CustomSensitivityRuleResponse
+
+interface CustomSensitivityRuleUseCase {
+ fun listRules(): List
+ fun getRule(id: Long): CustomSensitivityRuleResponse
+ fun createRule(request: CustomSensitivityRuleRequest): CustomSensitivityRuleResponse
+ fun updateRule(id: Long, request: CustomSensitivityRuleRequest): CustomSensitivityRuleResponse
+ fun deleteRule(id: Long)
+ fun previewRule(request: CustomRulePreviewRequest): List
+}
diff --git a/backend/src/main/kotlin/com/opendatamask/domain/port/input/dto/CustomSensitivityRuleDto.kt b/backend/src/main/kotlin/com/opendatamask/domain/port/input/dto/CustomSensitivityRuleDto.kt
new file mode 100644
index 0000000..6dccd88
--- /dev/null
+++ b/backend/src/main/kotlin/com/opendatamask/domain/port/input/dto/CustomSensitivityRuleDto.kt
@@ -0,0 +1,44 @@
+package com.opendatamask.domain.port.input.dto
+
+import com.opendatamask.domain.model.GenericDataType
+import com.opendatamask.domain.model.MatcherType
+import java.time.Instant
+
+data class CustomRuleMatcherDto(
+ val matcherType: MatcherType,
+ val value: String,
+ val caseSensitive: Boolean = false
+)
+
+data class CustomSensitivityRuleRequest(
+ val name: String,
+ val description: String? = null,
+ val dataTypeFilter: GenericDataType = GenericDataType.ANY,
+ val matchers: List = emptyList(),
+ val linkedPresetId: Long? = null,
+ val isActive: Boolean = true
+)
+
+data class CustomSensitivityRuleResponse(
+ val id: Long,
+ val name: String,
+ val description: String?,
+ val dataTypeFilter: GenericDataType,
+ val matchers: List,
+ val linkedPresetId: Long?,
+ val isActive: Boolean,
+ val createdAt: Instant,
+ val updatedAt: Instant
+)
+
+data class CustomRulePreviewRequest(
+ val workspaceId: Long,
+ val dataTypeFilter: GenericDataType = GenericDataType.ANY,
+ val matchers: List = emptyList()
+)
+
+data class CustomRulePreviewResult(
+ val tableName: String,
+ val columnName: String,
+ val columnType: String
+)
diff --git a/backend/src/main/kotlin/com/opendatamask/domain/port/input/dto/PrivacyHubDto.kt b/backend/src/main/kotlin/com/opendatamask/domain/port/input/dto/PrivacyHubDto.kt
index f5117fc..1a18aa4 100644
--- a/backend/src/main/kotlin/com/opendatamask/domain/port/input/dto/PrivacyHubDto.kt
+++ b/backend/src/main/kotlin/com/opendatamask/domain/port/input/dto/PrivacyHubDto.kt
@@ -20,5 +20,6 @@ data class PrivacyRecommendation(
val columnName: String,
val sensitivityType: String,
val confidenceLevel: String,
- val recommendedGenerator: String
+ val recommendedGenerator: String,
+ val recommendedPresetId: Long? = null
)
diff --git a/backend/src/main/kotlin/com/opendatamask/domain/port/output/CustomSensitivityRulePort.kt b/backend/src/main/kotlin/com/opendatamask/domain/port/output/CustomSensitivityRulePort.kt
new file mode 100644
index 0000000..9a7f55b
--- /dev/null
+++ b/backend/src/main/kotlin/com/opendatamask/domain/port/output/CustomSensitivityRulePort.kt
@@ -0,0 +1,12 @@
+package com.opendatamask.domain.port.output
+
+import com.opendatamask.domain.model.CustomSensitivityRule
+
+interface CustomSensitivityRulePort {
+ fun findAll(): List
+ fun findByIsActiveTrue(): List
+ fun findById(id: Long): java.util.Optional
+ fun save(rule: CustomSensitivityRule): CustomSensitivityRule
+ fun deleteById(id: Long)
+ fun existsByName(name: String): Boolean
+}
diff --git a/backend/src/test/kotlin/com/opendatamask/adapter/input/rest/CustomSensitivityRuleControllerTest.kt b/backend/src/test/kotlin/com/opendatamask/adapter/input/rest/CustomSensitivityRuleControllerTest.kt
new file mode 100644
index 0000000..9369ea1
--- /dev/null
+++ b/backend/src/test/kotlin/com/opendatamask/adapter/input/rest/CustomSensitivityRuleControllerTest.kt
@@ -0,0 +1,149 @@
+package com.opendatamask.adapter.input.rest
+
+import com.opendatamask.domain.model.GenericDataType
+import com.opendatamask.domain.model.MatcherType
+import com.opendatamask.domain.port.input.dto.CustomRuleMatcherDto
+import com.opendatamask.domain.port.input.dto.CustomRulePreviewResult
+import com.opendatamask.domain.port.input.dto.CustomSensitivityRuleResponse
+import com.opendatamask.application.service.CustomSensitivityRuleService
+import com.opendatamask.infrastructure.security.JwtTokenProvider
+import com.opendatamask.infrastructure.security.UserDetailsServiceImpl
+import org.junit.jupiter.api.Test
+import org.mockito.kotlin.*
+import org.springframework.beans.factory.annotation.Autowired
+import org.springframework.boot.autoconfigure.security.servlet.SecurityAutoConfiguration
+import org.springframework.boot.autoconfigure.security.servlet.SecurityFilterAutoConfiguration
+import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest
+import org.springframework.boot.test.mock.mockito.MockBean
+import org.springframework.http.MediaType
+import org.springframework.test.context.ActiveProfiles
+import org.springframework.test.web.servlet.MockMvc
+import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*
+import org.springframework.test.web.servlet.result.MockMvcResultMatchers.*
+import java.time.Instant
+
+@WebMvcTest(
+ CustomSensitivityRuleController::class,
+ excludeAutoConfiguration = [SecurityAutoConfiguration::class, SecurityFilterAutoConfiguration::class]
+)
+@ActiveProfiles("test")
+class CustomSensitivityRuleControllerTest {
+
+ @Autowired private lateinit var mockMvc: MockMvc
+
+ @MockBean private lateinit var customSensitivityRuleService: CustomSensitivityRuleService
+ @MockBean private lateinit var jwtTokenProvider: JwtTokenProvider
+ @MockBean private lateinit var userDetailsServiceImpl: UserDetailsServiceImpl
+
+ private fun makeRuleResponse(id: Long = 1L) = CustomSensitivityRuleResponse(
+ id = id,
+ name = "Internal_ID",
+ description = "Matches internal ID columns",
+ dataTypeFilter = GenericDataType.NUMERIC,
+ matchers = listOf(
+ CustomRuleMatcherDto(matcherType = MatcherType.CONTAINS, value = "uid", caseSensitive = false)
+ ),
+ linkedPresetId = null,
+ isActive = true,
+ createdAt = Instant.now(),
+ updatedAt = Instant.now()
+ )
+
+ @Test
+ fun `GET list rules returns 200 with list`() {
+ whenever(customSensitivityRuleService.listRules()).thenReturn(listOf(makeRuleResponse()))
+
+ mockMvc.perform(get("/api/sensitivity-rules"))
+ .andExpect(status().isOk)
+ .andExpect(jsonPath("$.length()").value(1))
+ .andExpect(jsonPath("$[0].name").value("Internal_ID"))
+ }
+
+ @Test
+ fun `GET single rule returns 200`() {
+ whenever(customSensitivityRuleService.getRule(1L)).thenReturn(makeRuleResponse())
+
+ mockMvc.perform(get("/api/sensitivity-rules/1"))
+ .andExpect(status().isOk)
+ .andExpect(jsonPath("$.id").value(1))
+ .andExpect(jsonPath("$.dataTypeFilter").value("NUMERIC"))
+ }
+
+ @Test
+ fun `GET single rule returns 404 when not found`() {
+ whenever(customSensitivityRuleService.getRule(99L))
+ .thenThrow(NoSuchElementException("Not found"))
+
+ mockMvc.perform(get("/api/sensitivity-rules/99"))
+ .andExpect(status().isNotFound)
+ }
+
+ @Test
+ fun `POST create rule returns 201`() {
+ whenever(customSensitivityRuleService.createRule(any())).thenReturn(makeRuleResponse())
+
+ mockMvc.perform(
+ post("/api/sensitivity-rules")
+ .contentType(MediaType.APPLICATION_JSON)
+ .content(
+ """{"name":"Internal_ID","dataTypeFilter":"NUMERIC",
+ |"matchers":[{"matcherType":"CONTAINS","value":"uid","caseSensitive":false}]}""".trimMargin()
+ )
+ )
+ .andExpect(status().isCreated)
+ .andExpect(jsonPath("$.name").value("Internal_ID"))
+ }
+
+ @Test
+ fun `PUT update rule returns 200`() {
+ whenever(customSensitivityRuleService.updateRule(eq(1L), any())).thenReturn(makeRuleResponse())
+
+ mockMvc.perform(
+ put("/api/sensitivity-rules/1")
+ .contentType(MediaType.APPLICATION_JSON)
+ .content("""{"name":"Internal_ID","dataTypeFilter":"NUMERIC","matchers":[]}""")
+ )
+ .andExpect(status().isOk)
+ .andExpect(jsonPath("$.id").value(1))
+ }
+
+ @Test
+ fun `DELETE rule returns 204`() {
+ doNothing().whenever(customSensitivityRuleService).deleteRule(1L)
+
+ mockMvc.perform(delete("/api/sensitivity-rules/1"))
+ .andExpect(status().isNoContent)
+ }
+
+ @Test
+ fun `DELETE rule returns 404 when not found`() {
+ whenever(customSensitivityRuleService.deleteRule(99L))
+ .thenThrow(NoSuchElementException("Not found"))
+
+ mockMvc.perform(delete("/api/sensitivity-rules/99"))
+ .andExpect(status().isNotFound)
+ }
+
+ @Test
+ fun `POST preview returns 200 with matched columns`() {
+ val results = listOf(
+ CustomRulePreviewResult("users", "user_id", "integer"),
+ CustomRulePreviewResult("transactions", "tx_id", "integer")
+ )
+ whenever(customSensitivityRuleService.previewRule(any())).thenReturn(results)
+
+ mockMvc.perform(
+ post("/api/sensitivity-rules/preview")
+ .contentType(MediaType.APPLICATION_JSON)
+ .content(
+ """{"workspaceId":1,"dataTypeFilter":"NUMERIC",
+ |"matchers":[{"matcherType":"ENDS_WITH","value":"id","caseSensitive":false}]}""".trimMargin()
+ )
+ )
+ .andExpect(status().isOk)
+ .andExpect(jsonPath("$.length()").value(2))
+ .andExpect(jsonPath("$[0].tableName").value("users"))
+ .andExpect(jsonPath("$[0].columnName").value("user_id"))
+ .andExpect(jsonPath("$[1].columnName").value("tx_id"))
+ }
+}
diff --git a/backend/src/test/kotlin/com/opendatamask/application/service/CustomSensitivityRuleServiceTest.kt b/backend/src/test/kotlin/com/opendatamask/application/service/CustomSensitivityRuleServiceTest.kt
new file mode 100644
index 0000000..fb3235d
--- /dev/null
+++ b/backend/src/test/kotlin/com/opendatamask/application/service/CustomSensitivityRuleServiceTest.kt
@@ -0,0 +1,219 @@
+package com.opendatamask.application.service
+
+import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper
+import com.opendatamask.domain.model.CustomRuleMatcher
+import com.opendatamask.domain.model.CustomSensitivityRule
+import com.opendatamask.domain.model.GenericDataType
+import com.opendatamask.domain.model.MatcherType
+import com.opendatamask.domain.model.SchemaSnapshot
+import com.opendatamask.domain.port.input.dto.CustomRuleMatcherDto
+import com.opendatamask.domain.port.input.dto.CustomRulePreviewRequest
+import com.opendatamask.domain.port.input.dto.CustomSensitivityRuleRequest
+import com.opendatamask.domain.port.output.CustomSensitivityRulePort
+import com.opendatamask.domain.port.output.SchemaSnapshotPort
+import org.junit.jupiter.api.Assertions.*
+import org.junit.jupiter.api.Test
+import org.junit.jupiter.api.extension.ExtendWith
+import org.mockito.InjectMocks
+import org.mockito.Mock
+import org.mockito.junit.jupiter.MockitoExtension
+import org.mockito.kotlin.*
+import java.util.Optional
+
+@ExtendWith(MockitoExtension::class)
+class CustomSensitivityRuleServiceTest {
+
+ @Mock private lateinit var customRuleRepository: CustomSensitivityRulePort
+ @Mock private lateinit var schemaSnapshotRepository: SchemaSnapshotPort
+
+ @InjectMocks
+ private lateinit var service: CustomSensitivityRuleService
+
+ private val mapper = jacksonObjectMapper()
+
+ // ── matchesColumnName tests ────────────────────────────────────────────
+
+ @Test
+ fun `matchesColumnName CONTAINS matches when value is in column name`() {
+ val matchers = listOf(CustomRuleMatcher(MatcherType.CONTAINS, "uid", false))
+ assertTrue(service.matchesColumnName("user_uid_col", matchers))
+ }
+
+ @Test
+ fun `matchesColumnName CONTAINS does not match when value absent`() {
+ val matchers = listOf(CustomRuleMatcher(MatcherType.CONTAINS, "uid", false))
+ assertFalse(service.matchesColumnName("email_address", matchers))
+ }
+
+ @Test
+ fun `matchesColumnName STARTS_WITH matches prefix`() {
+ val matchers = listOf(CustomRuleMatcher(MatcherType.STARTS_WITH, "user", false))
+ assertTrue(service.matchesColumnName("user_id", matchers))
+ }
+
+ @Test
+ fun `matchesColumnName ENDS_WITH matches suffix`() {
+ val matchers = listOf(CustomRuleMatcher(MatcherType.ENDS_WITH, "id", false))
+ assertTrue(service.matchesColumnName("user_id", matchers))
+ assertTrue(service.matchesColumnName("tx_id", matchers))
+ assertFalse(service.matchesColumnName("email_address", matchers))
+ }
+
+ @Test
+ fun `matchesColumnName REGEX matches pattern`() {
+ val matchers = listOf(CustomRuleMatcher(MatcherType.REGEX, ".*_id$", false))
+ assertTrue(service.matchesColumnName("user_id", matchers))
+ assertFalse(service.matchesColumnName("user_email", matchers))
+ }
+
+ @Test
+ fun `matchesColumnName is case-insensitive by default`() {
+ val matchers = listOf(CustomRuleMatcher(MatcherType.CONTAINS, "UID", false))
+ assertTrue(service.matchesColumnName("user_uid", matchers))
+ }
+
+ @Test
+ fun `matchesColumnName respects case-sensitive flag`() {
+ val matchers = listOf(CustomRuleMatcher(MatcherType.CONTAINS, "UID", caseSensitive = true))
+ assertFalse(service.matchesColumnName("user_uid", matchers))
+ assertTrue(service.matchesColumnName("user_UID", matchers))
+ }
+
+ @Test
+ fun `matchesColumnName returns false when matchers list is empty`() {
+ assertFalse(service.matchesColumnName("user_id", emptyList()))
+ }
+
+ // ── toGenericDataType tests ────────────────────────────────────────────
+
+ @Test
+ fun `toGenericDataType maps TEXT types correctly`() {
+ assertEquals(GenericDataType.TEXT, service.toGenericDataType("varchar"))
+ assertEquals(GenericDataType.TEXT, service.toGenericDataType("VARCHAR(255)"))
+ assertEquals(GenericDataType.TEXT, service.toGenericDataType("text"))
+ assertEquals(GenericDataType.TEXT, service.toGenericDataType("string"))
+ }
+
+ @Test
+ fun `toGenericDataType maps NUMERIC types correctly`() {
+ assertEquals(GenericDataType.NUMERIC, service.toGenericDataType("integer"))
+ assertEquals(GenericDataType.NUMERIC, service.toGenericDataType("bigint"))
+ assertEquals(GenericDataType.NUMERIC, service.toGenericDataType("decimal(10,2)"))
+ assertEquals(GenericDataType.NUMERIC, service.toGenericDataType("int"))
+ }
+
+ @Test
+ fun `toGenericDataType maps DATE types correctly`() {
+ assertEquals(GenericDataType.DATE, service.toGenericDataType("date"))
+ assertEquals(GenericDataType.DATE, service.toGenericDataType("timestamp"))
+ assertEquals(GenericDataType.DATE, service.toGenericDataType("datetime"))
+ }
+
+ @Test
+ fun `toGenericDataType maps BOOLEAN types correctly`() {
+ assertEquals(GenericDataType.BOOLEAN, service.toGenericDataType("boolean"))
+ assertEquals(GenericDataType.BOOLEAN, service.toGenericDataType("bool"))
+ }
+
+ @Test
+ fun `toGenericDataType returns ANY for unknown types`() {
+ assertEquals(GenericDataType.ANY, service.toGenericDataType("bytea"))
+ assertEquals(GenericDataType.ANY, service.toGenericDataType("json"))
+ }
+
+ // ── matchesDataType tests ──────────────────────────────────────────────
+
+ @Test
+ fun `matchesDataType ANY always returns true`() {
+ assertTrue(service.matchesDataType("integer", GenericDataType.ANY))
+ assertTrue(service.matchesDataType("varchar", GenericDataType.ANY))
+ }
+
+ @Test
+ fun `matchesDataType returns false for mismatched type`() {
+ assertFalse(service.matchesDataType("varchar", GenericDataType.NUMERIC))
+ assertFalse(service.matchesDataType("integer", GenericDataType.TEXT))
+ }
+
+ // ── previewRule tests ─────────────────────────────────────────────────
+
+ @Test
+ fun `previewRule returns matching columns from schema snapshot`() {
+ val schemaJson = """
+ {"tables":[
+ {"tableName":"users","columns":[
+ {"name":"user_id","type":"integer","nullable":false},
+ {"name":"email","type":"varchar","nullable":true}
+ ]},
+ {"tableName":"transactions","columns":[
+ {"name":"tx_id","type":"integer","nullable":false},
+ {"name":"amount","type":"decimal","nullable":true}
+ ]}
+ ]}
+ """.trimIndent()
+ val snapshot = SchemaSnapshot(workspaceId = 1L, schemaJson = schemaJson)
+ whenever(schemaSnapshotRepository.findTopByWorkspaceIdOrderBySnapshotAtDesc(1L))
+ .thenReturn(snapshot)
+
+ val request = CustomRulePreviewRequest(
+ workspaceId = 1L,
+ dataTypeFilter = GenericDataType.NUMERIC,
+ matchers = listOf(CustomRuleMatcherDto(MatcherType.ENDS_WITH, "id", false))
+ )
+ val results = service.previewRule(request)
+
+ assertEquals(2, results.size)
+ assertTrue(results.any { it.tableName == "users" && it.columnName == "user_id" })
+ assertTrue(results.any { it.tableName == "transactions" && it.columnName == "tx_id" })
+ }
+
+ @Test
+ fun `previewRule returns empty list when no snapshot exists`() {
+ whenever(schemaSnapshotRepository.findTopByWorkspaceIdOrderBySnapshotAtDesc(1L))
+ .thenReturn(null)
+
+ val request = CustomRulePreviewRequest(
+ workspaceId = 1L,
+ matchers = listOf(CustomRuleMatcherDto(MatcherType.CONTAINS, "id", false))
+ )
+ val results = service.previewRule(request)
+ assertTrue(results.isEmpty())
+ }
+
+ // ── createRule tests ──────────────────────────────────────────────────
+
+ @Test
+ fun `createRule saves and returns rule`() {
+ whenever(customRuleRepository.existsByName("Internal_ID")).thenReturn(false)
+ val saved = CustomSensitivityRule(
+ id = 1L,
+ name = "Internal_ID",
+ dataTypeFilter = GenericDataType.NUMERIC,
+ matchersJson = """[{"matcherType":"CONTAINS","value":"uid","caseSensitive":false}]"""
+ )
+ whenever(customRuleRepository.save(any())).thenReturn(saved)
+
+ val request = CustomSensitivityRuleRequest(
+ name = "Internal_ID",
+ dataTypeFilter = GenericDataType.NUMERIC,
+ matchers = listOf(CustomRuleMatcherDto(MatcherType.CONTAINS, "uid", false))
+ )
+ val response = service.createRule(request)
+ assertEquals("Internal_ID", response.name)
+ assertEquals(GenericDataType.NUMERIC, response.dataTypeFilter)
+ }
+
+ @Test
+ fun `createRule throws when name already exists`() {
+ whenever(customRuleRepository.existsByName("Internal_ID")).thenReturn(true)
+
+ val request = CustomSensitivityRuleRequest(name = "Internal_ID")
+ assertThrows(IllegalArgumentException::class.java) { service.createRule(request) }
+ }
+
+ @Test
+ fun `deleteRule throws when rule not found`() {
+ whenever(customRuleRepository.findById(99L)).thenReturn(Optional.empty())
+ assertThrows(NoSuchElementException::class.java) { service.deleteRule(99L) }
+ }
+}
diff --git a/backend/src/test/kotlin/com/opendatamask/application/service/PrivacyHubServiceTest.kt b/backend/src/test/kotlin/com/opendatamask/application/service/PrivacyHubServiceTest.kt
index d58d0e1..0b46282 100644
--- a/backend/src/test/kotlin/com/opendatamask/application/service/PrivacyHubServiceTest.kt
+++ b/backend/src/test/kotlin/com/opendatamask/application/service/PrivacyHubServiceTest.kt
@@ -3,6 +3,7 @@ package com.opendatamask.application.service
import com.opendatamask.domain.port.input.dto.PrivacyHubSummary
import com.opendatamask.domain.model.*
import com.opendatamask.adapter.output.persistence.*
+import com.opendatamask.domain.port.output.GeneratorPresetPort
import org.junit.jupiter.api.Assertions.*
import org.junit.jupiter.api.Test
import org.junit.jupiter.api.extension.ExtendWith
@@ -17,6 +18,7 @@ class PrivacyHubServiceTest {
@Mock private lateinit var columnSensitivityRepository: ColumnSensitivityRepository
@Mock private lateinit var columnGeneratorRepository: ColumnGeneratorRepository
@Mock private lateinit var tableConfigurationRepository: TableConfigurationRepository
+ @Mock private lateinit var generatorPresetRepository: GeneratorPresetPort
@InjectMocks
private lateinit var service: PrivacyHubService
diff --git a/backend/src/test/kotlin/com/opendatamask/application/service/SensitivityScanLogServiceTest.kt b/backend/src/test/kotlin/com/opendatamask/application/service/SensitivityScanLogServiceTest.kt
index 2403d6c..9e4fb7f 100644
--- a/backend/src/test/kotlin/com/opendatamask/application/service/SensitivityScanLogServiceTest.kt
+++ b/backend/src/test/kotlin/com/opendatamask/application/service/SensitivityScanLogServiceTest.kt
@@ -25,6 +25,8 @@ class SensitivityScanLogServiceTest {
@Mock private lateinit var dataConnectionRepository: DataConnectionRepository
@Mock private lateinit var connectorFactory: ConnectorFactory
@Mock private lateinit var EncryptionPort: EncryptionPort
+ @Mock private lateinit var customSensitivityRuleRepository: CustomSensitivityRuleRepository
+ @Mock private lateinit var customSensitivityRuleService: CustomSensitivityRuleService
@InjectMocks
private lateinit var sensitivityScanService: SensitivityScanService
diff --git a/backend/src/test/kotlin/com/opendatamask/application/service/SensitivityScanServiceTest.kt b/backend/src/test/kotlin/com/opendatamask/application/service/SensitivityScanServiceTest.kt
index 82f76ad..8b0c644 100644
--- a/backend/src/test/kotlin/com/opendatamask/application/service/SensitivityScanServiceTest.kt
+++ b/backend/src/test/kotlin/com/opendatamask/application/service/SensitivityScanServiceTest.kt
@@ -25,6 +25,8 @@ class SensitivityScanServiceTest {
@Mock private lateinit var dataConnectionRepository: DataConnectionPort
@Mock private lateinit var connectorFactory: ConnectorFactoryPort
@Mock private lateinit var EncryptionPort: EncryptionPort
+ @Mock private lateinit var customSensitivityRuleRepository: CustomSensitivityRulePort
+ @Mock private lateinit var customSensitivityRuleService: CustomSensitivityRuleService
@InjectMocks
private lateinit var sensitivityScanService: SensitivityScanService
diff --git a/frontend/src/api/sensitivityRules.ts b/frontend/src/api/sensitivityRules.ts
new file mode 100644
index 0000000..6b48b13
--- /dev/null
+++ b/frontend/src/api/sensitivityRules.ts
@@ -0,0 +1,46 @@
+import apiClient from './client'
+import type {
+ CustomSensitivityRule,
+ CustomSensitivityRuleRequest,
+ CustomRulePreviewRequest,
+ CustomRulePreviewResult
+} from '@/types'
+
+export async function listSensitivityRules(): Promise {
+ const { data } = await apiClient.get('/sensitivity-rules')
+ return data
+}
+
+export async function getSensitivityRule(id: number): Promise {
+ const { data } = await apiClient.get(`/sensitivity-rules/${id}`)
+ return data
+}
+
+export async function createSensitivityRule(
+ payload: CustomSensitivityRuleRequest
+): Promise {
+ const { data } = await apiClient.post('/sensitivity-rules', payload)
+ return data
+}
+
+export async function updateSensitivityRule(
+ id: number,
+ payload: CustomSensitivityRuleRequest
+): Promise {
+ const { data } = await apiClient.put(`/sensitivity-rules/${id}`, payload)
+ return data
+}
+
+export async function deleteSensitivityRule(id: number): Promise {
+ await apiClient.delete(`/sensitivity-rules/${id}`)
+}
+
+export async function previewSensitivityRule(
+ payload: CustomRulePreviewRequest
+): Promise {
+ const { data } = await apiClient.post(
+ '/sensitivity-rules/preview',
+ payload
+ )
+ return data
+}
diff --git a/frontend/src/components/NavBar.vue b/frontend/src/components/NavBar.vue
index 067aa41..419e965 100644
--- a/frontend/src/components/NavBar.vue
+++ b/frontend/src/components/NavBar.vue
@@ -34,6 +34,13 @@ function isActive(path: string): boolean {
>
Workspaces
+
+ Sensitivity Rules
+
diff --git a/frontend/src/router/index.ts b/frontend/src/router/index.ts
index 9310537..3f1acb4 100644
--- a/frontend/src/router/index.ts
+++ b/frontend/src/router/index.ts
@@ -9,6 +9,7 @@ import ConnectionsView from '@/views/ConnectionsView.vue'
import TablesView from '@/views/TablesView.vue'
import JobsView from '@/views/JobsView.vue'
import ActionsView from '@/views/ActionsView.vue'
+import SensitivityRulesView from '@/views/SensitivityRulesView.vue'
const router = createRouter({
history: createWebHistory(import.meta.env.BASE_URL),
@@ -67,6 +68,12 @@ const router = createRouter({
name: 'actions',
component: ActionsView,
meta: { requiresAuth: true }
+ },
+ {
+ path: '/settings/sensitivity-rules',
+ name: 'sensitivity-rules',
+ component: SensitivityRulesView,
+ meta: { requiresAuth: true }
}
]
})
diff --git a/frontend/src/types/index.ts b/frontend/src/types/index.ts
index f8901ea..995aea8 100644
--- a/frontend/src/types/index.ts
+++ b/frontend/src/types/index.ts
@@ -349,3 +349,60 @@ export interface WorkspaceConfigDto {
tables: WorkspaceTableConfigExport[]
actions: WorkspaceActionExport[]
}
+
+// ── Custom Sensitivity Rules ───────────────────────────────────────────────
+
+export enum GenericDataType {
+ TEXT = 'TEXT',
+ NUMERIC = 'NUMERIC',
+ DATE = 'DATE',
+ BOOLEAN = 'BOOLEAN',
+ ANY = 'ANY'
+}
+
+export enum MatcherType {
+ CONTAINS = 'CONTAINS',
+ STARTS_WITH = 'STARTS_WITH',
+ ENDS_WITH = 'ENDS_WITH',
+ REGEX = 'REGEX'
+}
+
+export interface CustomRuleMatcher {
+ matcherType: MatcherType
+ value: string
+ caseSensitive: boolean
+}
+
+export interface CustomSensitivityRule {
+ id: number
+ name: string
+ description: string | null
+ dataTypeFilter: GenericDataType
+ matchers: CustomRuleMatcher[]
+ linkedPresetId: number | null
+ isActive: boolean
+ createdAt: string
+ updatedAt: string
+}
+
+export interface CustomSensitivityRuleRequest {
+ name: string
+ description?: string | null
+ dataTypeFilter: GenericDataType
+ matchers: CustomRuleMatcher[]
+ linkedPresetId?: number | null
+ isActive: boolean
+}
+
+export interface CustomRulePreviewRequest {
+ workspaceId: number
+ dataTypeFilter: GenericDataType
+ matchers: CustomRuleMatcher[]
+}
+
+export interface CustomRulePreviewResult {
+ tableName: string
+ columnName: string
+ columnType: string
+}
+
diff --git a/frontend/src/views/SensitivityRulesView.vue b/frontend/src/views/SensitivityRulesView.vue
new file mode 100644
index 0000000..0b86687
--- /dev/null
+++ b/frontend/src/views/SensitivityRulesView.vue
@@ -0,0 +1,542 @@
+
+
+
+
+
+
+
+
{{ error }}
+
+
+
+
+
+
+
🔍
+
No custom sensitivity rules yet
+
+ Create rules to automatically detect sensitive columns by name pattern and data type.
+
+
+
+
+
+
+
+
+
+ | Name |
+ Data Type |
+ Matchers |
+ Linked Preset |
+ Status |
+ Actions |
+
+
+
+
+ | {{ rule.name }} |
+
+ {{ rule.dataTypeFilter }}
+ |
+
+
+ {{ m.matcherType.toLowerCase().replace('_', ' ') }}: {{ m.value }}
+
+ —
+ |
+ {{ presetName(rule.linkedPresetId) }} |
+
+
+ {{ rule.isActive ? 'Active' : 'Inactive' }}
+
+ |
+
+
+
+
+
+ |
+
+
+
+
+
+
+
+
+
+
+
+
+
From 1046fd96a6bbbcadf75bb4a144584e7ac9bd0ed1 Mon Sep 17 00:00:00 2001
From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com>
Date: Mon, 13 Apr 2026 14:06:31 +0000
Subject: [PATCH 3/4] Address code review feedback: add logging for silent
failures, improve readability
Agent-Logs-Url: https://github.com/MaximumTrainer/OpenDataMask/sessions/0ae7fbcf-757d-49e5-a44f-db80e367ae06
Co-authored-by: MaximumTrainer <1376575+MaximumTrainer@users.noreply.github.com>
---
.../service/CustomSensitivityRuleService.kt | 3 +++
.../application/service/PrivacyHubService.kt | 11 ++++++++++-
.../application/service/SensitivityScanService.kt | 10 +++++++---
3 files changed, 20 insertions(+), 4 deletions(-)
diff --git a/backend/src/main/kotlin/com/opendatamask/application/service/CustomSensitivityRuleService.kt b/backend/src/main/kotlin/com/opendatamask/application/service/CustomSensitivityRuleService.kt
index d959668..d540f05 100644
--- a/backend/src/main/kotlin/com/opendatamask/application/service/CustomSensitivityRuleService.kt
+++ b/backend/src/main/kotlin/com/opendatamask/application/service/CustomSensitivityRuleService.kt
@@ -14,6 +14,7 @@ import com.opendatamask.domain.port.input.dto.CustomSensitivityRuleRequest
import com.opendatamask.domain.port.input.dto.CustomSensitivityRuleResponse
import com.opendatamask.domain.port.output.CustomSensitivityRulePort
import com.opendatamask.domain.port.output.SchemaSnapshotPort
+import org.slf4j.LoggerFactory
import org.springframework.stereotype.Service
import java.time.Instant
@@ -23,6 +24,7 @@ class CustomSensitivityRuleService(
private val schemaSnapshotRepository: SchemaSnapshotPort
) : CustomSensitivityRuleUseCase {
+ private val logger = LoggerFactory.getLogger(CustomSensitivityRuleService::class.java)
private val mapper = jacksonObjectMapper()
override fun listRules(): List =
@@ -158,6 +160,7 @@ class CustomSensitivityRuleService(
val matchers: List = try {
mapper.readValue(matchersJson)
} catch (e: Exception) {
+ logger.warn("Failed to parse matchers JSON for rule '${name}' (id=${id}): ${e.message}")
emptyList()
}
return CustomSensitivityRuleResponse(
diff --git a/backend/src/main/kotlin/com/opendatamask/application/service/PrivacyHubService.kt b/backend/src/main/kotlin/com/opendatamask/application/service/PrivacyHubService.kt
index 31ab615..a23f838 100644
--- a/backend/src/main/kotlin/com/opendatamask/application/service/PrivacyHubService.kt
+++ b/backend/src/main/kotlin/com/opendatamask/application/service/PrivacyHubService.kt
@@ -11,6 +11,7 @@ import com.opendatamask.domain.port.output.ColumnGeneratorPort
import com.opendatamask.domain.port.output.ColumnSensitivityPort
import com.opendatamask.domain.port.output.GeneratorPresetPort
import com.opendatamask.domain.port.output.TableConfigurationPort
+import org.slf4j.LoggerFactory
import org.springframework.stereotype.Service
@Service
@@ -20,6 +21,7 @@ class PrivacyHubService(
private val tableConfigurationRepository: TableConfigurationPort,
private val generatorPresetRepository: GeneratorPresetPort
) : PrivacyHubUseCase {
+ private val logger = LoggerFactory.getLogger(PrivacyHubService::class.java)
override fun getSummary(workspaceId: Long): PrivacyHubSummary {
val sensitivities = columnSensitivityRepository.findByWorkspaceId(workspaceId)
@@ -87,7 +89,14 @@ class PrivacyHubService(
if (rec.recommendedPresetId != null) {
// Apply linked preset from a custom sensitivity rule
- val preset = generatorPresetRepository.findById(rec.recommendedPresetId).orElse(null) ?: continue
+ val preset = generatorPresetRepository.findById(rec.recommendedPresetId).orElse(null)
+ if (preset == null) {
+ logger.warn(
+ "Cannot apply recommendation for ${rec.tableName}.${rec.columnName}: " +
+ "linked preset id=${rec.recommendedPresetId} not found"
+ )
+ continue
+ }
val existingGenerator = columnGeneratorRepository.findByTableConfigurationId(tableConfig.id)
.find { it.columnName == rec.columnName }
if (existingGenerator != null) {
diff --git a/backend/src/main/kotlin/com/opendatamask/application/service/SensitivityScanService.kt b/backend/src/main/kotlin/com/opendatamask/application/service/SensitivityScanService.kt
index 924838d..3b8082d 100644
--- a/backend/src/main/kotlin/com/opendatamask/application/service/SensitivityScanService.kt
+++ b/backend/src/main/kotlin/com/opendatamask/application/service/SensitivityScanService.kt
@@ -13,6 +13,7 @@ import com.opendatamask.domain.port.output.SensitivityScanLogPort
import com.opendatamask.domain.port.output.SensitivityScanLogEntryPort
import com.opendatamask.domain.port.output.WorkspacePort
import com.opendatamask.domain.port.output.DataConnectionPort
+import org.slf4j.LoggerFactory
import org.springframework.scheduling.annotation.Scheduled
import org.springframework.stereotype.Service
import java.time.Instant
@@ -30,6 +31,7 @@ class SensitivityScanService(
private val customSensitivityRuleRepository: CustomSensitivityRulePort,
private val customSensitivityRuleService: CustomSensitivityRuleService
) : SensitivityScanUseCase {
+ private val logger = LoggerFactory.getLogger(SensitivityScanService::class.java)
private val builtInRules: List = buildRules()
private val mapper = jacksonObjectMapper()
@@ -61,6 +63,7 @@ class SensitivityScanService(
val matchers: List = try {
mapper.readValue(rule.matchersJson)
} catch (e: Exception) {
+ logger.warn("Failed to parse matchers JSON for custom rule '${rule.name}' (id=${rule.id}): ${e.message}")
emptyList()
}
rule to matchers
@@ -110,6 +113,7 @@ class SensitivityScanService(
} else {
val customMatch = detectCustomRuleSensitivity(column, columnInfo.type, activeCustomRules)
if (customMatch != null) {
+ val (matchedRule, _) = customMatch
log.sensitiveColumnsFound++
val existing = columnSensitivityRepository
.findByWorkspaceIdAndTableNameAndColumnName(workspaceId, table, column)
@@ -122,15 +126,15 @@ class SensitivityScanService(
entity.sensitivityType = SensitivityType.UNKNOWN
entity.confidenceLevel = ConfidenceLevel.HIGH
entity.recommendedGeneratorType = null
- entity.customSensitivityLabel = customMatch.first.name
- entity.recommendedPresetId = customMatch.first.linkedPresetId
+ entity.customSensitivityLabel = matchedRule.name
+ entity.recommendedPresetId = matchedRule.linkedPresetId
columnSensitivityRepository.save(entity)
sensitivityScanLogEntryRepository.save(
SensitivityScanLogEntry(
scanLogId = log.id!!,
tableName = table,
columnName = column,
- detectedType = customMatch.first.name,
+ detectedType = matchedRule.name,
confidenceLevel = ConfidenceLevel.HIGH.name,
recommendedGenerator = null,
scannedAt = LocalDateTime.now()
From 29a7b19bb65abfbe52f97882e1ba475ad0b2ebc5 Mon Sep 17 00:00:00 2001
From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com>
Date: Mon, 13 Apr 2026 15:20:55 +0000
Subject: [PATCH 4/4] Address PR review: validation, regex safety, preset
loading, N+1 fix, scan ordering, preview reset
Agent-Logs-Url: https://github.com/MaximumTrainer/OpenDataMask/sessions/5a92e1d9-53ef-44f8-b2b9-31addf85947e
Co-authored-by: MaximumTrainer <1376575+MaximumTrainer@users.noreply.github.com>
---
.../rest/CustomSensitivityRuleController.kt | 7 +-
.../service/CustomSensitivityRuleService.kt | 21 ++++-
.../application/service/PrivacyHubService.kt | 16 +++-
.../service/SensitivityScanService.kt | 92 ++++++++++---------
.../input/dto/CustomSensitivityRuleDto.kt | 10 +-
.../service/SensitivityScanLogServiceTest.kt | 2 +
.../service/SensitivityScanServiceTest.kt | 1 +
frontend/src/views/SensitivityRulesView.vue | 52 ++++++++---
8 files changed, 130 insertions(+), 71 deletions(-)
diff --git a/backend/src/main/kotlin/com/opendatamask/adapter/input/rest/CustomSensitivityRuleController.kt b/backend/src/main/kotlin/com/opendatamask/adapter/input/rest/CustomSensitivityRuleController.kt
index 9ecfbd3..8927580 100644
--- a/backend/src/main/kotlin/com/opendatamask/adapter/input/rest/CustomSensitivityRuleController.kt
+++ b/backend/src/main/kotlin/com/opendatamask/adapter/input/rest/CustomSensitivityRuleController.kt
@@ -5,6 +5,7 @@ import com.opendatamask.domain.port.input.dto.CustomRulePreviewResult
import com.opendatamask.domain.port.input.dto.CustomSensitivityRuleRequest
import com.opendatamask.domain.port.input.dto.CustomSensitivityRuleResponse
import com.opendatamask.application.service.CustomSensitivityRuleService
+import jakarta.validation.Valid
import org.springframework.http.HttpStatus
import org.springframework.http.ResponseEntity
import org.springframework.web.bind.annotation.*
@@ -25,7 +26,7 @@ class CustomSensitivityRuleController(
@PostMapping
fun createRule(
- @RequestBody request: CustomSensitivityRuleRequest
+ @Valid @RequestBody request: CustomSensitivityRuleRequest
): ResponseEntity =
ResponseEntity.status(HttpStatus.CREATED)
.body(customSensitivityRuleService.createRule(request))
@@ -33,7 +34,7 @@ class CustomSensitivityRuleController(
@PutMapping("/{id}")
fun updateRule(
@PathVariable id: Long,
- @RequestBody request: CustomSensitivityRuleRequest
+ @Valid @RequestBody request: CustomSensitivityRuleRequest
): ResponseEntity =
ResponseEntity.ok(customSensitivityRuleService.updateRule(id, request))
@@ -45,7 +46,7 @@ class CustomSensitivityRuleController(
@PostMapping("/preview")
fun previewRule(
- @RequestBody request: CustomRulePreviewRequest
+ @Valid @RequestBody request: CustomRulePreviewRequest
): ResponseEntity> =
ResponseEntity.ok(customSensitivityRuleService.previewRule(request))
}
diff --git a/backend/src/main/kotlin/com/opendatamask/application/service/CustomSensitivityRuleService.kt b/backend/src/main/kotlin/com/opendatamask/application/service/CustomSensitivityRuleService.kt
index d540f05..c950bf1 100644
--- a/backend/src/main/kotlin/com/opendatamask/application/service/CustomSensitivityRuleService.kt
+++ b/backend/src/main/kotlin/com/opendatamask/application/service/CustomSensitivityRuleService.kt
@@ -81,6 +81,11 @@ class CustomSensitivityRuleService(
val workspaceSchema = try {
mapper.readValue(snapshot.schemaJson)
} catch (e: Exception) {
+ logger.warn(
+ "Failed to parse schema snapshot JSON for workspaceId={}. Returning empty preview results.",
+ request.workspaceId,
+ e
+ )
return emptyList()
}
@@ -112,10 +117,15 @@ class CustomSensitivityRuleService(
MatcherType.CONTAINS -> col.contains(value)
MatcherType.STARTS_WITH -> col.startsWith(value)
MatcherType.ENDS_WITH -> col.endsWith(value)
- MatcherType.REGEX -> Regex(
- matcher.value,
- if (matcher.caseSensitive) emptySet() else setOf(RegexOption.IGNORE_CASE)
- ).containsMatchIn(columnName)
+ MatcherType.REGEX -> try {
+ Regex(
+ matcher.value,
+ if (matcher.caseSensitive) emptySet() else setOf(RegexOption.IGNORE_CASE)
+ ).containsMatchIn(columnName)
+ } catch (e: IllegalArgumentException) {
+ logger.warn("Invalid regex pattern '${matcher.value}' in custom rule matcher: ${e.message}")
+ false
+ }
}
}
}
@@ -157,6 +167,7 @@ class CustomSensitivityRuleService(
)
private fun CustomSensitivityRule.toResponse(): CustomSensitivityRuleResponse {
+ val ruleId = requireNotNull(id) { "Cannot map CustomSensitivityRule '$name' to response without a persisted id" }
val matchers: List = try {
mapper.readValue(matchersJson)
} catch (e: Exception) {
@@ -164,7 +175,7 @@ class CustomSensitivityRuleService(
emptyList()
}
return CustomSensitivityRuleResponse(
- id = id ?: 0L,
+ id = ruleId,
name = name,
description = description,
dataTypeFilter = dataTypeFilter,
diff --git a/backend/src/main/kotlin/com/opendatamask/application/service/PrivacyHubService.kt b/backend/src/main/kotlin/com/opendatamask/application/service/PrivacyHubService.kt
index a23f838..2300416 100644
--- a/backend/src/main/kotlin/com/opendatamask/application/service/PrivacyHubService.kt
+++ b/backend/src/main/kotlin/com/opendatamask/application/service/PrivacyHubService.kt
@@ -83,13 +83,24 @@ class PrivacyHubService(
val tableConfigs = tableConfigurationRepository.findByWorkspaceId(workspaceId)
val tableConfigMap = tableConfigs.associateBy { it.tableName }
+ // Preload all referenced presets into a map to avoid N+1 queries
+ val presetIds = recommendations.mapNotNull { it.recommendedPresetId }.toSet()
+ val presetMap = presetIds.associateWith { id ->
+ generatorPresetRepository.findById(id).orElse(null)
+ }
+
+ // Preload existing column generators per table config to avoid N+1 queries
+ val existingGeneratorMap: Map> = tableConfigs.associate { tc ->
+ tc.id to columnGeneratorRepository.findByTableConfigurationId(tc.id).associateBy { it.columnName }
+ }
+
var count = 0
for (rec in recommendations) {
val tableConfig = tableConfigMap[rec.tableName] ?: continue
if (rec.recommendedPresetId != null) {
// Apply linked preset from a custom sensitivity rule
- val preset = generatorPresetRepository.findById(rec.recommendedPresetId).orElse(null)
+ val preset = presetMap[rec.recommendedPresetId]
if (preset == null) {
logger.warn(
"Cannot apply recommendation for ${rec.tableName}.${rec.columnName}: " +
@@ -97,8 +108,7 @@ class PrivacyHubService(
)
continue
}
- val existingGenerator = columnGeneratorRepository.findByTableConfigurationId(tableConfig.id)
- .find { it.columnName == rec.columnName }
+ val existingGenerator = existingGeneratorMap[tableConfig.id]?.get(rec.columnName)
if (existingGenerator != null) {
existingGenerator.presetId = preset.id
existingGenerator.generatorType = preset.generatorType
diff --git a/backend/src/main/kotlin/com/opendatamask/application/service/SensitivityScanService.kt b/backend/src/main/kotlin/com/opendatamask/application/service/SensitivityScanService.kt
index 3b8082d..54429dc 100644
--- a/backend/src/main/kotlin/com/opendatamask/application/service/SensitivityScanService.kt
+++ b/backend/src/main/kotlin/com/opendatamask/application/service/SensitivityScanService.kt
@@ -83,6 +83,10 @@ class SensitivityScanService(
emptyList()
}
val builtInResult = detectSensitivity(column, samples)
+ // Evaluate custom rules for every column so a linked preset can be attached
+ // even when a built-in rule already detected sensitivity.
+ val customMatch = detectCustomRuleSensitivity(column, columnInfo.type, activeCustomRules)
+
if (builtInResult != null) {
log.sensitiveColumnsFound++
val existing = columnSensitivityRepository
@@ -96,8 +100,15 @@ class SensitivityScanService(
entity.sensitivityType = builtInResult.sensitivityType
entity.confidenceLevel = builtInResult.confidence
entity.recommendedGeneratorType = builtInResult.recommendedGenerator
- entity.customSensitivityLabel = null
- entity.recommendedPresetId = null
+ // Custom rule match may supply a linked preset while keeping the built-in type label
+ if (customMatch != null) {
+ val (matchedRule, _) = customMatch
+ entity.customSensitivityLabel = matchedRule.name
+ entity.recommendedPresetId = matchedRule.linkedPresetId
+ } else {
+ entity.customSensitivityLabel = null
+ entity.recommendedPresetId = null
+ }
columnSensitivityRepository.save(entity)
sensitivityScanLogEntryRepository.save(
SensitivityScanLogEntry(
@@ -110,49 +121,46 @@ class SensitivityScanService(
scannedAt = LocalDateTime.now()
)
)
- } else {
- val customMatch = detectCustomRuleSensitivity(column, columnInfo.type, activeCustomRules)
- if (customMatch != null) {
- val (matchedRule, _) = customMatch
- log.sensitiveColumnsFound++
- val existing = columnSensitivityRepository
- .findByWorkspaceIdAndTableNameAndColumnName(workspaceId, table, column)
- val entity = existing ?: ColumnSensitivity(
- workspaceId = workspaceId,
+ } else if (customMatch != null) {
+ val (matchedRule, _) = customMatch
+ log.sensitiveColumnsFound++
+ val existing = columnSensitivityRepository
+ .findByWorkspaceIdAndTableNameAndColumnName(workspaceId, table, column)
+ val entity = existing ?: ColumnSensitivity(
+ workspaceId = workspaceId,
+ tableName = table,
+ columnName = column
+ )
+ entity.isSensitive = true
+ entity.sensitivityType = SensitivityType.UNKNOWN
+ entity.confidenceLevel = ConfidenceLevel.HIGH
+ entity.recommendedGeneratorType = null
+ entity.customSensitivityLabel = matchedRule.name
+ entity.recommendedPresetId = matchedRule.linkedPresetId
+ columnSensitivityRepository.save(entity)
+ sensitivityScanLogEntryRepository.save(
+ SensitivityScanLogEntry(
+ scanLogId = log.id!!,
tableName = table,
- columnName = column
- )
- entity.isSensitive = true
- entity.sensitivityType = SensitivityType.UNKNOWN
- entity.confidenceLevel = ConfidenceLevel.HIGH
- entity.recommendedGeneratorType = null
- entity.customSensitivityLabel = matchedRule.name
- entity.recommendedPresetId = matchedRule.linkedPresetId
- columnSensitivityRepository.save(entity)
- sensitivityScanLogEntryRepository.save(
- SensitivityScanLogEntry(
- scanLogId = log.id!!,
- tableName = table,
- columnName = column,
- detectedType = matchedRule.name,
- confidenceLevel = ConfidenceLevel.HIGH.name,
- recommendedGenerator = null,
- scannedAt = LocalDateTime.now()
- )
+ columnName = column,
+ detectedType = matchedRule.name,
+ confidenceLevel = ConfidenceLevel.HIGH.name,
+ recommendedGenerator = null,
+ scannedAt = LocalDateTime.now()
)
- } else {
- sensitivityScanLogEntryRepository.save(
- SensitivityScanLogEntry(
- scanLogId = log.id!!,
- tableName = table,
- columnName = column,
- detectedType = null,
- confidenceLevel = null,
- recommendedGenerator = null,
- scannedAt = LocalDateTime.now()
- )
+ )
+ } else {
+ sensitivityScanLogEntryRepository.save(
+ SensitivityScanLogEntry(
+ scanLogId = log.id!!,
+ tableName = table,
+ columnName = column,
+ detectedType = null,
+ confidenceLevel = null,
+ recommendedGenerator = null,
+ scannedAt = LocalDateTime.now()
)
- }
+ )
}
}
}
diff --git a/backend/src/main/kotlin/com/opendatamask/domain/port/input/dto/CustomSensitivityRuleDto.kt b/backend/src/main/kotlin/com/opendatamask/domain/port/input/dto/CustomSensitivityRuleDto.kt
index 6dccd88..e316278 100644
--- a/backend/src/main/kotlin/com/opendatamask/domain/port/input/dto/CustomSensitivityRuleDto.kt
+++ b/backend/src/main/kotlin/com/opendatamask/domain/port/input/dto/CustomSensitivityRuleDto.kt
@@ -2,16 +2,18 @@ package com.opendatamask.domain.port.input.dto
import com.opendatamask.domain.model.GenericDataType
import com.opendatamask.domain.model.MatcherType
+import jakarta.validation.constraints.NotBlank
+import jakarta.validation.constraints.NotNull
import java.time.Instant
data class CustomRuleMatcherDto(
- val matcherType: MatcherType,
- val value: String,
+ @field:NotNull val matcherType: MatcherType,
+ @field:NotBlank val value: String,
val caseSensitive: Boolean = false
)
data class CustomSensitivityRuleRequest(
- val name: String,
+ @field:NotBlank val name: String,
val description: String? = null,
val dataTypeFilter: GenericDataType = GenericDataType.ANY,
val matchers: List = emptyList(),
@@ -32,7 +34,7 @@ data class CustomSensitivityRuleResponse(
)
data class CustomRulePreviewRequest(
- val workspaceId: Long,
+ @field:NotNull val workspaceId: Long,
val dataTypeFilter: GenericDataType = GenericDataType.ANY,
val matchers: List = emptyList()
)
diff --git a/backend/src/test/kotlin/com/opendatamask/application/service/SensitivityScanLogServiceTest.kt b/backend/src/test/kotlin/com/opendatamask/application/service/SensitivityScanLogServiceTest.kt
index 9e4fb7f..d7f83f1 100644
--- a/backend/src/test/kotlin/com/opendatamask/application/service/SensitivityScanLogServiceTest.kt
+++ b/backend/src/test/kotlin/com/opendatamask/application/service/SensitivityScanLogServiceTest.kt
@@ -67,6 +67,7 @@ class SensitivityScanLogServiceTest {
.thenReturn(null)
whenever(columnSensitivityRepository.save(any()))
.thenAnswer { it.arguments[0] as ColumnSensitivity }
+ whenever(customSensitivityRuleRepository.findByIsActiveTrue()).thenReturn(emptyList())
sensitivityScanService.scanWorkspace(1L)
@@ -122,6 +123,7 @@ class SensitivityScanLogServiceTest {
.thenReturn(null)
whenever(columnSensitivityRepository.save(any()))
.thenAnswer { it.arguments[0] as ColumnSensitivity }
+ whenever(customSensitivityRuleRepository.findByIsActiveTrue()).thenReturn(emptyList())
sensitivityScanService.scanWorkspace(1L)
diff --git a/backend/src/test/kotlin/com/opendatamask/application/service/SensitivityScanServiceTest.kt b/backend/src/test/kotlin/com/opendatamask/application/service/SensitivityScanServiceTest.kt
index 8b0c644..d733e28 100644
--- a/backend/src/test/kotlin/com/opendatamask/application/service/SensitivityScanServiceTest.kt
+++ b/backend/src/test/kotlin/com/opendatamask/application/service/SensitivityScanServiceTest.kt
@@ -165,6 +165,7 @@ class SensitivityScanServiceTest {
)).thenReturn(null)
whenever(columnSensitivityRepository.save(any()))
.thenAnswer { it.arguments[0] as ColumnSensitivity }
+ whenever(customSensitivityRuleRepository.findByIsActiveTrue()).thenReturn(emptyList())
val result = sensitivityScanService.scanWorkspace(1L)
diff --git a/frontend/src/views/SensitivityRulesView.vue b/frontend/src/views/SensitivityRulesView.vue
index 0b86687..5451b31 100644
--- a/frontend/src/views/SensitivityRulesView.vue
+++ b/frontend/src/views/SensitivityRulesView.vue
@@ -13,13 +13,11 @@ import type { GeneratorPresetResponse } from '@/api/presets'
import type {
CustomSensitivityRule,
CustomSensitivityRuleRequest,
- CustomRuleMatcher,
CustomRulePreviewResult
} from '@/types'
import { GenericDataType, MatcherType } from '@/types'
const workspaceStore = useWorkspaceStore()
-const workspaces = ref(workspaceStore.workspaces)
const rules = ref([])
const loading = ref(false)
@@ -48,11 +46,10 @@ const previewing = ref(false)
const previewError = ref(null)
// ── Presets ────────────────────────────────────────────────────────────────
-const systemPresets = ref([])
+const allPresets = ref([])
onMounted(async () => {
- await Promise.all([fetchRules(), fetchPresets(), workspaceStore.fetchWorkspaces()])
- workspaces.value = workspaceStore.workspaces
+ await Promise.all([fetchRules(), fetchSystemPresets(), workspaceStore.fetchWorkspaces()])
})
async function fetchRules() {
@@ -67,14 +64,33 @@ async function fetchRules() {
}
}
-async function fetchPresets() {
+async function fetchSystemPresets() {
try {
- systemPresets.value = await listSystemPresets()
+ allPresets.value = await listSystemPresets()
} catch {
// non-critical
}
}
+async function loadWorkspacePresets(workspaceId: number) {
+ try {
+ const wsPresets = await listWorkspacePresets(workspaceId)
+ // Merge with system presets, deduplicating by id
+ const systemIds = new Set(allPresets.value.filter((p) => p.isSystem).map((p) => p.id))
+ const systemPresets = allPresets.value.filter((p) => p.isSystem)
+ allPresets.value = [...systemPresets, ...wsPresets.filter((p) => !systemIds.has(p.id))]
+ } catch {
+ // non-critical
+ }
+}
+
+function resetPreviewState() {
+ previewWorkspaceId.value = null
+ previewResults.value = []
+ previewing.value = false
+ previewError.value = null
+}
+
function openCreateDrawer() {
editingRule.value = null
form.value = {
@@ -85,8 +101,7 @@ function openCreateDrawer() {
linkedPresetId: null,
isActive: true
}
- previewResults.value = []
- previewError.value = null
+ resetPreviewState()
saveError.value = null
drawerOpen.value = true
}
@@ -101,8 +116,7 @@ function openEditDrawer(rule: CustomSensitivityRule) {
linkedPresetId: rule.linkedPresetId,
isActive: rule.isActive
}
- previewResults.value = []
- previewError.value = null
+ resetPreviewState()
saveError.value = null
drawerOpen.value = true
}
@@ -149,6 +163,12 @@ async function deleteRule(rule: CustomSensitivityRule) {
}
}
+async function onPreviewWorkspaceChange(workspaceId: number | null) {
+ previewResults.value = []
+ previewError.value = null
+ if (workspaceId) await loadWorkspacePresets(workspaceId)
+}
+
async function runPreview() {
if (!previewWorkspaceId.value) {
previewError.value = 'Please select a workspace to preview against.'
@@ -172,7 +192,7 @@ async function runPreview() {
function presetName(presetId: number | null): string {
if (!presetId) return '—'
- return systemPresets.value.find((p) => p.id === presetId)?.name ?? `#${presetId}`
+ return allPresets.value.find((p) => p.id === presetId)?.name ?? `#${presetId}`
}
@@ -322,7 +342,7 @@ function presetName(presetId: number | null): string {
@@ -347,7 +367,11 @@ function presetName(presetId: number | null): string {
Select a workspace to see which columns this rule would match (without saving).
-