diff --git a/services/hanke-service/src/integrationTest/kotlin/fi/hel/haitaton/hanke/permissions/HankeKayttajaControllerITest.kt b/services/hanke-service/src/integrationTest/kotlin/fi/hel/haitaton/hanke/permissions/HankeKayttajaControllerITest.kt index 5475d03ad..cef2b08d4 100644 --- a/services/hanke-service/src/integrationTest/kotlin/fi/hel/haitaton/hanke/permissions/HankeKayttajaControllerITest.kt +++ b/services/hanke-service/src/integrationTest/kotlin/fi/hel/haitaton/hanke/permissions/HankeKayttajaControllerITest.kt @@ -16,6 +16,7 @@ import fi.hel.haitaton.hanke.factory.HankeKayttajaFactory import fi.hel.haitaton.hanke.hankeError import fi.hel.haitaton.hanke.hasSameElementsAs import fi.hel.haitaton.hanke.logging.DisclosureLogService +import fi.hel.haitaton.hanke.permissions.HankeKayttajaController.Tunnistautuminen import fi.hel.haitaton.hanke.permissions.PermissionCode.VIEW import io.mockk.Called import io.mockk.checkUnnecessaryStub @@ -334,6 +335,74 @@ class HankeKayttajaControllerITest(@Autowired override val mockMvc: MockMvc) : C return Pair(hanke, updates) } } + + @Nested + inner class IdentifyUser { + private val url = "/kayttajat" + private val tunniste = "r5cmC0BmJaSX5Q6WA981ow8j" + private val tunnisteId = UUID.fromString("827fe492-2add-4d87-9564-049f963c1d86") + private val kayttajaId = UUID.fromString("ee239f7a-c5bb-4462-b9a5-3695eb410086") + private val permissionId = 156 + + @Test + fun `Returns 204 on success`() { + justRun { hankeKayttajaService.createPermissionFromToken(USERNAME, tunniste) } + + post(url, Tunnistautuminen(tunniste)) + .andExpect(status().isNoContent) + .andExpect(content().string("")) + + verify { hankeKayttajaService.createPermissionFromToken(USERNAME, tunniste) } + } + + @Test + fun `Returns 404 when tunniste not found`() { + every { hankeKayttajaService.createPermissionFromToken(USERNAME, tunniste) } throws + TunnisteNotFoundException(USERNAME, tunniste) + + post(url, Tunnistautuminen(tunniste)) + .andExpect(status().isNotFound) + .andExpect(hankeError(HankeError.HAI4004)) + + verify { hankeKayttajaService.createPermissionFromToken(USERNAME, tunniste) } + } + + @Test + fun `Returns 500 when tunniste is orphaned`() { + every { hankeKayttajaService.createPermissionFromToken(USERNAME, tunniste) } throws + OrphanedTunnisteException(USERNAME, tunnisteId) + + post(url, Tunnistautuminen(tunniste)) + .andExpect(status().isInternalServerError) + .andExpect(hankeError(HankeError.HAI4001)) + + verify { hankeKayttajaService.createPermissionFromToken(USERNAME, tunniste) } + } + + @Test + fun `Returns 409 when user already has a permission`() { + every { hankeKayttajaService.createPermissionFromToken(USERNAME, tunniste) } throws + UserAlreadyHasPermissionException(USERNAME, tunnisteId, permissionId) + + post(url, Tunnistautuminen(tunniste)) + .andExpect(status().isConflict) + .andExpect(hankeError(HankeError.HAI4003)) + + verify { hankeKayttajaService.createPermissionFromToken(USERNAME, tunniste) } + } + + @Test + fun `Returns 409 when other user already has a permission for the hanke kayttaja`() { + every { hankeKayttajaService.createPermissionFromToken(USERNAME, tunniste) } throws + PermissionAlreadyExistsException(USERNAME, "Other user", kayttajaId, permissionId) + + post(url, Tunnistautuminen(tunniste)) + .andExpect(status().isConflict) + .andExpect(hankeError(HankeError.HAI4003)) + + verify { hankeKayttajaService.createPermissionFromToken(USERNAME, tunniste) } + } + } } @WebMvcTest( diff --git a/services/hanke-service/src/integrationTest/kotlin/fi/hel/haitaton/hanke/permissions/HankeKayttajaServiceITest.kt b/services/hanke-service/src/integrationTest/kotlin/fi/hel/haitaton/hanke/permissions/HankeKayttajaServiceITest.kt index d106528fa..b38c02b25 100644 --- a/services/hanke-service/src/integrationTest/kotlin/fi/hel/haitaton/hanke/permissions/HankeKayttajaServiceITest.kt +++ b/services/hanke-service/src/integrationTest/kotlin/fi/hel/haitaton/hanke/permissions/HankeKayttajaServiceITest.kt @@ -16,6 +16,7 @@ import assertk.assertions.isNotNull import assertk.assertions.isNull import assertk.assertions.matches import assertk.assertions.messageContains +import com.fasterxml.jackson.databind.util.ClassUtil.hasClass import fi.hel.haitaton.hanke.DatabaseTest import fi.hel.haitaton.hanke.factory.AlluDataFactory import fi.hel.haitaton.hanke.factory.AlluDataFactory.Companion.withContacts @@ -633,6 +634,121 @@ class HankeKayttajaServiceITest : DatabaseTest() { } } + @Nested + inner class CreatePermissionFromToken { + private val tunniste = "Itf4UuErPqBHkhJF7CUAsu69" + private val newUserId = "newUser" + + @Test + fun `throws exception if tunniste doesn't exist`() { + assertFailure { hankeKayttajaService.createPermissionFromToken(newUserId, "fake") } + .all { + hasClass(TunnisteNotFoundException::class) + messageContains(newUserId) + messageContains("fake") + } + } + + @Test + fun `throws exception if there's no kayttaja with the tunniste`() { + val tunniste = saveToken() + + assertFailure { hankeKayttajaService.createPermissionFromToken(newUserId, "existing") } + .all { + hasClass(OrphanedTunnisteException::class) + messageContains(newUserId) + messageContains(tunniste.id.toString()) + } + } + + @Test + fun `throws an exception if the user already has a permission for the hanke kayttaja`() { + val tunniste = saveToken() + val hanke = hankeFactory.save() + val kayttaja = + saveUserAndPermission( + hanke.id!!, + kayttajaTunniste = tunniste, + userId = newUserId, + ) + + assertFailure { hankeKayttajaService.createPermissionFromToken(newUserId, "existing") } + .all { + hasClass(UserAlreadyHasPermissionException::class) + messageContains(newUserId) + messageContains(tunniste.id.toString()) + messageContains(kayttaja.permission!!.id.toString()) + } + } + + @Test + fun `throws an exception if the user has a permission for the hanke from elsewhere`() { + val hanke = hankeFactory.save() + val kayttaja = saveUserAndToken(hanke.id!!, tunniste = tunniste) + val permission = + permissionRepository.save( + PermissionEntity( + userId = newUserId, + hankeId = hanke.id!!, + role = roleRepository.findOneByRole(Role.KATSELUOIKEUS) + ) + ) + + assertFailure { hankeKayttajaService.createPermissionFromToken(newUserId, tunniste) } + .all { + hasClass(UserAlreadyHasPermissionException::class) + messageContains(newUserId) + messageContains(kayttaja.kayttajaTunniste!!.id.toString()) + messageContains(permission.id.toString()) + } + } + + @Test + fun `throws an exception if another user already has a permission for the hanke kayttaja`() { + val tunniste = saveToken() + val hanke = hankeFactory.save() + val kayttaja = + saveUserAndPermission( + hanke.id!!, + kayttajaTunniste = tunniste, + userId = "Other user", + ) + + assertFailure { hankeKayttajaService.createPermissionFromToken(newUserId, "existing") } + .all { + hasClass(PermissionAlreadyExistsException::class) + messageContains(newUserId) + messageContains("Other user") + messageContains(kayttaja.id.toString()) + messageContains(kayttaja.permission!!.id.toString()) + } + } + + @Test + fun `Creates a permission`() { + val hanke = hankeFactory.save() + saveUserAndToken(hanke.id!!, tunniste = tunniste) + + hankeKayttajaService.createPermissionFromToken(newUserId, tunniste) + + val permission = permissionRepository.findOneByHankeIdAndUserId(hanke.id!!, newUserId) + assertThat(permission) + .isNotNull() + .transform { it.role.role } + .isEqualTo(Role.KATSELUOIKEUS) + } + + @Test + fun `Removes the user token`() { + val hanke = hankeFactory.save() + saveUserAndToken(hanke.id!!, tunniste = tunniste) + + hankeKayttajaService.createPermissionFromToken(newUserId, tunniste) + + assertThat(kayttajaTunnisteRepository.findAll()).isEmpty() + } + } + private fun Assert>.areValid() = each { t -> t.transform { it.id }.isNotNull() t.transform { it.role }.isEqualTo(Role.KATSELUOIKEUS) @@ -688,16 +804,7 @@ class HankeKayttajaServiceITest : DatabaseTest() { role: Role = Role.KATSELUOIKEUS, tunniste: String = "existing", ): HankeKayttajaEntity { - val kayttajaTunnisteEntity = - kayttajaTunnisteRepository.save( - KayttajaTunnisteEntity( - tunniste = tunniste, - createdAt = OffsetDateTime.parse("2023-03-31T15:41:21Z"), - sentAt = null, - role = role, - hankeKayttaja = null, - ) - ) + val kayttajaTunnisteEntity = saveToken(tunniste, role) return saveUser(hankeId, nimi, sahkoposti, null, kayttajaTunnisteEntity) } @@ -732,6 +839,20 @@ class HankeKayttajaServiceITest : DatabaseTest() { ) } + private fun saveToken( + tunniste: String = "existing", + role: Role = Role.KATSELUOIKEUS, + ) = + kayttajaTunnisteRepository.save( + KayttajaTunnisteEntity( + tunniste = tunniste, + createdAt = OffsetDateTime.parse("2023-03-31T15:41:21Z"), + sentAt = null, + role = role, + hankeKayttaja = null, + ) + ) + private fun savePermission(hankeId: Int, userId: String, role: Role) = permissionRepository.save( PermissionEntity( diff --git a/services/hanke-service/src/main/kotlin/fi/hel/haitaton/hanke/HankeError.kt b/services/hanke-service/src/main/kotlin/fi/hel/haitaton/hanke/HankeError.kt index e71ccde12..486af75c0 100644 --- a/services/hanke-service/src/main/kotlin/fi/hel/haitaton/hanke/HankeError.kt +++ b/services/hanke-service/src/main/kotlin/fi/hel/haitaton/hanke/HankeError.kt @@ -2,9 +2,11 @@ package fi.hel.haitaton.hanke import com.fasterxml.jackson.annotation.JsonFormat import com.fasterxml.jackson.annotation.JsonUnwrapped +import io.swagger.v3.oas.annotations.media.Schema import jakarta.validation.ConstraintViolation @JsonFormat(shape = JsonFormat.Shape.OBJECT) +@Schema(enumAsRef = true) enum class HankeError(val errorMessage: String) { HAI0001("Access denied"), HAI0002("Internal error"), @@ -44,6 +46,7 @@ enum class HankeError(val errorMessage: String) { HAI4001("HankeKayttaja not found"), HAI4002("Trying to change own permission"), HAI4003("Permission data conflict"), + HAI4004("Kayttajatunniste not found"), ; val errorCode: String diff --git a/services/hanke-service/src/main/kotlin/fi/hel/haitaton/hanke/permissions/HankeKayttaja.kt b/services/hanke-service/src/main/kotlin/fi/hel/haitaton/hanke/permissions/HankeKayttaja.kt index b3b663c54..e9d3e3e37 100644 --- a/services/hanke-service/src/main/kotlin/fi/hel/haitaton/hanke/permissions/HankeKayttaja.kt +++ b/services/hanke-service/src/main/kotlin/fi/hel/haitaton/hanke/permissions/HankeKayttaja.kt @@ -34,10 +34,10 @@ class HankeKayttajaEntity( val sahkoposti: String, @OneToOne @JoinColumn(name = "permission_id", updatable = true, nullable = true) - val permission: PermissionEntity?, + var permission: PermissionEntity?, @OneToOne @JoinColumn(name = "tunniste_id", updatable = true, nullable = true) - val kayttajaTunniste: KayttajaTunnisteEntity?, + var kayttajaTunniste: KayttajaTunnisteEntity?, ) { fun toDto(): HankeKayttajaDto = HankeKayttajaDto( diff --git a/services/hanke-service/src/main/kotlin/fi/hel/haitaton/hanke/permissions/HankeKayttajaController.kt b/services/hanke-service/src/main/kotlin/fi/hel/haitaton/hanke/permissions/HankeKayttajaController.kt index bca35c1fe..9a6fd5095 100644 --- a/services/hanke-service/src/main/kotlin/fi/hel/haitaton/hanke/permissions/HankeKayttajaController.kt +++ b/services/hanke-service/src/main/kotlin/fi/hel/haitaton/hanke/permissions/HankeKayttajaController.kt @@ -20,16 +20,15 @@ import org.springframework.http.MediaType.APPLICATION_JSON_VALUE import org.springframework.web.bind.annotation.ExceptionHandler import org.springframework.web.bind.annotation.GetMapping import org.springframework.web.bind.annotation.PathVariable +import org.springframework.web.bind.annotation.PostMapping import org.springframework.web.bind.annotation.PutMapping import org.springframework.web.bind.annotation.RequestBody -import org.springframework.web.bind.annotation.RequestMapping import org.springframework.web.bind.annotation.ResponseStatus import org.springframework.web.bind.annotation.RestController private val logger = KotlinLogging.logger {} @RestController -@RequestMapping("/hankkeet/{hankeTunnus}/kayttajat") @SecurityRequirement(name = "bearerAuth") class HankeKayttajaController( private val hankeService: HankeService, @@ -39,7 +38,7 @@ class HankeKayttajaController( private val featureFlags: FeatureFlags, ) { - @GetMapping(produces = [APPLICATION_JSON_VALUE]) + @GetMapping("/hankkeet/{hankeTunnus}/kayttajat", produces = [APPLICATION_JSON_VALUE]) @Operation( summary = "Get Hanke users", description = "Returns a list of users and their Hanke related information." @@ -84,7 +83,7 @@ class HankeKayttajaController( return HankeKayttajaResponse(users) } - @PutMapping + @PutMapping("/hankkeet/{hankeTunnus}/kayttajat") @Operation( summary = "Update permissions of the listed users.", description = @@ -164,6 +163,49 @@ same permissions. ) } + @PostMapping("/kayttajat") + @ResponseStatus(HttpStatus.NO_CONTENT) + @Operation( + summary = "Identify a user", + description = + """ +Identifies a user who has been invited to a hanke. Finds a token in database +that's the same as the token given in the request. Activates the permission to +the hanke the token was created for. + +Removes the token after a successful identification. +""" + ) + @ApiResponses( + value = + [ + ApiResponse( + description = "User identified, permission to hanke given", + responseCode = "204", + ), + ApiResponse( + description = "Token not found or outdated", + responseCode = "404", + content = [Content(schema = Schema(implementation = HankeError::class))] + ), + ApiResponse( + description = "Token doesn't have a user associated with it", + responseCode = "500", + content = [Content(schema = Schema(implementation = HankeError::class))] + ), + ApiResponse( + description = "Permission already exists", + responseCode = "409", + content = [Content(schema = Schema(implementation = HankeError::class))] + ), + ] + ) + fun identifyUser(@RequestBody tunnistautuminen: Tunnistautuminen) { + hankeKayttajaService.createPermissionFromToken(currentUserId(), tunnistautuminen.tunniste) + } + + data class Tunnistautuminen(val tunniste: String) + @ExceptionHandler(MissingAdminPermissionException::class) @ResponseStatus(HttpStatus.FORBIDDEN) @Hidden @@ -203,4 +245,36 @@ same permissions. logger.warn(ex) { ex.message } return HankeError.HAI4001 } + + @ExceptionHandler + @ResponseStatus(HttpStatus.NOT_FOUND) + @Hidden + fun tunnisteNotFoundException(ex: TunnisteNotFoundException): HankeError { + logger.warn(ex) { ex.message } + return HankeError.HAI4004 + } + + @ExceptionHandler + @ResponseStatus(HttpStatus.INTERNAL_SERVER_ERROR) + @Hidden + fun orphanedTunnisteException(ex: OrphanedTunnisteException): HankeError { + logger.error(ex) { ex.message } + return HankeError.HAI4001 + } + + @ExceptionHandler + @ResponseStatus(HttpStatus.CONFLICT) + @Hidden + fun userAlreadyHasPermissionException(ex: UserAlreadyHasPermissionException): HankeError { + logger.warn(ex) { ex.message } + return HankeError.HAI4003 + } + + @ExceptionHandler + @ResponseStatus(HttpStatus.CONFLICT) + @Hidden + fun permissionAlreadyExistsException(ex: PermissionAlreadyExistsException): HankeError { + logger.warn(ex) { ex.message } + return HankeError.HAI4003 + } } diff --git a/services/hanke-service/src/main/kotlin/fi/hel/haitaton/hanke/permissions/HankeKayttajaService.kt b/services/hanke-service/src/main/kotlin/fi/hel/haitaton/hanke/permissions/HankeKayttajaService.kt index c2f931cc6..19bd51af8 100644 --- a/services/hanke-service/src/main/kotlin/fi/hel/haitaton/hanke/permissions/HankeKayttajaService.kt +++ b/services/hanke-service/src/main/kotlin/fi/hel/haitaton/hanke/permissions/HankeKayttajaService.kt @@ -100,7 +100,7 @@ class HankeKayttajaService( kayttajat.forEach { kayttaja -> if (kayttaja.permission != null) { - kayttaja.permission.role = roleRepository.findOneByRole(updates[kayttaja.id]!!) + kayttaja.permission!!.role = roleRepository.findOneByRole(updates[kayttaja.id]!!) } else { kayttaja.kayttajaTunniste!!.role = updates[kayttaja.id]!! } @@ -109,6 +109,37 @@ class HankeKayttajaService( validateAdminRemains(hanke) } + @Transactional + fun createPermissionFromToken(userId: String, tunniste: String) { + logger.info { "Trying to activate token $tunniste for user $userId" } + val tunnisteEntity = + kayttajaTunnisteRepository.findByTunniste(tunniste) + ?: throw TunnisteNotFoundException(userId, tunniste) + + val kayttaja = + tunnisteEntity.hankeKayttaja + ?: throw OrphanedTunnisteException(userId, tunnisteEntity.id) + + permissionService.findPermission(kayttaja.hankeId, userId)?.let { permission -> + throw UserAlreadyHasPermissionException(userId, tunnisteEntity.id, permission.id) + } + + kayttaja.permission?.let { permission -> + throw PermissionAlreadyExistsException( + userId, + permission.userId, + kayttaja.id, + permission.id + ) + } + + kayttaja.permission = + permissionService.create(kayttaja.hankeId, userId, tunnisteEntity.role) + + kayttaja.kayttajaTunniste = null + kayttajaTunnisteRepository.delete(tunnisteEntity) + } + /** Check that every user an update was requested for was found as a user of the hanke. */ private fun validateAllKayttajatFound( existingKayttajat: List, @@ -241,3 +272,28 @@ class HankeKayttajatNotFoundException(missingIds: Collection, hanke: Hanke "Some HankeKayttaja were not found. Either the IDs don't exist or they belong to another " + "hanke. Missing IDs: ${missingIds.joinToString()}, hankeId=${hanke.id}, hanketunnus=${hanke.hankeTunnus}" ) + +class TunnisteNotFoundException(userId: String, tunniste: String) : + RuntimeException("A matching token was not found, userId=$userId, tunniste=$tunniste") + +class OrphanedTunnisteException(userId: String, tunnisteId: UUID) : + RuntimeException( + "A token didn't have a matching user, userId=$userId, kayttajaTunnisteId=$tunnisteId" + ) + +class UserAlreadyHasPermissionException(userId: String, tunnisteId: UUID, permissionId: Int) : + RuntimeException( + "A user already had an active permission, userId=$userId, kayttajaTunnisteId=$tunnisteId, permissionsId=$permissionId" + ) + +class PermissionAlreadyExistsException( + currentUserId: String, + permissionUserId: String, + hankeKayttajaId: UUID, + permissionId: Int, +) : + RuntimeException( + "Another user has an active permission with the same hanke kayttaja, " + + "the current user is $currentUserId, the user on the permission is $permissionUserId, " + + "hankeKayttajaId=$hankeKayttajaId, permissionId=$permissionId" + ) diff --git a/services/hanke-service/src/main/kotlin/fi/hel/haitaton/hanke/permissions/KayttajaTunniste.kt b/services/hanke-service/src/main/kotlin/fi/hel/haitaton/hanke/permissions/KayttajaTunniste.kt index 893314a55..f0717d8cf 100644 --- a/services/hanke-service/src/main/kotlin/fi/hel/haitaton/hanke/permissions/KayttajaTunniste.kt +++ b/services/hanke-service/src/main/kotlin/fi/hel/haitaton/hanke/permissions/KayttajaTunniste.kt @@ -49,4 +49,7 @@ class KayttajaTunnisteEntity( } } -@Repository interface KayttajaTunnisteRepository : JpaRepository {} +@Repository +interface KayttajaTunnisteRepository : JpaRepository { + fun findByTunniste(tunniste: String): KayttajaTunnisteEntity? +} diff --git a/services/hanke-service/src/main/kotlin/fi/hel/haitaton/hanke/permissions/PermissionService.kt b/services/hanke-service/src/main/kotlin/fi/hel/haitaton/hanke/permissions/PermissionService.kt index 2aac612a6..02bcd16c5 100644 --- a/services/hanke-service/src/main/kotlin/fi/hel/haitaton/hanke/permissions/PermissionService.kt +++ b/services/hanke-service/src/main/kotlin/fi/hel/haitaton/hanke/permissions/PermissionService.kt @@ -21,6 +21,17 @@ class PermissionService( return hasPermission(role, permission) } + fun findPermission(hankeId: Int, userId: String): PermissionEntity? = + permissionRepository.findOneByHankeIdAndUserId(hankeId, userId) + + /** When you don't want to accidentally update existing permissions. */ + fun create(hankeId: Int, userId: String, role: Role): PermissionEntity { + val roleEntity = roleRepository.findOneByRole(role) + return permissionRepository.save( + PermissionEntity(userId = userId, hankeId = hankeId, role = roleEntity) + ) + } + fun setPermission(hankeId: Int, userId: String, role: Role): PermissionEntity { val roleEntity = roleRepository.findOneByRole(role) val entity = diff --git a/services/hanke-service/src/main/resources/application.properties b/services/hanke-service/src/main/resources/application.properties index 211b74326..b12aee85f 100644 --- a/services/hanke-service/src/main/resources/application.properties +++ b/services/hanke-service/src/main/resources/application.properties @@ -89,6 +89,8 @@ sentry.logging.enabled=${HAITATON_SENTRY_LOGGING_ENABLED:false} # Default is access from behind reverse proxy. springdoc.swagger-ui.url=${HAITATON_SWAGGER_PATH_PREFIX:/api/v3}/api-docs springdoc.swagger-ui.config-url=${HAITATON_SWAGGER_PATH_PREFIX:/api/v3}/api-docs/swagger-config +springdoc.default-produces-media-type=application/json +springdoc.default-consumes-media-type=application/json spring.mail.host=${MAIL_SENDER_HOST:localhost} spring.mail.port=${MAIL_SENDER_PORT:2525}