diff --git a/backend/src/main/kotlin/com/opendatamask/adapter/input/rest/CustomDataMappingController.kt b/backend/src/main/kotlin/com/opendatamask/adapter/input/rest/CustomDataMappingController.kt new file mode 100644 index 0000000..df0031f --- /dev/null +++ b/backend/src/main/kotlin/com/opendatamask/adapter/input/rest/CustomDataMappingController.kt @@ -0,0 +1,75 @@ +package com.opendatamask.adapter.input.rest + +import com.opendatamask.domain.port.input.CustomDataMappingUseCase +import com.opendatamask.domain.port.input.dto.BulkCustomDataMappingRequest +import com.opendatamask.domain.port.input.dto.CustomDataMappingRequest +import com.opendatamask.domain.port.input.dto.CustomDataMappingResponse +import jakarta.validation.Valid +import org.springframework.http.HttpStatus +import org.springframework.http.ResponseEntity +import org.springframework.web.bind.annotation.* + +@RestController +@RequestMapping("/api/workspaces/{workspaceId}/mappings") +class CustomDataMappingController( + private val customDataMappingService: CustomDataMappingUseCase +) { + + @PostMapping + fun createMapping( + @PathVariable workspaceId: Long, + @Valid @RequestBody request: CustomDataMappingRequest + ): ResponseEntity = + ResponseEntity.status(HttpStatus.CREATED) + .body(customDataMappingService.createMapping(workspaceId, request)) + + @GetMapping("/{mappingId}") + fun getMapping( + @PathVariable workspaceId: Long, + @PathVariable mappingId: Long + ): ResponseEntity = + ResponseEntity.ok(customDataMappingService.getMapping(workspaceId, mappingId)) + + @GetMapping + fun listMappings( + @PathVariable workspaceId: Long, + @RequestParam(required = false) connectionId: Long?, + @RequestParam(required = false) tableName: String? + ): ResponseEntity> { + val hasConnectionId = connectionId != null + val hasTableName = !tableName.isNullOrBlank() + if (hasConnectionId != hasTableName) { + return ResponseEntity.badRequest().build() + } + val result = if (hasConnectionId && hasTableName) { + customDataMappingService.listMappingsForTable(workspaceId, connectionId!!, tableName!!) + } else { + customDataMappingService.listMappings(workspaceId) + } + return ResponseEntity.ok(result) + } + + @PutMapping("/{mappingId}") + fun updateMapping( + @PathVariable workspaceId: Long, + @PathVariable mappingId: Long, + @Valid @RequestBody request: CustomDataMappingRequest + ): ResponseEntity = + ResponseEntity.ok(customDataMappingService.updateMapping(workspaceId, mappingId, request)) + + @DeleteMapping("/{mappingId}") + fun deleteMapping( + @PathVariable workspaceId: Long, + @PathVariable mappingId: Long + ): ResponseEntity { + customDataMappingService.deleteMapping(workspaceId, mappingId) + return ResponseEntity.noContent().build() + } + + @PostMapping("/bulk") + fun saveBulkMappings( + @PathVariable workspaceId: Long, + @Valid @RequestBody request: BulkCustomDataMappingRequest + ): ResponseEntity> = + ResponseEntity.ok(customDataMappingService.saveBulkMappings(workspaceId, request)) +} diff --git a/backend/src/main/kotlin/com/opendatamask/adapter/input/rest/DataConnectionController.kt b/backend/src/main/kotlin/com/opendatamask/adapter/input/rest/DataConnectionController.kt index 781fa2d..ef29038 100644 --- a/backend/src/main/kotlin/com/opendatamask/adapter/input/rest/DataConnectionController.kt +++ b/backend/src/main/kotlin/com/opendatamask/adapter/input/rest/DataConnectionController.kt @@ -1,5 +1,6 @@ package com.opendatamask.adapter.input.rest +import com.opendatamask.domain.port.input.dto.ConnectionSchemaResponse import com.opendatamask.domain.port.input.dto.ConnectionTestResult import com.opendatamask.domain.port.input.dto.DataConnectionRequest import com.opendatamask.domain.port.input.dto.DataConnectionResponse @@ -62,5 +63,12 @@ class DataConnectionController( ): ResponseEntity { return ResponseEntity.ok(dataConnectionService.testConnection(workspaceId, connectionId)) } + + @GetMapping("/{connectionId}/schema") + fun browseConnectionSchema( + @PathVariable workspaceId: Long, + @PathVariable connectionId: Long + ): ResponseEntity = + ResponseEntity.ok(dataConnectionService.browseConnectionSchema(workspaceId, connectionId)) } diff --git a/backend/src/main/kotlin/com/opendatamask/adapter/output/persistence/CustomDataMappingRepository.kt b/backend/src/main/kotlin/com/opendatamask/adapter/output/persistence/CustomDataMappingRepository.kt new file mode 100644 index 0000000..e4fbeaf --- /dev/null +++ b/backend/src/main/kotlin/com/opendatamask/adapter/output/persistence/CustomDataMappingRepository.kt @@ -0,0 +1,25 @@ +package com.opendatamask.adapter.output.persistence + +import com.opendatamask.domain.model.CustomDataMapping +import com.opendatamask.domain.port.output.CustomDataMappingPort +import org.springframework.data.jpa.repository.JpaRepository +import org.springframework.stereotype.Repository +import java.util.Optional + +@Repository +interface CustomDataMappingRepository : JpaRepository, CustomDataMappingPort { + override fun findById(id: Long): Optional + override fun findByWorkspaceId(workspaceId: Long): List + override fun findByWorkspaceIdAndConnectionIdAndTableName( + workspaceId: Long, + connectionId: Long, + tableName: String + ): List + override fun save(mapping: CustomDataMapping): CustomDataMapping + override fun deleteById(id: Long) + override fun deleteByWorkspaceIdAndConnectionIdAndTableName( + workspaceId: Long, + connectionId: Long, + tableName: String + ) +} diff --git a/backend/src/main/kotlin/com/opendatamask/application/service/CustomDataMappingService.kt b/backend/src/main/kotlin/com/opendatamask/application/service/CustomDataMappingService.kt new file mode 100644 index 0000000..581fb4e --- /dev/null +++ b/backend/src/main/kotlin/com/opendatamask/application/service/CustomDataMappingService.kt @@ -0,0 +1,135 @@ +package com.opendatamask.application.service + +import com.opendatamask.domain.model.CustomDataMapping +import com.opendatamask.domain.model.GeneratorType +import com.opendatamask.domain.model.MappingAction +import com.opendatamask.domain.model.MaskingStrategy +import com.opendatamask.domain.port.input.CustomDataMappingUseCase +import com.opendatamask.domain.port.input.dto.BulkCustomDataMappingRequest +import com.opendatamask.domain.port.input.dto.CustomDataMappingRequest +import com.opendatamask.domain.port.input.dto.CustomDataMappingResponse +import com.opendatamask.domain.port.output.CustomDataMappingPort +import org.springframework.stereotype.Service +import org.springframework.transaction.annotation.Transactional + +@Service +class CustomDataMappingService( + private val customDataMappingRepository: CustomDataMappingPort +) : CustomDataMappingUseCase { + + @Transactional + override fun createMapping(workspaceId: Long, request: CustomDataMappingRequest): CustomDataMappingResponse { + validateMaskingCombination(request.action, request.maskingStrategy, request.fakeGeneratorType) + val mapping = CustomDataMapping( + workspaceId = workspaceId, + connectionId = request.connectionId, + tableName = request.tableName, + columnName = request.columnName, + action = request.action, + maskingStrategy = if (request.action == MappingAction.MASK) request.maskingStrategy else null, + fakeGeneratorType = if (request.action == MappingAction.MASK) request.fakeGeneratorType else null + ) + return customDataMappingRepository.save(mapping).toResponse() + } + + @Transactional(readOnly = true) + override fun getMapping(workspaceId: Long, mappingId: Long): CustomDataMappingResponse = + findMapping(workspaceId, mappingId).toResponse() + + @Transactional(readOnly = true) + override fun listMappings(workspaceId: Long): List = + customDataMappingRepository.findByWorkspaceId(workspaceId).map { it.toResponse() } + + @Transactional(readOnly = true) + override fun listMappingsForTable( + workspaceId: Long, + connectionId: Long, + tableName: String + ): List = + customDataMappingRepository + .findByWorkspaceIdAndConnectionIdAndTableName(workspaceId, connectionId, tableName) + .map { it.toResponse() } + + @Transactional + override fun updateMapping( + workspaceId: Long, + mappingId: Long, + request: CustomDataMappingRequest + ): CustomDataMappingResponse { + validateMaskingCombination(request.action, request.maskingStrategy, request.fakeGeneratorType) + val mapping = findMapping(workspaceId, mappingId) + mapping.connectionId = request.connectionId + mapping.tableName = request.tableName + mapping.columnName = request.columnName + mapping.action = request.action + mapping.maskingStrategy = if (request.action == MappingAction.MASK) request.maskingStrategy else null + mapping.fakeGeneratorType = if (request.action == MappingAction.MASK) request.fakeGeneratorType else null + return customDataMappingRepository.save(mapping).toResponse() + } + + @Transactional + override fun deleteMapping(workspaceId: Long, mappingId: Long) { + val mapping = findMapping(workspaceId, mappingId) + customDataMappingRepository.deleteById(mapping.id) + } + + @Transactional + override fun saveBulkMappings( + workspaceId: Long, + request: BulkCustomDataMappingRequest + ): List { + customDataMappingRepository.deleteByWorkspaceIdAndConnectionIdAndTableName( + workspaceId, request.connectionId, request.tableName + ) + val mappings = request.columnMappings.map { entry -> + validateMaskingCombination(entry.action, entry.maskingStrategy, entry.fakeGeneratorType) + CustomDataMapping( + workspaceId = workspaceId, + connectionId = request.connectionId, + tableName = request.tableName, + columnName = entry.columnName, + action = entry.action, + maskingStrategy = if (entry.action == MappingAction.MASK) entry.maskingStrategy else null, + fakeGeneratorType = if (entry.action == MappingAction.MASK) entry.fakeGeneratorType else null + ) + } + return mappings.map { customDataMappingRepository.save(it).toResponse() } + } + + private fun findMapping(workspaceId: Long, mappingId: Long): CustomDataMapping { + val mapping = customDataMappingRepository.findById(mappingId) + .orElseThrow { NoSuchElementException("Custom data mapping not found: $mappingId") } + if (mapping.workspaceId != workspaceId) { + throw NoSuchElementException("Mapping $mappingId does not belong to workspace $workspaceId") + } + return mapping + } + + private fun validateMaskingCombination( + action: MappingAction, + maskingStrategy: MaskingStrategy?, + fakeGeneratorType: GeneratorType? + ) { + if (action == MappingAction.MASK) { + if (maskingStrategy == null) { + throw IllegalArgumentException("maskingStrategy is required when action is MASK") + } + if (maskingStrategy == MaskingStrategy.FAKE && fakeGeneratorType == null) { + throw IllegalArgumentException("fakeGeneratorType is required when maskingStrategy is FAKE") + } + } + } + + private fun CustomDataMapping.toResponse() = CustomDataMappingResponse( + id = id, + workspaceId = workspaceId, + connectionId = connectionId, + tableName = tableName, + columnName = columnName, + action = action, + maskingStrategy = maskingStrategy, + fakeGeneratorType = fakeGeneratorType, + createdAt = createdAt, + updatedAt = updatedAt + ) +} diff --git a/backend/src/main/kotlin/com/opendatamask/application/service/DataConnectionService.kt b/backend/src/main/kotlin/com/opendatamask/application/service/DataConnectionService.kt index cc6f1ad..8efc324 100644 --- a/backend/src/main/kotlin/com/opendatamask/application/service/DataConnectionService.kt +++ b/backend/src/main/kotlin/com/opendatamask/application/service/DataConnectionService.kt @@ -5,6 +5,7 @@ import com.opendatamask.domain.port.input.DataConnectionUseCase import com.opendatamask.domain.port.output.EncryptionPort import com.opendatamask.domain.port.output.ConnectorFactoryPort import com.opendatamask.domain.port.output.DataConnectionPort +import com.opendatamask.domain.port.input.dto.ConnectionSchemaResponse import com.opendatamask.domain.port.input.dto.ConnectionTestResult import com.opendatamask.domain.port.input.dto.DataConnectionRequest import com.opendatamask.domain.port.input.dto.DataConnectionResponse @@ -108,6 +109,34 @@ class DataConnectionService( } } + @Transactional(readOnly = true) + override fun browseConnectionSchema(workspaceId: Long, connectionId: Long): ConnectionSchemaResponse { + val connection = findConnection(workspaceId, connectionId) + val decryptedConnectionString = encryptionPort.decrypt(connection.connectionString) + val decryptedPassword = connection.password?.let { encryptionPort.decrypt(it) } + + val connector = connectorFactory.createConnector( + type = connection.type, + connectionString = decryptedConnectionString, + username = connection.username, + password = decryptedPassword, + database = connection.database + ) + + val tables = connector.listTables().map { tableName -> + val columns = connector.listColumns(tableName).map { col -> + ConnectionSchemaResponse.ColumnSchemaInfo( + name = col.name, + type = col.type, + nullable = col.nullable + ) + } + ConnectionSchemaResponse.TableSchemaInfo(tableName = tableName, columns = columns) + } + + return ConnectionSchemaResponse(connectionId = connectionId, tables = tables) + } + private fun findConnection(workspaceId: Long, connectionId: Long): DataConnection { val connection = dataConnectionRepository.findById(connectionId) .orElseThrow { NoSuchElementException("Connection not found: $connectionId") } diff --git a/backend/src/main/kotlin/com/opendatamask/domain/model/CustomDataMapping.kt b/backend/src/main/kotlin/com/opendatamask/domain/model/CustomDataMapping.kt new file mode 100644 index 0000000..4100d48 --- /dev/null +++ b/backend/src/main/kotlin/com/opendatamask/domain/model/CustomDataMapping.kt @@ -0,0 +1,69 @@ +package com.opendatamask.domain.model + +import jakarta.persistence.* +import java.time.LocalDateTime + +enum class MappingAction { + MIGRATE_AS_IS, MASK +} + +enum class MaskingStrategy { + FAKE, HASH, NULL +} + +@Entity +@Table( + name = "custom_data_mappings", + uniqueConstraints = [ + UniqueConstraint( + name = "uk_cdm_workspace_connection_table_column", + columnNames = ["workspace_id", "connection_id", "table_name", "column_name"] + ) + ] +) +class CustomDataMapping( + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + val id: Long = 0, + + @Column(name = "workspace_id", nullable = false) + var workspaceId: Long, + + @Column(name = "connection_id", nullable = false) + var connectionId: Long, + + @Column(name = "table_name", nullable = false) + var tableName: String, + + @Column(name = "column_name", nullable = false) + var columnName: String, + + @Enumerated(EnumType.STRING) + @Column(nullable = false) + var action: MappingAction, + + @Enumerated(EnumType.STRING) + @Column + var maskingStrategy: MaskingStrategy? = null, + + @Enumerated(EnumType.STRING) + @Column + var fakeGeneratorType: GeneratorType? = null, + + @Column(nullable = false) + var createdAt: LocalDateTime = LocalDateTime.now(), + + @Column(nullable = false) + var updatedAt: LocalDateTime = LocalDateTime.now() +) { + @PrePersist + fun prePersist() { + createdAt = LocalDateTime.now() + updatedAt = LocalDateTime.now() + } + + @PreUpdate + fun preUpdate() { + updatedAt = LocalDateTime.now() + } +} diff --git a/backend/src/main/kotlin/com/opendatamask/domain/port/input/CustomDataMappingUseCase.kt b/backend/src/main/kotlin/com/opendatamask/domain/port/input/CustomDataMappingUseCase.kt new file mode 100644 index 0000000..45c8eea --- /dev/null +++ b/backend/src/main/kotlin/com/opendatamask/domain/port/input/CustomDataMappingUseCase.kt @@ -0,0 +1,15 @@ +package com.opendatamask.domain.port.input + +import com.opendatamask.domain.port.input.dto.BulkCustomDataMappingRequest +import com.opendatamask.domain.port.input.dto.CustomDataMappingRequest +import com.opendatamask.domain.port.input.dto.CustomDataMappingResponse + +interface CustomDataMappingUseCase { + fun createMapping(workspaceId: Long, request: CustomDataMappingRequest): CustomDataMappingResponse + fun getMapping(workspaceId: Long, mappingId: Long): CustomDataMappingResponse + fun listMappings(workspaceId: Long): List + fun listMappingsForTable(workspaceId: Long, connectionId: Long, tableName: String): List + fun updateMapping(workspaceId: Long, mappingId: Long, request: CustomDataMappingRequest): CustomDataMappingResponse + fun deleteMapping(workspaceId: Long, mappingId: Long) + fun saveBulkMappings(workspaceId: Long, request: BulkCustomDataMappingRequest): List +} diff --git a/backend/src/main/kotlin/com/opendatamask/domain/port/input/DataConnectionUseCase.kt b/backend/src/main/kotlin/com/opendatamask/domain/port/input/DataConnectionUseCase.kt index eac2790..01ce0f7 100644 --- a/backend/src/main/kotlin/com/opendatamask/domain/port/input/DataConnectionUseCase.kt +++ b/backend/src/main/kotlin/com/opendatamask/domain/port/input/DataConnectionUseCase.kt @@ -1,5 +1,6 @@ package com.opendatamask.domain.port.input +import com.opendatamask.domain.port.input.dto.ConnectionSchemaResponse import com.opendatamask.domain.port.input.dto.ConnectionTestResult import com.opendatamask.domain.port.input.dto.DataConnectionRequest import com.opendatamask.domain.port.input.dto.DataConnectionResponse @@ -11,4 +12,5 @@ interface DataConnectionUseCase { fun updateConnection(workspaceId: Long, connectionId: Long, request: DataConnectionRequest): DataConnectionResponse fun deleteConnection(workspaceId: Long, connectionId: Long) fun testConnection(workspaceId: Long, connectionId: Long): ConnectionTestResult + fun browseConnectionSchema(workspaceId: Long, connectionId: Long): ConnectionSchemaResponse } diff --git a/backend/src/main/kotlin/com/opendatamask/domain/port/input/dto/CustomDataMappingDto.kt b/backend/src/main/kotlin/com/opendatamask/domain/port/input/dto/CustomDataMappingDto.kt new file mode 100644 index 0000000..fc6cc97 --- /dev/null +++ b/backend/src/main/kotlin/com/opendatamask/domain/port/input/dto/CustomDataMappingDto.kt @@ -0,0 +1,80 @@ +package com.opendatamask.domain.port.input.dto + +import com.opendatamask.domain.model.GeneratorType +import com.opendatamask.domain.model.MappingAction +import com.opendatamask.domain.model.MaskingStrategy +import jakarta.validation.Valid +import jakarta.validation.constraints.NotBlank +import jakarta.validation.constraints.NotNull +import java.time.LocalDateTime + +data class CustomDataMappingRequest( + @field:NotNull(message = "Connection ID is required") + val connectionId: Long, + + @field:NotBlank(message = "Table name is required") + val tableName: String, + + @field:NotBlank(message = "Column name is required") + val columnName: String, + + @field:NotNull(message = "Action is required") + val action: MappingAction, + + val maskingStrategy: MaskingStrategy? = null, + + val fakeGeneratorType: GeneratorType? = null +) + +data class BulkCustomDataMappingRequest( + @field:NotNull(message = "Connection ID is required") + val connectionId: Long, + + @field:NotBlank(message = "Table name is required") + val tableName: String, + + @field:NotNull(message = "Column mappings are required") + @field:Valid + val columnMappings: List +) { + data class ColumnMappingEntry( + @field:NotBlank(message = "Column name is required") + val columnName: String, + + @field:NotNull(message = "Action is required") + val action: MappingAction, + + val maskingStrategy: MaskingStrategy? = null, + + val fakeGeneratorType: GeneratorType? = null + ) +} + +data class CustomDataMappingResponse( + val id: Long, + val workspaceId: Long, + val connectionId: Long, + val tableName: String, + val columnName: String, + val action: MappingAction, + val maskingStrategy: MaskingStrategy?, + val fakeGeneratorType: GeneratorType?, + val createdAt: LocalDateTime, + val updatedAt: LocalDateTime +) + +data class ConnectionSchemaResponse( + val connectionId: Long, + val tables: List +) { + data class TableSchemaInfo( + val tableName: String, + val columns: List + ) + + data class ColumnSchemaInfo( + val name: String, + val type: String, + val nullable: Boolean + ) +} diff --git a/backend/src/main/kotlin/com/opendatamask/domain/port/output/CustomDataMappingPort.kt b/backend/src/main/kotlin/com/opendatamask/domain/port/output/CustomDataMappingPort.kt new file mode 100644 index 0000000..d20cd66 --- /dev/null +++ b/backend/src/main/kotlin/com/opendatamask/domain/port/output/CustomDataMappingPort.kt @@ -0,0 +1,21 @@ +package com.opendatamask.domain.port.output + +import com.opendatamask.domain.model.CustomDataMapping +import java.util.Optional + +interface CustomDataMappingPort { + fun findById(id: Long): Optional + fun findByWorkspaceId(workspaceId: Long): List + fun findByWorkspaceIdAndConnectionIdAndTableName( + workspaceId: Long, + connectionId: Long, + tableName: String + ): List + fun save(mapping: CustomDataMapping): CustomDataMapping + fun deleteById(id: Long) + fun deleteByWorkspaceIdAndConnectionIdAndTableName( + workspaceId: Long, + connectionId: Long, + tableName: String + ) +} diff --git a/backend/src/test/kotlin/com/opendatamask/application/service/CustomDataMappingServiceTest.kt b/backend/src/test/kotlin/com/opendatamask/application/service/CustomDataMappingServiceTest.kt new file mode 100644 index 0000000..51b6488 --- /dev/null +++ b/backend/src/test/kotlin/com/opendatamask/application/service/CustomDataMappingServiceTest.kt @@ -0,0 +1,290 @@ +package com.opendatamask.application.service + +import com.opendatamask.domain.model.CustomDataMapping +import com.opendatamask.domain.model.GeneratorType +import com.opendatamask.domain.model.MappingAction +import com.opendatamask.domain.model.MaskingStrategy +import com.opendatamask.domain.port.input.dto.BulkCustomDataMappingRequest +import com.opendatamask.domain.port.input.dto.CustomDataMappingRequest +import com.opendatamask.domain.port.output.CustomDataMappingPort +import org.junit.jupiter.api.Assertions.* +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.assertThrows +import org.junit.jupiter.api.extension.ExtendWith +import org.mockito.InjectMocks +import org.mockito.Mock +import org.mockito.junit.jupiter.MockitoExtension +import org.mockito.kotlin.* +import java.util.Optional + +@ExtendWith(MockitoExtension::class) +class CustomDataMappingServiceTest { + + @Mock private lateinit var customDataMappingPort: CustomDataMappingPort + + @InjectMocks + private lateinit var service: CustomDataMappingService + + private fun makeMapping( + id: Long = 1L, + workspaceId: Long = 10L, + connectionId: Long = 2L, + tableName: String = "users", + columnName: String = "email", + action: MappingAction = MappingAction.MASK, + maskingStrategy: MaskingStrategy? = MaskingStrategy.FAKE, + fakeGeneratorType: GeneratorType? = GeneratorType.EMAIL + ) = CustomDataMapping( + id = id, + workspaceId = workspaceId, + connectionId = connectionId, + tableName = tableName, + columnName = columnName, + action = action, + maskingStrategy = maskingStrategy, + fakeGeneratorType = fakeGeneratorType + ) + + // ── createMapping ────────────────────────────────────────────────────── + + @Test + fun `createMapping saves and returns response`() { + val saved = makeMapping(id = 1L) + val request = CustomDataMappingRequest( + connectionId = 2L, + tableName = "users", + columnName = "email", + action = MappingAction.MASK, + maskingStrategy = MaskingStrategy.FAKE, + fakeGeneratorType = GeneratorType.EMAIL + ) + whenever(customDataMappingPort.save(any())).thenReturn(saved) + + val response = service.createMapping(10L, request) + + assertEquals(1L, response.id) + assertEquals(10L, response.workspaceId) + assertEquals("users", response.tableName) + assertEquals("email", response.columnName) + assertEquals(MappingAction.MASK, response.action) + assertEquals(MaskingStrategy.FAKE, response.maskingStrategy) + assertEquals(GeneratorType.EMAIL, response.fakeGeneratorType) + } + + @Test + fun `createMapping with MIGRATE_AS_IS action has null masking fields`() { + val saved = makeMapping( + id = 1L, action = MappingAction.MIGRATE_AS_IS, + maskingStrategy = null, fakeGeneratorType = null + ) + val request = CustomDataMappingRequest( + connectionId = 2L, + tableName = "users", + columnName = "id", + action = MappingAction.MIGRATE_AS_IS + ) + whenever(customDataMappingPort.save(any())).thenReturn(saved) + + val response = service.createMapping(10L, request) + + assertEquals(MappingAction.MIGRATE_AS_IS, response.action) + assertNull(response.maskingStrategy) + assertNull(response.fakeGeneratorType) + } + + // ── getMapping ───────────────────────────────────────────────────────── + + @Test + fun `getMapping returns mapping by id`() { + val mapping = makeMapping(id = 1L, workspaceId = 10L) + whenever(customDataMappingPort.findById(1L)).thenReturn(Optional.of(mapping)) + + val response = service.getMapping(10L, 1L) + + assertEquals(1L, response.id) + assertEquals("email", response.columnName) + } + + @Test + fun `getMapping throws when mapping not found`() { + whenever(customDataMappingPort.findById(99L)).thenReturn(Optional.empty()) + + assertThrows { service.getMapping(10L, 99L) } + } + + @Test + fun `getMapping throws when mapping belongs to different workspace`() { + val mapping = makeMapping(id = 1L, workspaceId = 20L) + whenever(customDataMappingPort.findById(1L)).thenReturn(Optional.of(mapping)) + + assertThrows { service.getMapping(10L, 1L) } + } + + // ── listMappings ─────────────────────────────────────────────────────── + + @Test + fun `listMappings returns all mappings for workspace`() { + val mappings = listOf(makeMapping(id = 1L), makeMapping(id = 2L, columnName = "phone")) + whenever(customDataMappingPort.findByWorkspaceId(10L)).thenReturn(mappings) + + val result = service.listMappings(10L) + + assertEquals(2, result.size) + } + + // ── listMappingsForTable ─────────────────────────────────────────────── + + @Test + fun `listMappingsForTable returns mappings for specified table`() { + val mappings = listOf( + makeMapping(id = 1L, tableName = "users", columnName = "email"), + makeMapping(id = 2L, tableName = "users", columnName = "phone") + ) + whenever(customDataMappingPort.findByWorkspaceIdAndConnectionIdAndTableName(10L, 2L, "users")) + .thenReturn(mappings) + + val result = service.listMappingsForTable(10L, 2L, "users") + + assertEquals(2, result.size) + } + + // ── updateMapping ────────────────────────────────────────────────────── + + @Test + fun `updateMapping updates and returns updated response`() { + val existing = makeMapping(id = 1L, workspaceId = 10L, action = MappingAction.MIGRATE_AS_IS) + val request = CustomDataMappingRequest( + connectionId = 2L, + tableName = "users", + columnName = "email", + action = MappingAction.MASK, + maskingStrategy = MaskingStrategy.NULL + ) + whenever(customDataMappingPort.findById(1L)).thenReturn(Optional.of(existing)) + whenever(customDataMappingPort.save(any())).thenAnswer { it.arguments[0] as CustomDataMapping } + + val response = service.updateMapping(10L, 1L, request) + + assertEquals(MappingAction.MASK, response.action) + assertEquals(MaskingStrategy.NULL, response.maskingStrategy) + } + + @Test + fun `updateMapping throws when mapping not found`() { + whenever(customDataMappingPort.findById(99L)).thenReturn(Optional.empty()) + + assertThrows { + service.updateMapping(10L, 99L, CustomDataMappingRequest( + connectionId = 2L, tableName = "users", columnName = "email", + action = MappingAction.MIGRATE_AS_IS + )) + } + } + + // ── deleteMapping ────────────────────────────────────────────────────── + + @Test + fun `deleteMapping removes mapping`() { + val mapping = makeMapping(id = 1L, workspaceId = 10L) + whenever(customDataMappingPort.findById(1L)).thenReturn(Optional.of(mapping)) + + service.deleteMapping(10L, 1L) + + verify(customDataMappingPort).deleteById(1L) + } + + @Test + fun `deleteMapping throws when mapping not found`() { + whenever(customDataMappingPort.findById(99L)).thenReturn(Optional.empty()) + + assertThrows { service.deleteMapping(10L, 99L) } + } + + // ── saveBulkMappings ─────────────────────────────────────────────────── + + @Test + fun `saveBulkMappings replaces existing mappings for table and returns all`() { + val request = BulkCustomDataMappingRequest( + connectionId = 2L, + tableName = "users", + columnMappings = listOf( + BulkCustomDataMappingRequest.ColumnMappingEntry("id", MappingAction.MIGRATE_AS_IS), + BulkCustomDataMappingRequest.ColumnMappingEntry("email", MappingAction.MASK, MaskingStrategy.FAKE, GeneratorType.EMAIL), + BulkCustomDataMappingRequest.ColumnMappingEntry("ssn", MappingAction.MASK, MaskingStrategy.NULL) + ) + ) + whenever(customDataMappingPort.save(any())) + .thenAnswer { it.arguments[0] as CustomDataMapping } + + val result = service.saveBulkMappings(10L, request) + + verify(customDataMappingPort).deleteByWorkspaceIdAndConnectionIdAndTableName(10L, 2L, "users") + verify(customDataMappingPort, times(3)).save(any()) + assertEquals(3, result.size) + assertEquals("id", result[0].columnName) + assertEquals(MappingAction.MIGRATE_AS_IS, result[0].action) + assertEquals(MappingAction.MASK, result[1].action) + assertEquals(MaskingStrategy.FAKE, result[1].maskingStrategy) + assertEquals(GeneratorType.EMAIL, result[1].fakeGeneratorType) + } + + @Test + fun `saveBulkMappings with HASH strategy sets correct masking strategy`() { + val request = BulkCustomDataMappingRequest( + connectionId = 2L, + tableName = "users", + columnMappings = listOf( + BulkCustomDataMappingRequest.ColumnMappingEntry("user_ref", MappingAction.MASK, MaskingStrategy.HASH) + ) + ) + val savedMapping = CustomDataMapping( + id = 1L, workspaceId = 10L, connectionId = 2L, tableName = "users", + columnName = "user_ref", action = MappingAction.MASK, maskingStrategy = MaskingStrategy.HASH + ) + whenever(customDataMappingPort.save(any())).thenReturn(savedMapping) + + val result = service.saveBulkMappings(10L, request) + + assertEquals(1, result.size) + assertEquals(MaskingStrategy.HASH, result[0].maskingStrategy) + assertNull(result[0].fakeGeneratorType) + } + + // ── validation ───────────────────────────────────────────────────────── + + @Test + fun `createMapping throws when MASK action is missing maskingStrategy`() { + val request = CustomDataMappingRequest( + connectionId = 2L, tableName = "users", columnName = "email", + action = MappingAction.MASK, maskingStrategy = null + ) + + assertThrows { service.createMapping(10L, request) } + verify(customDataMappingPort, never()).save(any()) + } + + @Test + fun `createMapping throws when FAKE strategy is missing fakeGeneratorType`() { + val request = CustomDataMappingRequest( + connectionId = 2L, tableName = "users", columnName = "email", + action = MappingAction.MASK, maskingStrategy = MaskingStrategy.FAKE, fakeGeneratorType = null + ) + + assertThrows { service.createMapping(10L, request) } + verify(customDataMappingPort, never()).save(any()) + } + + @Test + fun `saveBulkMappings throws when MASK entry is missing maskingStrategy`() { + val request = BulkCustomDataMappingRequest( + connectionId = 2L, + tableName = "users", + columnMappings = listOf( + BulkCustomDataMappingRequest.ColumnMappingEntry("email", MappingAction.MASK, maskingStrategy = null) + ) + ) + + assertThrows { service.saveBulkMappings(10L, request) } + verify(customDataMappingPort, never()).save(any()) + } +} diff --git a/backend/src/test/kotlin/com/opendatamask/application/service/DataConnectionServiceTest.kt b/backend/src/test/kotlin/com/opendatamask/application/service/DataConnectionServiceTest.kt index 8edc407..3fca733 100644 --- a/backend/src/test/kotlin/com/opendatamask/application/service/DataConnectionServiceTest.kt +++ b/backend/src/test/kotlin/com/opendatamask/application/service/DataConnectionServiceTest.kt @@ -314,6 +314,41 @@ class DataConnectionServiceTest { assertFalse(result.success) assertTrue(result.message.contains("host unreachable")) } -} + // ── browseConnectionSchema ───────────────────────────────────────────── + @Test + fun `browseConnectionSchema returns tables and columns from connector`() { + val conn = makeConnection(id = 1L, workspaceId = 10L) + val mockConnector = mock() + whenever(dataConnectionRepository.findById(1L)).thenReturn(Optional.of(conn)) + whenever(EncryptionPort.decrypt("encrypted_conn")).thenReturn("real_conn") + whenever(EncryptionPort.decrypt("encrypted_pass")).thenReturn("real_pass") + whenever(connectorFactory.createConnector(any(), any(), anyOrNull(), anyOrNull(), anyOrNull())) + .thenReturn(mockConnector) + whenever(mockConnector.listTables()).thenReturn(listOf("users", "orders")) + whenever(mockConnector.listColumns("users")).thenReturn(listOf( + com.opendatamask.domain.port.output.ColumnInfo("id", "bigint", false), + com.opendatamask.domain.port.output.ColumnInfo("email", "varchar", true) + )) + whenever(mockConnector.listColumns("orders")).thenReturn(listOf( + com.opendatamask.domain.port.output.ColumnInfo("id", "bigint", false) + )) + + val result = service.browseConnectionSchema(10L, 1L) + + assertEquals(1L, result.connectionId) + assertEquals(2, result.tables.size) + val usersTable = result.tables.find { it.tableName == "users" }!! + assertEquals(2, usersTable.columns.size) + assertEquals("email", usersTable.columns[1].name) + assertTrue(usersTable.columns[1].nullable) + } + + @Test + fun `browseConnectionSchema throws when connection not found`() { + whenever(dataConnectionRepository.findById(99L)).thenReturn(Optional.empty()) + + assertThrows { service.browseConnectionSchema(10L, 99L) } + } +} \ No newline at end of file diff --git a/docs/user-guide.md b/docs/user-guide.md index db8fa1b..0d2daf1 100644 --- a/docs/user-guide.md +++ b/docs/user-guide.md @@ -319,6 +319,65 @@ OpenDataMask automatically scans workspaces to detect sensitive columns using pa --- +## Custom Data Mapping + +The **Custom Data Mapping** wizard provides a guided, column-level alternative to the Tables view for configuring how each attribute moves from source to destination. + +### Actions + +| Action | Description | +|--------|-------------| +| `MIGRATE_AS_IS` | Copy the column value without modification. Use for primary keys, timestamps, and public metadata. | +| `MASK` | Replace the column value using a selected masking strategy. | + +### Masking Strategies (when `MASK` is selected) + +| Strategy | Description | +|----------|-------------| +| `FAKE` | Replace with realistic synthetic data. Select a generator type (e.g. `EMAIL`, `FULL_NAME`, `PHONE`). | +| `HASH` | Apply deterministic SHA-256 hashing to preserve joins while anonymising the value. | +| `NULL` | Remove the value entirely (sets the column to `NULL`). | + +### How to use the wizard + +1. Navigate to **Workspace → Data Mappings**. +2. **Step 1 – Select Connection**: choose a source database connection from the list. +3. **Step 2 – Select Table**: OpenDataMask reads the live schema and presents all available tables. +4. **Step 3 – Configure Columns**: for every column, select `MIGRATE_AS_IS` or `MASK`. If `MASK` is chosen, select a strategy and (for `FAKE`) a generator type. +5. Click **Save Mappings** to persist the configuration. + +### API + +The following REST endpoints manage custom data mappings: + +| Method | Path | Description | +|--------|------|-------------| +| `POST` | `/api/workspaces/{id}/mappings` | Create a single mapping | +| `GET` | `/api/workspaces/{id}/mappings` | List all mappings (optional `?connectionId=&tableName=` filters) | +| `GET` | `/api/workspaces/{id}/mappings/{mappingId}` | Get a specific mapping | +| `PUT` | `/api/workspaces/{id}/mappings/{mappingId}` | Update a mapping | +| `DELETE` | `/api/workspaces/{id}/mappings/{mappingId}` | Delete a mapping | +| `POST` | `/api/workspaces/{id}/mappings/bulk` | Replace all mappings for a table in one request | +| `GET` | `/api/workspaces/{id}/connections/{connId}/schema` | Browse live tables & columns from a connection | + +#### Bulk mapping request example + +```json +{ + "connectionId": 3, + "tableName": "users", + "columnMappings": [ + { "columnName": "id", "action": "MIGRATE_AS_IS" }, + { "columnName": "email", "action": "MASK", "maskingStrategy": "FAKE", "fakeGeneratorType": "EMAIL" }, + { "columnName": "name", "action": "MASK", "maskingStrategy": "FAKE", "fakeGeneratorType": "FULL_NAME" }, + { "columnName": "ssn", "action": "MASK", "maskingStrategy": "NULL" }, + { "columnName": "ref_id","action": "MASK", "maskingStrategy": "HASH" } + ] +} +``` + +--- + ## Operational Workflow ### Step-by-step: Mask a database diff --git a/frontend/src/api/mapping.ts b/frontend/src/api/mapping.ts new file mode 100644 index 0000000..64e166f --- /dev/null +++ b/frontend/src/api/mapping.ts @@ -0,0 +1,78 @@ +import apiClient from './client' +import type { + CustomDataMapping, + CustomDataMappingRequest, + BulkCustomDataMappingRequest, + ConnectionSchemaResponse +} from '@/types' + +// ── Custom Data Mappings ────────────────────────────────────────────────── + +export async function listMappings(workspaceId: number): Promise { + const { data } = await apiClient.get( + `/workspaces/${workspaceId}/mappings` + ) + return data +} + +export async function listMappingsForTable( + workspaceId: number, + connectionId: number, + tableName: string +): Promise { + const { data } = await apiClient.get( + `/workspaces/${workspaceId}/mappings`, + { params: { connectionId, tableName } } + ) + return data +} + +export async function createMapping( + workspaceId: number, + payload: CustomDataMappingRequest +): Promise { + const { data } = await apiClient.post( + `/workspaces/${workspaceId}/mappings`, + payload + ) + return data +} + +export async function updateMapping( + workspaceId: number, + mappingId: number, + payload: CustomDataMappingRequest +): Promise { + const { data } = await apiClient.put( + `/workspaces/${workspaceId}/mappings/${mappingId}`, + payload + ) + return data +} + +export async function deleteMapping(workspaceId: number, mappingId: number): Promise { + await apiClient.delete(`/workspaces/${workspaceId}/mappings/${mappingId}`) +} + +export async function saveBulkMappings( + workspaceId: number, + payload: BulkCustomDataMappingRequest +): Promise { + const { data } = await apiClient.post( + `/workspaces/${workspaceId}/mappings/bulk`, + payload + ) + return data +} + +// ── Connection Schema Browsing ──────────────────────────────────────────── + +export async function browseConnectionSchema( + workspaceId: number, + connectionId: number +): Promise { + const { data } = await apiClient.get( + `/workspaces/${workspaceId}/connections/${connectionId}/schema` + ) + return data +} diff --git a/frontend/src/router/index.ts b/frontend/src/router/index.ts index 2203b6a..40ae7b2 100644 --- a/frontend/src/router/index.ts +++ b/frontend/src/router/index.ts @@ -10,6 +10,7 @@ import TablesView from '@/views/TablesView.vue' import JobsView from '@/views/JobsView.vue' import ActionsView from '@/views/ActionsView.vue' import SensitivityRulesView from '@/views/SensitivityRulesView.vue' +import DataMappingView from '@/views/DataMappingView.vue' const SAML_AUTH_ENDPOINT = '/saml2/authenticate/default' const samlEnabled = import.meta.env.VITE_SAML_ENABLED === 'true' @@ -77,6 +78,12 @@ const router = createRouter({ name: 'sensitivity-rules', component: SensitivityRulesView, meta: { requiresAuth: true } + }, + { + path: '/workspaces/:id/mappings', + name: 'data-mappings', + component: DataMappingView, + meta: { requiresAuth: true } } ] }) diff --git a/frontend/src/types/index.ts b/frontend/src/types/index.ts index 6c819c1..8d98aa8 100644 --- a/frontend/src/types/index.ts +++ b/frontend/src/types/index.ts @@ -373,7 +373,71 @@ export interface WorkspaceConfigDto { actions: WorkspaceActionExport[] } -// ── Custom Sensitivity Rules ─────────────────────────────────────────────── +// ── Custom Data Mapping ──────────────────────────────────────────────────── + +export enum MappingAction { + MIGRATE_AS_IS = 'MIGRATE_AS_IS', + MASK = 'MASK' +} + +export enum MaskingStrategy { + FAKE = 'FAKE', + HASH = 'HASH', + NULL = 'NULL' +} + +export interface CustomDataMapping { + id: number + workspaceId: number + connectionId: number + tableName: string + columnName: string + action: MappingAction + maskingStrategy: MaskingStrategy | null + fakeGeneratorType: GeneratorType | null + createdAt: string + updatedAt: string +} + +export interface CustomDataMappingRequest { + connectionId: number + tableName: string + columnName: string + action: MappingAction + maskingStrategy?: MaskingStrategy | null + fakeGeneratorType?: GeneratorType | null +} + +export interface ColumnMappingEntry { + columnName: string + action: MappingAction + maskingStrategy?: MaskingStrategy | null + fakeGeneratorType?: GeneratorType | null +} + +export interface BulkCustomDataMappingRequest { + connectionId: number + tableName: string + columnMappings: ColumnMappingEntry[] +} + +export interface ColumnSchemaInfo { + name: string + type: string + nullable: boolean +} + +export interface TableSchemaInfo { + tableName: string + columns: ColumnSchemaInfo[] +} + +export interface ConnectionSchemaResponse { + connectionId: number + tables: TableSchemaInfo[] +} + + export enum GenericDataType { TEXT = 'TEXT', diff --git a/frontend/src/views/DataMappingView.vue b/frontend/src/views/DataMappingView.vue new file mode 100644 index 0000000..3e9328d --- /dev/null +++ b/frontend/src/views/DataMappingView.vue @@ -0,0 +1,685 @@ + + + + + diff --git a/frontend/src/views/WorkspaceDetailView.vue b/frontend/src/views/WorkspaceDetailView.vue index 6ee1599..abd7acb 100644 --- a/frontend/src/views/WorkspaceDetailView.vue +++ b/frontend/src/views/WorkspaceDetailView.vue @@ -34,11 +34,12 @@ async function fetchStats() { } const tabs = [ - { label: 'Overview', icon: '📋', path: () => `/workspaces/${workspaceId.value}` }, - { label: 'Connections', icon: '🔌', path: () => `/workspaces/${workspaceId.value}/connections` }, - { label: 'Tables', icon: '📊', path: () => `/workspaces/${workspaceId.value}/tables` }, - { label: 'Jobs', icon: '⚙️', path: () => `/workspaces/${workspaceId.value}/jobs` }, - { label: 'Actions', icon: '⚡', path: () => `/workspaces/${workspaceId.value}/actions` } + { label: 'Overview', icon: '📋', path: () => `/workspaces/${workspaceId.value}` }, + { label: 'Connections', icon: '🔌', path: () => `/workspaces/${workspaceId.value}/connections` }, + { label: 'Tables', icon: '📊', path: () => `/workspaces/${workspaceId.value}/tables` }, + { label: 'Data Mappings', icon: '🗺️', path: () => `/workspaces/${workspaceId.value}/mappings` }, + { label: 'Jobs', icon: '⚙️', path: () => `/workspaces/${workspaceId.value}/jobs` }, + { label: 'Actions', icon: '⚡', path: () => `/workspaces/${workspaceId.value}/actions` } ] function navigate(path: string) { @@ -132,6 +133,16 @@ function formatDate(d: string) {
Configure masking rules
+