Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -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<List<CustomSensitivityRuleResponse>> =
ResponseEntity.ok(customSensitivityRuleService.listRules())

@GetMapping("/{id}")
fun getRule(@PathVariable id: Long): ResponseEntity<CustomSensitivityRuleResponse> =
ResponseEntity.ok(customSensitivityRuleService.getRule(id))

@PostMapping
fun createRule(
@Valid @RequestBody request: CustomSensitivityRuleRequest
): ResponseEntity<CustomSensitivityRuleResponse> =
ResponseEntity.status(HttpStatus.CREATED)
.body(customSensitivityRuleService.createRule(request))

@PutMapping("/{id}")
fun updateRule(
@PathVariable id: Long,
@Valid @RequestBody request: CustomSensitivityRuleRequest
): ResponseEntity<CustomSensitivityRuleResponse> =
ResponseEntity.ok(customSensitivityRuleService.updateRule(id, request))
Comment on lines +27 to +39
Copy link

Copilot AI Apr 13, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Other controllers (e.g., GeneratorPresetController) use @Valid on request bodies to enforce DTO constraints. Here, create/update/preview accept request bodies without @Valid, so any added bean validation on the DTOs will not run. Consider adding @Valid to the @RequestBody parameters.

Copilot uses AI. Check for mistakes.

@DeleteMapping("/{id}")
fun deleteRule(@PathVariable id: Long): ResponseEntity<Void> {
customSensitivityRuleService.deleteRule(id)
return ResponseEntity.noContent().build()
}

@PostMapping("/preview")
fun previewRule(
@Valid @RequestBody request: CustomRulePreviewRequest
): ResponseEntity<List<CustomRulePreviewResult>> =
ResponseEntity.ok(customSensitivityRuleService.previewRule(request))
}
Original file line number Diff line number Diff line change
@@ -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<CustomSensitivityRule, Long>, CustomSensitivityRulePort {
override fun findAll(): List<CustomSensitivityRule>
override fun findByIsActiveTrue(): List<CustomSensitivityRule>
override fun findById(id: Long): Optional<CustomSensitivityRule>
override fun save(rule: CustomSensitivityRule): CustomSensitivityRule
override fun deleteById(id: Long)
override fun existsByName(name: String): Boolean
}
Original file line number Diff line number Diff line change
@@ -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<CustomSensitivityRuleResponse> =
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<CustomRulePreviewResult> {
val snapshot = schemaSnapshotRepository
.findTopByWorkspaceIdOrderBySnapshotAtDesc(request.workspaceId)
?: return emptyList()

val workspaceSchema = try {
mapper.readValue<SchemaSnapshotSchema>(snapshot.schemaJson)
} catch (e: Exception) {
Copy link

Copilot AI Apr 13, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

previewRule() swallows schema JSON parsing exceptions and returns an empty list without logging. This makes it hard to diagnose corrupt snapshots or schema-format changes. Consider logging a warn with workspaceId and the parse error before returning empty results.

Suggested change
} catch (e: Exception) {
} catch (e: Exception) {
logger.warn(
"Failed to parse schema snapshot JSON for workspaceId={}. Returning empty preview results.",
request.workspaceId,
e
)

Copilot uses AI. Check for mistakes.
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<CustomRuleMatcher>): 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<CustomRuleMatcher> = 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<SchemaSnapshotTable>)
private data class SchemaSnapshotTable(val tableName: String, val columns: List<SchemaSnapshotColumn>)
private data class SchemaSnapshotColumn(val name: String, val type: String, val nullable: Boolean = true)
}
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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
)
}
}
Expand All @@ -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<Long, Map<String, ColumnGenerator>> = 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)
Comment on lines +101 to +116
Copy link

Copilot AI Apr 13, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

applyRecommendations() performs per-recommendation lookups (generatorPresetRepository.findById(...)) and per-recommendation generator scans (findByTableConfigurationId(...).find { ... }). For many recommendations this becomes an N+1 pattern. Consider preloading presets into a map and preloading existing generators per tableConfig (or extending the port with a targeted lookup) to reduce DB round-trips.

Copilot uses AI. Check for mistakes.
} 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
}
Expand All @@ -118,3 +165,4 @@ class PrivacyHubService(
else -> "NOT_SENSITIVE"
}
}

Loading
Loading