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..8927580 --- /dev/null +++ b/backend/src/main/kotlin/com/opendatamask/adapter/input/rest/CustomSensitivityRuleController.kt @@ -0,0 +1,52 @@ +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 jakarta.validation.Valid +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( + @Valid @RequestBody request: CustomSensitivityRuleRequest + ): ResponseEntity = + ResponseEntity.status(HttpStatus.CREATED) + .body(customSensitivityRuleService.createRule(request)) + + @PutMapping("/{id}") + fun updateRule( + @PathVariable id: Long, + @Valid @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( + @Valid @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..c950bf1 --- /dev/null +++ b/backend/src/main/kotlin/com/opendatamask/application/service/CustomSensitivityRuleService.kt @@ -0,0 +1,200 @@ +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.slf4j.LoggerFactory +import org.springframework.stereotype.Service +import java.time.Instant + +@Service +class CustomSensitivityRuleService( + private val customRuleRepository: CustomSensitivityRulePort, + private val schemaSnapshotRepository: SchemaSnapshotPort +) : CustomSensitivityRuleUseCase { + + private val logger = LoggerFactory.getLogger(CustomSensitivityRuleService::class.java) + 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) { + logger.warn( + "Failed to parse schema snapshot JSON for workspaceId={}. Returning empty preview results.", + request.workspaceId, + e + ) + 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 -> 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 + } + } + } + } + + 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 ruleId = requireNotNull(id) { "Cannot map CustomSensitivityRule '$name' to response without a persisted id" } + 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( + id = ruleId, + 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..2300416 100644 --- a/backend/src/main/kotlin/com/opendatamask/application/service/PrivacyHubService.kt +++ b/backend/src/main/kotlin/com/opendatamask/application/service/PrivacyHubService.kt @@ -9,15 +9,19 @@ 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.slf4j.LoggerFactory import org.springframework.stereotype.Service @Service class PrivacyHubService( private val columnSensitivityRepository: ColumnSensitivityPort, private val columnGeneratorRepository: ColumnGeneratorPort, - private val tableConfigurationRepository: TableConfigurationPort + 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) @@ -66,9 +70,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 ) } } @@ -78,23 +83,65 @@ 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.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 = presetMap[rec.recommendedPresetId] + if (preset == null) { + logger.warn( + "Cannot apply recommendation for ${rec.tableName}.${rec.columnName}: " + + "linked preset id=${rec.recommendedPresetId} not found" + ) + continue + } + val existingGenerator = existingGeneratorMap[tableConfig.id]?.get(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 +165,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..54429dc 100644 --- a/backend/src/main/kotlin/com/opendatamask/application/service/SensitivityScanService.kt +++ b/backend/src/main/kotlin/com/opendatamask/application/service/SensitivityScanService.kt @@ -1,15 +1,19 @@ 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 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 @@ -23,9 +27,13 @@ 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 logger = LoggerFactory.getLogger(SensitivityScanService::class.java) + private val builtInRules: List = buildRules() + private val mapper = jacksonObjectMapper() override fun scanWorkspace(workspaceId: Long): SensitivityScanLog { val log = sensitivityScanLogRepository.save(SensitivityScanLog(workspaceId = workspaceId)) @@ -50,6 +58,17 @@ class SensitivityScanService( database = sourceConnection.database ) + val activeCustomRules = customSensitivityRuleRepository.findByIsActiveTrue() + .map { rule -> + 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 + } + val tables = connector.listTables() log.tablesScanned = tables.size @@ -63,8 +82,12 @@ class SensitivityScanService( } catch (e: Exception) { emptyList() } - val result = detectSensitivity(column, samples) - if (result != null) { + 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 .findByWorkspaceIdAndTableNameAndColumnName(workspaceId, table, column) @@ -74,22 +97,71 @@ 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 + // 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( - scanLogId = log.id!!, + 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 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, - detectedType = result?.sensitivityType?.name, - confidenceLevel = result?.confidence?.name, - recommendedGenerator = result?.recommendedGenerator?.name, - scannedAt = LocalDateTime.now() + 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() + ) + ) + } 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 +177,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 +187,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 +198,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 +233,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..e316278 --- /dev/null +++ b/backend/src/main/kotlin/com/opendatamask/domain/port/input/dto/CustomSensitivityRuleDto.kt @@ -0,0 +1,46 @@ +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( + @field:NotNull val matcherType: MatcherType, + @field:NotBlank val value: String, + val caseSensitive: Boolean = false +) + +data class CustomSensitivityRuleRequest( + @field:NotBlank 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( + @field:NotNull 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..d7f83f1 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 @@ -65,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) @@ -120,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 82f76ad..d733e28 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 @@ -163,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/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..5451b31 --- /dev/null +++ b/frontend/src/views/SensitivityRulesView.vue @@ -0,0 +1,566 @@ + + +