diff --git a/lapis2/src/main/kotlin/org/genspectrum/lapis/LapisSpringConfig.kt b/lapis2/src/main/kotlin/org/genspectrum/lapis/LapisSpringConfig.kt index b614b04ed..34469327f 100644 --- a/lapis2/src/main/kotlin/org/genspectrum/lapis/LapisSpringConfig.kt +++ b/lapis2/src/main/kotlin/org/genspectrum/lapis/LapisSpringConfig.kt @@ -5,6 +5,7 @@ import com.fasterxml.jackson.dataformat.yaml.YAMLFactory import com.fasterxml.jackson.module.kotlin.readValue import com.fasterxml.jackson.module.kotlin.registerKotlinModule import mu.KotlinLogging +import org.genspectrum.lapis.auth.DataOpennessAuthorizationFilter import org.genspectrum.lapis.config.DatabaseConfig import org.genspectrum.lapis.config.SequenceFilterFields import org.genspectrum.lapis.logging.RequestContext @@ -52,4 +53,8 @@ class LapisSpringConfig { KotlinLogging.logger("StatisticsLogger"), timeFactory, ) + + @Bean + fun dataOpennessAuthorizationFilter(databaseConfig: DatabaseConfig, objectMapper: ObjectMapper) = + DataOpennessAuthorizationFilter.createFromConfig(databaseConfig, objectMapper) } diff --git a/lapis2/src/main/kotlin/org/genspectrum/lapis/auth/DataOpennessAuthorizationFilter.kt b/lapis2/src/main/kotlin/org/genspectrum/lapis/auth/DataOpennessAuthorizationFilter.kt new file mode 100644 index 000000000..8612bffc1 --- /dev/null +++ b/lapis2/src/main/kotlin/org/genspectrum/lapis/auth/DataOpennessAuthorizationFilter.kt @@ -0,0 +1,75 @@ +package org.genspectrum.lapis.auth + +import com.fasterxml.jackson.databind.ObjectMapper +import jakarta.servlet.FilterChain +import jakarta.servlet.http.HttpServletRequest +import jakarta.servlet.http.HttpServletResponse +import org.genspectrum.lapis.config.DatabaseConfig +import org.genspectrum.lapis.config.OpennessLevel +import org.genspectrum.lapis.controller.LapisHttpErrorResponse +import org.springframework.http.HttpStatus +import org.springframework.http.MediaType +import org.springframework.web.filter.OncePerRequestFilter + +abstract class DataOpennessAuthorizationFilter(val objectMapper: ObjectMapper) : OncePerRequestFilter() { + override fun doFilterInternal( + request: HttpServletRequest, + response: HttpServletResponse, + filterChain: FilterChain, + ) { + when (val result = isAuthorizedForEndpoint(request)) { + AuthorizationResult.Success -> filterChain.doFilter(request, response) + is AuthorizationResult.Failure -> { + response.status = HttpStatus.FORBIDDEN.value() + response.contentType = MediaType.APPLICATION_JSON_VALUE + response.writer.write( + objectMapper.writeValueAsString( + LapisHttpErrorResponse( + "Forbidden", + result.message, + ), + ), + ) + } + } + } + + abstract fun isAuthorizedForEndpoint(request: HttpServletRequest): AuthorizationResult + + companion object { + fun createFromConfig(databaseConfig: DatabaseConfig, objectMapper: ObjectMapper) = + when (databaseConfig.schema.opennessLevel) { + OpennessLevel.OPEN -> NoOpAuthorizationFilter(objectMapper) + OpennessLevel.GISAID -> ProtectedGisaidDataAuthorizationFilter(objectMapper) + } + } +} + +sealed interface AuthorizationResult { + companion object { + fun success(): AuthorizationResult = Success + + fun failure(message: String): AuthorizationResult = Failure(message) + } + + fun isSuccessful(): Boolean + + object Success : AuthorizationResult { + override fun isSuccessful() = true + } + + class Failure(val message: String) : AuthorizationResult { + override fun isSuccessful() = false + } +} + +private class NoOpAuthorizationFilter(objectMapper: ObjectMapper) : DataOpennessAuthorizationFilter(objectMapper) { + override fun isAuthorizedForEndpoint(request: HttpServletRequest) = AuthorizationResult.success() +} + +private class ProtectedGisaidDataAuthorizationFilter(objectMapper: ObjectMapper) : + DataOpennessAuthorizationFilter(objectMapper) { + + override fun isAuthorizedForEndpoint(request: HttpServletRequest) = + AuthorizationResult.failure("An access key is required to access this endpoint.") +} diff --git a/lapis2/src/main/kotlin/org/genspectrum/lapis/config/DatabaseConfig.kt b/lapis2/src/main/kotlin/org/genspectrum/lapis/config/DatabaseConfig.kt index 807a2cf65..829abd5b5 100644 --- a/lapis2/src/main/kotlin/org/genspectrum/lapis/config/DatabaseConfig.kt +++ b/lapis2/src/main/kotlin/org/genspectrum/lapis/config/DatabaseConfig.kt @@ -4,6 +4,7 @@ data class DatabaseConfig(val schema: DatabaseSchema) data class DatabaseSchema( val instanceName: String, + val opennessLevel: OpennessLevel, val metadata: List, val primaryKey: String, val features: List = emptyList(), @@ -12,3 +13,8 @@ data class DatabaseSchema( data class DatabaseMetadata(val name: String, val type: String) data class DatabaseFeature(val name: String) + +enum class OpennessLevel { + OPEN, + GISAID, +} diff --git a/lapis2/src/test/kotlin/org/genspectrum/lapis/auth/GisaidAuthorizationTest.kt b/lapis2/src/test/kotlin/org/genspectrum/lapis/auth/GisaidAuthorizationTest.kt new file mode 100644 index 000000000..68e0aefe0 --- /dev/null +++ b/lapis2/src/test/kotlin/org/genspectrum/lapis/auth/GisaidAuthorizationTest.kt @@ -0,0 +1,52 @@ +package org.genspectrum.lapis.auth + +import com.ninjasquad.springmockk.MockkBean +import io.mockk.MockKAnnotations +import io.mockk.MockKMatcherScope +import io.mockk.every +import org.genspectrum.lapis.controller.LapisController +import org.genspectrum.lapis.response.AggregatedResponse +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test +import org.springframework.beans.factory.annotation.Autowired +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc +import org.springframework.boot.test.context.SpringBootTest +import org.springframework.http.MediaType +import org.springframework.test.web.servlet.MockMvc +import org.springframework.test.web.servlet.request.MockMvcRequestBuilders +import org.springframework.test.web.servlet.result.MockMvcResultMatchers + +@SpringBootTest(properties = ["lapis.databaseConfig.path=src/test/resources/config/gisaidDatabaseConfig.yaml"]) +@AutoConfigureMockMvc +class GisaidAuthorizationTest(@Autowired val mockMvc: MockMvc) { + + @MockkBean + lateinit var lapisController: LapisController + + private fun MockKMatcherScope.validControllerCall() = lapisController.aggregated(any()) + private val validRoute = "/aggregated" + + @BeforeEach + fun setUp() { + every { validControllerCall() } returns AggregatedResponse(1) + + MockKAnnotations.init(this) + } + + @Test + fun `given no access key in request to GISAID instance, then access is denied`() { + mockMvc.perform(MockMvcRequestBuilders.get(validRoute)) + .andExpect(MockMvcResultMatchers.status().isForbidden) + .andExpect(MockMvcResultMatchers.content().contentType(MediaType.APPLICATION_JSON)) + .andExpect( + MockMvcResultMatchers.content().json( + """ + { + "title": "Forbidden", + "message": "An access key is required to access this endpoint." + } + """, + ), + ) + } +} diff --git a/lapis2/src/test/kotlin/org/genspectrum/lapis/config/DatabaseConfigTest.kt b/lapis2/src/test/kotlin/org/genspectrum/lapis/config/DatabaseConfigTest.kt index 5b8d6b8df..22656bd77 100644 --- a/lapis2/src/test/kotlin/org/genspectrum/lapis/config/DatabaseConfigTest.kt +++ b/lapis2/src/test/kotlin/org/genspectrum/lapis/config/DatabaseConfigTest.kt @@ -17,6 +17,7 @@ class DatabaseConfigTest { fun `load test database config`() { assertThat(underTest.schema.instanceName, `is`("sars_cov-2_minimal_test_config")) assertThat(underTest.schema.primaryKey, `is`("gisaid_epi_isl")) + assertThat(underTest.schema.opennessLevel, `is`(OpennessLevel.OPEN)) assertThat( underTest.schema.metadata, containsInAnyOrder( diff --git a/lapis2/src/test/kotlin/org/genspectrum/lapis/config/SequenceFilterFieldsTest.kt b/lapis2/src/test/kotlin/org/genspectrum/lapis/config/SequenceFilterFieldsTest.kt index 9efa079a0..e03d79177 100644 --- a/lapis2/src/test/kotlin/org/genspectrum/lapis/config/SequenceFilterFieldsTest.kt +++ b/lapis2/src/test/kotlin/org/genspectrum/lapis/config/SequenceFilterFieldsTest.kt @@ -75,6 +75,7 @@ class SequenceFilterFieldsTest { ) = DatabaseConfig( DatabaseSchema( "test config", + OpennessLevel.OPEN, databaseMetadata, "test primary key", databaseFeatures, diff --git a/lapis2/src/test/resources/config/gisaidDatabaseConfig.yaml b/lapis2/src/test/resources/config/gisaidDatabaseConfig.yaml new file mode 100644 index 000000000..5c62a2bc8 --- /dev/null +++ b/lapis2/src/test/resources/config/gisaidDatabaseConfig.yaml @@ -0,0 +1,7 @@ +schema: + instanceName: gisaidTestConfig + opennessLevel: GISAID + metadata: + - name: pangoLineage + type: pango_lineage + primaryKey: pangoLineage diff --git a/lapis2/src/test/resources/config/testDatabaseConfig.yaml b/lapis2/src/test/resources/config/testDatabaseConfig.yaml index d6eee4224..2003b9a3d 100644 --- a/lapis2/src/test/resources/config/testDatabaseConfig.yaml +++ b/lapis2/src/test/resources/config/testDatabaseConfig.yaml @@ -1,5 +1,6 @@ schema: instanceName: sars_cov-2_minimal_test_config + opennessLevel: OPEN metadata: - name: gisaid_epi_isl type: string diff --git a/siloLapisTests/testDatabaseConfig.yaml b/siloLapisTests/testDatabaseConfig.yaml index d6eee4224..2003b9a3d 100644 --- a/siloLapisTests/testDatabaseConfig.yaml +++ b/siloLapisTests/testDatabaseConfig.yaml @@ -1,5 +1,6 @@ schema: instanceName: sars_cov-2_minimal_test_config + opennessLevel: OPEN metadata: - name: gisaid_epi_isl type: string