diff --git a/.environment/docker/docker-compose/Dockerfile.azurite b/.environment/docker/docker-compose/Dockerfile.azurite index 7e3f1c6b72c..34e8ca43509 100644 --- a/.environment/docker/docker-compose/Dockerfile.azurite +++ b/.environment/docker/docker-compose/Dockerfile.azurite @@ -1 +1 @@ -FROM mcr.microsoft.com/azure-storage/azurite:3.31.0 +FROM mcr.microsoft.com/azure-storage/azurite:3.32.0 diff --git a/.github/actions/build-vars/action.yml b/.github/actions/build-vars/action.yml index 04aa8917865..663d29f06dd 100644 --- a/.github/actions/build-vars/action.yml +++ b/.github/actions/build-vars/action.yml @@ -234,7 +234,7 @@ runs: echo "has_frontend_change=${{ steps.filter.outputs.frontend_react }}" >> $GITHUB_OUTPUT fi - - uses: azure/login@6c251865b4e6290e7b78be643ea2d005bc51f69a + - uses: azure/login@a65d910e8af852a8061c627c456678983e180302 if: inputs.sp-creds != 'false' with: creds: ${{ inputs.sp-creds }} diff --git a/.github/actions/vpn-azure/action.yml b/.github/actions/vpn-azure/action.yml index 20b78d728b5..803ff5fe6a4 100644 --- a/.github/actions/vpn-azure/action.yml +++ b/.github/actions/vpn-azure/action.yml @@ -63,7 +63,7 @@ runs: fi shell: bash - - uses: azure/login@6c251865b4e6290e7b78be643ea2d005bc51f69a + - uses: azure/login@a65d910e8af852a8061c627c456678983e180302 if: inputs.sp-creds with: creds: ${{ inputs.sp-creds }} diff --git a/.github/workflows/release_chatops_app.yml b/.github/workflows/release_chatops_app.yml index 72ca550d76d..3b590e47ecc 100644 --- a/.github/workflows/release_chatops_app.yml +++ b/.github/workflows/release_chatops_app.yml @@ -40,7 +40,7 @@ jobs: with: submodules: true - - uses: azure/login@6c251865b4e6290e7b78be643ea2d005bc51f69a + - uses: azure/login@a65d910e8af852a8061c627c456678983e180302 with: creds: ${{ secrets.SERVICE_PRINCIPAL_CREDS }} diff --git a/.github/workflows/restore_databases.yml b/.github/workflows/restore_databases.yml index 2f71e9fd54e..f0051fbeb7c 100644 --- a/.github/workflows/restore_databases.yml +++ b/.github/workflows/restore_databases.yml @@ -90,7 +90,7 @@ jobs: echo "SINK_BACKUP_STORAGE=pdh${{ env.SINK_ENV_NAME }}terraform" >> $GITHUB_ENV # Login to Azure - - uses: azure/login@6c251865b4e6290e7b78be643ea2d005bc51f69a + - uses: azure/login@a65d910e8af852a8061c627c456678983e180302 with: creds: ${{ secrets.SERVICE_PRINCIPAL_CREDS }} @@ -139,7 +139,7 @@ jobs: - name: Check out changes uses: actions/checkout@692973e3d937129bcbf40652eb9f2f61becf3332 - - uses: azure/login@6c251865b4e6290e7b78be643ea2d005bc51f69a + - uses: azure/login@a65d910e8af852a8061c627c456678983e180302 with: creds: ${{ secrets.SERVICE_PRINCIPAL_CREDS }} @@ -230,7 +230,7 @@ jobs: - name: Check out changes uses: actions/checkout@692973e3d937129bcbf40652eb9f2f61becf3332 - - uses: azure/login@6c251865b4e6290e7b78be643ea2d005bc51f69a + - uses: azure/login@a65d910e8af852a8061c627c456678983e180302 with: creds: ${{ secrets.SERVICE_PRINCIPAL_CREDS }} diff --git a/.github/workflows/start_test_servers.yml b/.github/workflows/start_test_servers.yml index 901a3f0a189..b1b52c9d2bb 100644 --- a/.github/workflows/start_test_servers.yml +++ b/.github/workflows/start_test_servers.yml @@ -17,7 +17,7 @@ jobs: uses: actions/checkout@692973e3d937129bcbf40652eb9f2f61becf3332 # Login to Azure - - uses: azure/login@6c251865b4e6290e7b78be643ea2d005bc51f69a + - uses: azure/login@a65d910e8af852a8061c627c456678983e180302 with: creds: ${{ secrets.SERVICE_PRINCIPAL_CREDS }} diff --git a/.github/workflows/stop_test_servers.yml b/.github/workflows/stop_test_servers.yml index 6e7a42e2eab..9fd0ebd0506 100644 --- a/.github/workflows/stop_test_servers.yml +++ b/.github/workflows/stop_test_servers.yml @@ -28,7 +28,7 @@ jobs: sp-creds: ${{ secrets.SERVICE_PRINCIPAL_CREDS }} tf-auth: true # Login to Azure - - uses: azure/login@6c251865b4e6290e7b78be643ea2d005bc51f69a + - uses: azure/login@a65d910e8af852a8061c627c456678983e180302 with: creds: ${{ secrets.SERVICE_PRINCIPAL_CREDS }} diff --git a/.github/workflows/validate_resources.yml b/.github/workflows/validate_resources.yml index d8cc76acd22..101557421ec 100644 --- a/.github/workflows/validate_resources.yml +++ b/.github/workflows/validate_resources.yml @@ -103,7 +103,7 @@ jobs: - name: Check Out Changes uses: actions/checkout@692973e3d937129bcbf40652eb9f2f61becf3332 - - uses: azure/login@6c251865b4e6290e7b78be643ea2d005bc51f69a + - uses: azure/login@a65d910e8af852a8061c627c456678983e180302 with: creds: ${{ secrets.SERVICE_PRINCIPAL_CREDS }} @@ -136,7 +136,7 @@ jobs: - name: Check Out Changes uses: actions/checkout@692973e3d937129bcbf40652eb9f2f61becf3332 - - uses: azure/login@6c251865b4e6290e7b78be643ea2d005bc51f69a + - uses: azure/login@a65d910e8af852a8061c627c456678983e180302 with: creds: ${{ secrets.SERVICE_PRINCIPAL_CREDS }} diff --git a/auth/.gitignore b/auth/.gitignore new file mode 100644 index 00000000000..5a979af6fff --- /dev/null +++ b/auth/.gitignore @@ -0,0 +1,40 @@ +HELP.md +.gradle +build/ +!gradle/wrapper/gradle-wrapper.jar +!**/src/main/**/build/ +!**/src/test/**/build/ + +### STS ### +.apt_generated +.classpath +.factorypath +.project +.settings +.springBeans +.sts4-cache +bin/ +!**/src/main/**/bin/ +!**/src/test/**/bin/ + +### IntelliJ IDEA ### +.idea +*.iws +*.iml +*.ipr +out/ +!**/src/main/**/out/ +!**/src/test/**/out/ + +### NetBeans ### +/nbproject/private/ +/nbbuild/ +/dist/ +/nbdist/ +/.nb-gradle/ + +### VS Code ### +.vscode/ + +### Kotlin ### +.kotlin diff --git a/auth/build.gradle.kts b/auth/build.gradle.kts new file mode 100644 index 00000000000..f04d2619d62 --- /dev/null +++ b/auth/build.gradle.kts @@ -0,0 +1,58 @@ +apply(from = rootProject.file("buildSrc/shared.gradle.kts")) + +plugins { + id("org.springframework.boot") version "3.3.2" + id("io.spring.dependency-management") version "1.1.6" + id("reportstream.project-conventions") + kotlin("plugin.spring") version "2.0.0" +} + +group = "gov.cdc.prime" +version = "0.0.1-SNAPSHOT" + +dependencies { + implementation(project(":shared")) + + implementation("org.jetbrains.kotlin:kotlin-reflect") + implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core:1.8.1") + implementation("org.jetbrains.kotlinx:kotlinx-coroutines-reactor:1.8.1") + + /** + * Spring WebFlux was chosen for this project to be able to better handle periods of high traffic + */ + implementation("org.springframework.boot:spring-boot-starter-webflux") + implementation("org.springframework.cloud:spring-cloud-gateway-webflux") + implementation("org.springframework.boot:spring-boot-starter-oauth2-resource-server") + + runtimeOnly("com.nimbusds:oauth2-oidc-sdk:11.18") + + testImplementation("org.springframework.boot:spring-boot-starter-test") + testImplementation("org.springframework.security:spring-security-test") + testImplementation("org.jetbrains.kotlin:kotlin-test-junit5") + testImplementation("org.mockito.kotlin:mockito-kotlin:5.4.0") + testImplementation("com.squareup.okhttp3:mockwebserver:4.12.0") + + testRuntimeOnly("org.junit.platform:junit-platform-launcher") + + compileOnly("org.springframework.boot:spring-boot-devtools") +} + +// There is a conflict in logging implementations. Excluded these in favor of using log4j-slf4j2-impl +configurations.all { + exclude(group = "org.apache.logging.log4j", module = "log4j-to-slf4j") + exclude(group = "ch.qos.logback") +} + +dependencyManagement { + imports { + mavenBom("com.azure.spring:spring-cloud-azure-dependencies:5.14.0") + mavenBom("org.springframework.cloud:spring-cloud-dependencies:2023.0.3") + } +} + +kotlin { + compilerOptions { + // https://docs.spring.io/spring-boot/docs/2.0.x/reference/html/boot-features-kotlin.html#boot-features-kotlin-null-safety + freeCompilerArgs.addAll("-Xjsr305=strict") + } +} \ No newline at end of file diff --git a/auth/gradle/wrapper/gradle-wrapper.jar b/auth/gradle/wrapper/gradle-wrapper.jar new file mode 100644 index 00000000000..e6441136f3d Binary files /dev/null and b/auth/gradle/wrapper/gradle-wrapper.jar differ diff --git a/auth/gradle/wrapper/gradle-wrapper.properties b/auth/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 00000000000..a4413138c96 --- /dev/null +++ b/auth/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,7 @@ +distributionBase=GRADLE_USER_HOME +distributionPath=wrapper/dists +distributionUrl=https\://services.gradle.org/distributions/gradle-8.8-bin.zip +networkTimeout=10000 +validateDistributionUrl=true +zipStoreBase=GRADLE_USER_HOME +zipStorePath=wrapper/dists diff --git a/auth/src/main/kotlin/gov/cdc/prime/reportstream/auth/AuthApplication.kt b/auth/src/main/kotlin/gov/cdc/prime/reportstream/auth/AuthApplication.kt new file mode 100644 index 00000000000..249ef82f081 --- /dev/null +++ b/auth/src/main/kotlin/gov/cdc/prime/reportstream/auth/AuthApplication.kt @@ -0,0 +1,11 @@ +package gov.cdc.prime.reportstream.auth + +import org.springframework.boot.autoconfigure.SpringBootApplication +import org.springframework.boot.runApplication + +@SpringBootApplication +class AuthApplication + +fun main(args: Array) { + runApplication(*args) +} \ No newline at end of file diff --git a/auth/src/main/kotlin/gov/cdc/prime/reportstream/auth/AuthApplicationConstants.kt b/auth/src/main/kotlin/gov/cdc/prime/reportstream/auth/AuthApplicationConstants.kt new file mode 100644 index 00000000000..2c2909dd275 --- /dev/null +++ b/auth/src/main/kotlin/gov/cdc/prime/reportstream/auth/AuthApplicationConstants.kt @@ -0,0 +1,14 @@ +package gov.cdc.prime.reportstream.auth + +/** + * File used for application-wide constants + */ +object AuthApplicationConstants { + + /** + * All endpoints defined here + */ + object Endpoints { + const val HEALTHCHECK_ENDPOINT_V1 = "/api/v1/healthcheck" + } +} \ No newline at end of file diff --git a/auth/src/main/kotlin/gov/cdc/prime/reportstream/auth/config/ApplicationConfig.kt b/auth/src/main/kotlin/gov/cdc/prime/reportstream/auth/config/ApplicationConfig.kt new file mode 100644 index 00000000000..c0aeb78fdbe --- /dev/null +++ b/auth/src/main/kotlin/gov/cdc/prime/reportstream/auth/config/ApplicationConfig.kt @@ -0,0 +1,32 @@ +package gov.cdc.prime.reportstream.auth.config + +import org.springframework.boot.context.properties.ConfigurationProperties +import org.springframework.boot.context.properties.EnableConfigurationProperties +import org.springframework.context.annotation.Bean +import org.springframework.context.annotation.Configuration +import kotlin.time.TimeSource + +/** + * Simple class to automatically read configuration from application.yml (or environment variable overrides) + */ +@Configuration +@EnableConfigurationProperties(ProxyConfigurationProperties::class) +class ApplicationConfig( + val proxyConfig: ProxyConfigurationProperties, +) { + + @Bean + fun timeSource(): TimeSource { + return TimeSource.Monotonic + } +} + +@ConfigurationProperties("proxy") +data class ProxyConfigurationProperties( + val pathMappings: List, +) + +data class ProxyPathMapping( + val baseUrl: String, + val pathPrefix: String, +) \ No newline at end of file diff --git a/auth/src/main/kotlin/gov/cdc/prime/reportstream/auth/config/SecurityConfig.kt b/auth/src/main/kotlin/gov/cdc/prime/reportstream/auth/config/SecurityConfig.kt new file mode 100644 index 00000000000..004493a1646 --- /dev/null +++ b/auth/src/main/kotlin/gov/cdc/prime/reportstream/auth/config/SecurityConfig.kt @@ -0,0 +1,35 @@ +package gov.cdc.prime.reportstream.auth.config + +import gov.cdc.prime.reportstream.auth.AuthApplicationConstants +import org.springframework.context.annotation.Bean +import org.springframework.context.annotation.Configuration +import org.springframework.security.config.annotation.web.reactive.EnableWebFluxSecurity +import org.springframework.security.config.web.server.ServerHttpSecurity +import org.springframework.security.web.server.SecurityWebFilterChain + +/** + * Security configuration setup + * + * All incoming requests will require authentication via opaque token check + */ +@Configuration +@EnableWebFluxSecurity +class SecurityConfig { + + @Bean + fun securityWebFilterChain(http: ServerHttpSecurity): SecurityWebFilterChain { + http + .authorizeExchange { authorize -> + authorize + // allow health endpoint without authentication + .pathMatchers(AuthApplicationConstants.Endpoints.HEALTHCHECK_ENDPOINT_V1).permitAll() + // all other requests must be authenticated + .anyExchange().authenticated() + } + .oauth2ResourceServer { + it.opaqueToken { } + } + + return http.build() + } +} \ No newline at end of file diff --git a/auth/src/main/kotlin/gov/cdc/prime/reportstream/auth/controller/AuthController.kt b/auth/src/main/kotlin/gov/cdc/prime/reportstream/auth/controller/AuthController.kt new file mode 100644 index 00000000000..e62df018405 --- /dev/null +++ b/auth/src/main/kotlin/gov/cdc/prime/reportstream/auth/controller/AuthController.kt @@ -0,0 +1,47 @@ +package gov.cdc.prime.reportstream.auth.controller + +import gov.cdc.prime.reportstream.auth.service.ProxyURIStrategy +import kotlinx.coroutines.reactive.awaitSingle +import org.apache.logging.log4j.kotlin.Logging +import org.springframework.cloud.gateway.webflux.ProxyExchange +import org.springframework.http.ResponseEntity +import org.springframework.security.oauth2.server.resource.authentication.BearerTokenAuthentication +import org.springframework.web.bind.annotation.RequestMapping +import org.springframework.web.bind.annotation.RestController +import org.springframework.web.server.ServerWebExchange + +@RestController +class AuthController( + private val proxyURIStrategy: ProxyURIStrategy, +) : Logging { + + /** + * Main workhorse of the application. Handles all incoming requests and properly forwards them given successful + * authentication. Missing or invalid bearer tokens will result in a 401 unauthorized response. + * + * Authentication will be handled by the OAuth 2.0 resource server opaque token configuration + * @see https://docs.spring.io/spring-security/reference/servlet/oauth2/resource-server/opaque-token.html + * + * Proxying will be handled by the Spring Cloud Gateway library from which the ProxyExchange object is injected + */ + @RequestMapping("**") + suspend fun proxy( + exchange: ServerWebExchange, + proxy: ProxyExchange, + auth: BearerTokenAuthentication, + ): ResponseEntity { + val sub = auth.tokenAttributes["sub"] + val scopes = auth.tokenAttributes["scope"] + + logger.info("Token with sub=$sub and scopes=$scopes is authenticated with Okta") + + val uri = proxyURIStrategy.getTargetURI(exchange.request.uri) + proxy.uri(uri.toString()) + + logger.info("Proxying request to ${exchange.request.method} $uri") + val response = proxy.forward().awaitSingle() + logger.info("Proxy response from ${exchange.request.method} $uri status=${response.statusCode}") + + return response + } +} \ No newline at end of file diff --git a/auth/src/main/kotlin/gov/cdc/prime/reportstream/auth/controller/HealthController.kt b/auth/src/main/kotlin/gov/cdc/prime/reportstream/auth/controller/HealthController.kt new file mode 100644 index 00000000000..f90ee051982 --- /dev/null +++ b/auth/src/main/kotlin/gov/cdc/prime/reportstream/auth/controller/HealthController.kt @@ -0,0 +1,25 @@ +package gov.cdc.prime.reportstream.auth.controller + +import gov.cdc.prime.reportstream.auth.AuthApplicationConstants +import gov.cdc.prime.reportstream.auth.model.ApplicationStatus +import org.springframework.http.MediaType +import org.springframework.web.bind.annotation.GetMapping +import org.springframework.web.bind.annotation.RestController +import kotlin.time.TimeSource + +@RestController +class HealthController( + timeSource: TimeSource, +) { + + private val applicationStart = timeSource.markNow() + + @GetMapping( + AuthApplicationConstants.Endpoints.HEALTHCHECK_ENDPOINT_V1, + produces = [MediaType.APPLICATION_JSON_VALUE] + ) + suspend fun health(): ApplicationStatus { + val uptime = applicationStart.elapsedNow().toString() + return ApplicationStatus("auth", "ok", uptime) + } +} \ No newline at end of file diff --git a/auth/src/main/kotlin/gov/cdc/prime/reportstream/auth/model/ApplicationStatus.kt b/auth/src/main/kotlin/gov/cdc/prime/reportstream/auth/model/ApplicationStatus.kt new file mode 100644 index 00000000000..da9a90b2fa0 --- /dev/null +++ b/auth/src/main/kotlin/gov/cdc/prime/reportstream/auth/model/ApplicationStatus.kt @@ -0,0 +1,10 @@ +package gov.cdc.prime.reportstream.auth.model + +/** + * Simple json response model for application status + */ +data class ApplicationStatus( + val application: String, + val status: String, + val uptime: String, +) \ No newline at end of file diff --git a/auth/src/main/kotlin/gov/cdc/prime/reportstream/auth/service/ProxyURIStrategy.kt b/auth/src/main/kotlin/gov/cdc/prime/reportstream/auth/service/ProxyURIStrategy.kt new file mode 100644 index 00000000000..38686400a11 --- /dev/null +++ b/auth/src/main/kotlin/gov/cdc/prime/reportstream/auth/service/ProxyURIStrategy.kt @@ -0,0 +1,55 @@ +package gov.cdc.prime.reportstream.auth.service + +import gov.cdc.prime.reportstream.auth.config.ApplicationConfig +import org.springframework.context.annotation.Profile +import org.springframework.stereotype.Component +import java.net.URI + +/** + * Implementations are ways to decide the ultimate destination of an incoming request + */ +interface ProxyURIStrategy { + fun getTargetURI(incomingUri: URI): URI +} + +/** + * This implementation decides via the path prefix. Currently used locally for when all services are + * running on different ports of localhost. + * + * Configured under proxyConfig.pathMappings + * + * http://localhost:9000/submissions/health -> http://localhost:8880/health + */ +@Component +@Profile("local") +class PathPrefixProxyURIStrategy( + private val applicationConfig: ApplicationConfig, +) : ProxyURIStrategy { + override fun getTargetURI(incomingUri: URI): URI { + val proxyPathMappings = applicationConfig.proxyConfig.pathMappings + val maybePathMapping = proxyPathMappings.find { incomingUri.path.startsWith(it.pathPrefix) } + return if (maybePathMapping != null) { + val baseUri = URI(maybePathMapping.baseUrl) + val path = incomingUri.path.removePrefix(maybePathMapping.pathPrefix) + URI( + baseUri.scheme, + baseUri.userInfo, + baseUri.host, + baseUri.port, + path, + incomingUri.query, + incomingUri.fragment + ) + } else { + throw IllegalStateException("no configured proxy target in path mappings for path=${incomingUri.path}") + } + } +} + +@Component +@Profile("deployed") +class HostProxyPathURIStrategy : ProxyURIStrategy { + override fun getTargetURI(incomingUri: URI): URI { + TODO("Not yet implemented") + } +} \ No newline at end of file diff --git a/auth/src/main/resources/application.yml b/auth/src/main/resources/application.yml new file mode 100644 index 00000000000..6a085c848dd --- /dev/null +++ b/auth/src/main/resources/application.yml @@ -0,0 +1,29 @@ +spring: + application: + name: "auth" + profiles: + active: local + security: + oauth2: + resourceserver: + opaquetoken: # Set client secret in SPRING_SECURITY_OAUTH2_RESOURCESERVER_OPAQUETOKEN_CLIENT_SECRET env variable + client-id: 0oaek8tip2lhrhHce1d7 + introspection-uri: https://reportstream.oktapreview.com/oauth2/ausekaai7gUuUtHda1d7/v1/introspect + cloud: + gateway: + proxy: + sensitive: [] # pass authorization and cookie headers downstream (filtered by default) + +server.port: 9000 + +proxy.pathMappings: + - pathPrefix: /reportstream + baseUrl: http://localhost:7071 + - pathPrefix: /submissions + baseUrl: http://localhost:8880 + +#Uncomment for verbose logging +#logging: +# level: +# web: debug +# org.springframework.web: debug diff --git a/auth/src/test/kotlin/gov/cdc/prime/reportstream/auth/controller/AuthControllerTest.kt b/auth/src/test/kotlin/gov/cdc/prime/reportstream/auth/controller/AuthControllerTest.kt new file mode 100644 index 00000000000..8242e7a3780 --- /dev/null +++ b/auth/src/test/kotlin/gov/cdc/prime/reportstream/auth/controller/AuthControllerTest.kt @@ -0,0 +1,146 @@ +package gov.cdc.prime.reportstream.auth.controller + +import gov.cdc.prime.reportstream.auth.service.ProxyURIStrategy +import okhttp3.mockwebserver.MockResponse +import okhttp3.mockwebserver.MockWebServer +import org.junit.jupiter.api.AfterEach +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.extension.ExtendWith +import org.mockito.kotlin.any +import org.mockito.kotlin.given +import org.springframework.beans.factory.annotation.Autowired +import org.springframework.boot.test.autoconfigure.web.reactive.AutoConfigureWebTestClient +import org.springframework.boot.test.context.SpringBootTest +import org.springframework.boot.test.mock.mockito.MockBean +import org.springframework.http.HttpHeaders +import org.springframework.http.MediaType +import org.springframework.security.test.web.reactive.server.SecurityMockServerConfigurers.csrf +import org.springframework.security.test.web.reactive.server.SecurityMockServerConfigurers.mockOpaqueToken +import org.springframework.test.context.junit.jupiter.SpringExtension +import org.springframework.test.web.reactive.server.WebTestClient +import java.net.URI +import java.nio.charset.Charset +import kotlin.test.Test +import kotlin.test.assertEquals + +@ExtendWith(SpringExtension::class) +@SpringBootTest +@AutoConfigureWebTestClient +class AuthControllerTest @Autowired constructor( + private val webTestClient: WebTestClient, + @MockBean private val mockedUriStrategy: ProxyURIStrategy, +) { + + private val server: MockWebServer = MockWebServer() + + @BeforeEach + fun setUp() { + server.start() + } + + @AfterEach + fun tearDown() { + server.shutdown() + } + + @Test + fun `successful proxy`() { + server.enqueue( + MockResponse() + .setHeader(HttpHeaders.CONTENT_TYPE, MediaType.TEXT_PLAIN) + .setBody("hello world!") + ) + + val incomingUri = URI("/service/path") + val outgoingUri = URI(server.url("/path").toString()) + given(mockedUriStrategy.getTargetURI(incomingUri)).willReturn(outgoingUri) + + webTestClient + .mutateWith(csrf()) + .mutateWith( + mockOpaqueToken() + .attributes { map -> + map["sub"] = "sub" + map["scope"] = listOf("scope1", "scope2") + } + ) + .post() + .uri("/service/path") + .accept(MediaType.TEXT_PLAIN) + .headers { headers -> + headers.add("x-test-header", "Pass this along") + } + .bodyValue("body") + .exchange() + // assertions on the response received from the mock server + .expectStatus().isOk + .expectHeader().contentType(MediaType.TEXT_PLAIN) + .expectBody(String::class.java).isEqualTo("hello world!") + + // assertions on recorded request to proxy + val recordedRequest = server.takeRequest() + assertEquals( + recordedRequest.headers.get("x-test-header"), + "Pass this along" + ) + assertEquals( + recordedRequest.body.readString(Charset.defaultCharset()), + "body" + ) + } + + @Test + fun `authorization fails in proxied server`() { + server.enqueue(MockResponse().setResponseCode(403)) + + given(mockedUriStrategy.getTargetURI(any())) + .willReturn(URI(server.url("/").toString())) + + webTestClient + .mutateWith(csrf()) + .mutateWith( + mockOpaqueToken() + .attributes { map -> + map["sub"] = "sub" + map["scope"] = listOf("scope1", "scope2") + } + ) + .post() + .uri("/random") + .accept(MediaType.TEXT_PLAIN) + .headers { headers -> + headers.add("x-test-header", "Pass this along") + } + .bodyValue("body") + .exchange() + // assertions on the response received from the mock server + .expectStatus().isForbidden + + // assertions on recorded request to proxy + val recordedRequest = server.takeRequest() + assertEquals( + recordedRequest.headers.get("x-test-header"), + "Pass this along" + ) + assertEquals( + recordedRequest.body.readString(Charset.defaultCharset()), + "body" + ) + } + + @Test + fun `authentication fails`() { + given(mockedUriStrategy.getTargetURI(any())) + .willReturn(URI(server.url("/").toString())) + + webTestClient + .mutateWith(csrf()) + .post() + .uri("/random") + .exchange() + .expectStatus().isUnauthorized + + // no request should be made to server + assertEquals(server.requestCount, 0) + } +} \ No newline at end of file diff --git a/auth/src/test/resources/application.yml b/auth/src/test/resources/application.yml new file mode 100644 index 00000000000..2925d96b9de --- /dev/null +++ b/auth/src/test/resources/application.yml @@ -0,0 +1,24 @@ +spring: + application: + name: "auth" + profiles: + active: test + security: + oauth2: + resourceserver: + opaquetoken: + client-id: mockClient + client-secret: mockSecret + introspection-uri: https://localhost:9999/oauth2/default/v1/introspect # should never be hit + cloud: + gateway: + proxy: + sensitive: [] # pass authorization and cookie headers downstream (filtered by default) + +server.port: 9000 + +proxy.pathMappings: + - pathPrefix: /reportstream + baseUrl: http://localhost:7071 + - pathPrefix: /submissions + baseUrl: http://localhost:8880 diff --git a/prime-router/build.gradle.kts b/prime-router/build.gradle.kts index 90a5971f314..b64a5b7208e 100644 --- a/prime-router/build.gradle.kts +++ b/prime-router/build.gradle.kts @@ -858,6 +858,9 @@ dependencies { // https://mvnrepository.com/artifact/ca.uhn.hapi.fhir/hapi-fhir-caching-caffeine implementation("ca.uhn.hapi.fhir:hapi-fhir-caching-caffeine:7.2.2") implementation("ca.uhn.hapi.fhir:hapi-fhir-client:7.2.2") + // pin + implementation("ca.uhn.hapi.fhir:org.hl7.fhir.utilities:6.3.24") + implementation("ca.uhn.hapi.fhir:org.hl7.fhir.r4:6.3.24") implementation("ca.uhn.hapi:hapi-base:2.5.1") implementation("ca.uhn.hapi:hapi-structures-v251:2.5.1") implementation("ca.uhn.hapi:hapi-structures-v27:2.5.1") diff --git a/prime-router/src/main/kotlin/cli/ProcessFhirCommands.kt b/prime-router/src/main/kotlin/cli/ProcessFhirCommands.kt index 32321a12871..0b2d70a7149 100644 --- a/prime-router/src/main/kotlin/cli/ProcessFhirCommands.kt +++ b/prime-router/src/main/kotlin/cli/ProcessFhirCommands.kt @@ -39,11 +39,11 @@ import gov.cdc.prime.router.fhirengine.translation.hl7.utils.FhirPathUtils import gov.cdc.prime.router.fhirengine.utils.FhirTranscoder import gov.cdc.prime.router.fhirengine.utils.HL7Reader import gov.cdc.prime.router.fhirengine.utils.getObservations +import org.hl7.fhir.r4.fhirpath.FHIRLexer.FHIRLexerException import org.hl7.fhir.r4.model.Base import org.hl7.fhir.r4.model.Bundle import org.hl7.fhir.r4.model.Extension import org.hl7.fhir.r4.model.Reference -import org.hl7.fhir.r4.utils.FHIRLexer.FHIRLexerException /** * Process data into/from FHIR. diff --git a/prime-router/src/main/kotlin/fhirengine/engine/CustomFhirPathFunctions.kt b/prime-router/src/main/kotlin/fhirengine/engine/CustomFhirPathFunctions.kt index 5fcfcaeb1f1..0083feea221 100644 --- a/prime-router/src/main/kotlin/fhirengine/engine/CustomFhirPathFunctions.kt +++ b/prime-router/src/main/kotlin/fhirengine/engine/CustomFhirPathFunctions.kt @@ -10,11 +10,11 @@ import gov.cdc.prime.router.common.NPIUtilities import gov.cdc.prime.router.fhirengine.translation.hl7.SchemaException import gov.cdc.prime.router.metadata.GeoData import gov.cdc.prime.router.metadata.LivdLookup +import org.hl7.fhir.r4.fhirpath.FHIRPathUtilityClasses.FunctionDetails import org.hl7.fhir.r4.model.Base import org.hl7.fhir.r4.model.Device import org.hl7.fhir.r4.model.Observation import org.hl7.fhir.r4.model.StringType -import org.hl7.fhir.r4.utils.FHIRPathUtilityClasses.FunctionDetails import java.time.LocalDate import java.util.Date import java.util.UUID diff --git a/prime-router/src/main/kotlin/fhirengine/translation/hl7/utils/ConstantResolver.kt b/prime-router/src/main/kotlin/fhirengine/translation/hl7/utils/ConstantResolver.kt index 5263b8c0ad4..d15800abd95 100644 --- a/prime-router/src/main/kotlin/fhirengine/translation/hl7/utils/ConstantResolver.kt +++ b/prime-router/src/main/kotlin/fhirengine/translation/hl7/utils/ConstantResolver.kt @@ -9,14 +9,14 @@ import org.apache.commons.text.StringSubstitutor import org.apache.commons.text.lookup.StringLookup import org.apache.logging.log4j.kotlin.Logging import org.hl7.fhir.exceptions.PathEngineException +import org.hl7.fhir.r4.fhirpath.FHIRPathEngine +import org.hl7.fhir.r4.fhirpath.FHIRPathUtilityClasses +import org.hl7.fhir.r4.fhirpath.TypeDetails import org.hl7.fhir.r4.model.Base import org.hl7.fhir.r4.model.Bundle import org.hl7.fhir.r4.model.IntegerType import org.hl7.fhir.r4.model.StringType -import org.hl7.fhir.r4.model.TypeDetails import org.hl7.fhir.r4.model.ValueSet -import org.hl7.fhir.r4.utils.FHIRPathEngine -import org.hl7.fhir.r4.utils.FHIRPathUtilityClasses.FunctionDetails import java.lang.IllegalArgumentException import java.lang.NumberFormatException @@ -145,7 +145,13 @@ class ConstantSubstitutor { */ class FhirPathCustomResolver(private val customFhirFunctions: FhirPathFunctions? = null) : FHIRPathEngine.IEvaluationContext, Logging { - override fun resolveConstant(appContext: Any?, name: String?, beforeContext: Boolean): List { + override fun resolveConstant( + engine: FHIRPathEngine?, + appContext: Any?, + name: String?, + beforeContext: Boolean, + explicitConstant: Boolean, + ): List { // Name is always passed in from the FHIR path engine require(!name.isNullOrBlank()) @@ -210,7 +216,12 @@ class FhirPathCustomResolver(private val customFhirFunctions: FhirPathFunctions? } } - override fun resolveConstantType(appContext: Any?, name: String?): TypeDetails { + override fun resolveConstantType( + engine: FHIRPathEngine?, + appContext: Any?, + name: String?, + explicitConstant: Boolean, + ): TypeDetails { throw NotImplementedError("Not implemented") } @@ -218,19 +229,25 @@ class FhirPathCustomResolver(private val customFhirFunctions: FhirPathFunctions? throw NotImplementedError("Not implemented") } - override fun resolveFunction(functionName: String?): FunctionDetails? { + override fun resolveFunction( + engine: FHIRPathEngine?, + functionName: String?, + ): FHIRPathUtilityClasses.FunctionDetails? { return CustomFHIRFunctions.resolveFunction(functionName, customFhirFunctions) } override fun checkFunction( + engine: FHIRPathEngine?, appContext: Any?, functionName: String?, + focus: TypeDetails?, parameters: MutableList?, ): TypeDetails { throw NotImplementedError("Not implemented") } override fun executeFunction( + engine: FHIRPathEngine?, appContext: Any?, focus: MutableList?, functionName: String?, @@ -246,7 +263,7 @@ class FhirPathCustomResolver(private val customFhirFunctions: FhirPathFunctions? } } - override fun resolveReference(appContext: Any?, url: String?, refContext: Base?): Base? { + override fun resolveReference(engine: FHIRPathEngine?, appContext: Any?, url: String?, refContext: Base?): Base? { // Name is always passed in from the FHIR path engine require(!url.isNullOrBlank()) @@ -256,11 +273,11 @@ class FhirPathCustomResolver(private val customFhirFunctions: FhirPathFunctions? } } - override fun conformsToProfile(appContext: Any?, item: Base?, url: String?): Boolean { + override fun conformsToProfile(engine: FHIRPathEngine?, appContext: Any?, item: Base?, url: String?): Boolean { throw NotImplementedError("Not implemented") } - override fun resolveValueSet(appContext: Any?, url: String?): ValueSet { + override fun resolveValueSet(engine: FHIRPathEngine?, appContext: Any?, url: String?): ValueSet { throw NotImplementedError("Not implemented") } } \ No newline at end of file diff --git a/prime-router/src/main/kotlin/fhirengine/translation/hl7/utils/CustomFHIRFunctions.kt b/prime-router/src/main/kotlin/fhirengine/translation/hl7/utils/CustomFHIRFunctions.kt index 8b6fb6f0734..e6986f75388 100644 --- a/prime-router/src/main/kotlin/fhirengine/translation/hl7/utils/CustomFHIRFunctions.kt +++ b/prime-router/src/main/kotlin/fhirengine/translation/hl7/utils/CustomFHIRFunctions.kt @@ -4,13 +4,13 @@ import ca.uhn.fhir.model.api.TemporalPrecisionEnum import fhirengine.translation.hl7.utils.FhirPathFunctions import fhirengine.translation.hl7.utils.helpers.convertDateToAge import gov.cdc.prime.router.fhirengine.translation.hl7.SchemaException +import org.hl7.fhir.r4.fhirpath.FHIRPathUtilityClasses.FunctionDetails import org.hl7.fhir.r4.model.Base import org.hl7.fhir.r4.model.BaseDateTimeType import org.hl7.fhir.r4.model.BooleanType import org.hl7.fhir.r4.model.DateTimeType import org.hl7.fhir.r4.model.IntegerType import org.hl7.fhir.r4.model.StringType -import org.hl7.fhir.r4.utils.FHIRPathUtilityClasses.FunctionDetails import java.time.DateTimeException import java.time.ZoneId import java.util.TimeZone diff --git a/prime-router/src/main/kotlin/fhirengine/translation/hl7/utils/FhirPathFunctions.kt b/prime-router/src/main/kotlin/fhirengine/translation/hl7/utils/FhirPathFunctions.kt index 19128b571c3..6a7d0da648c 100644 --- a/prime-router/src/main/kotlin/fhirengine/translation/hl7/utils/FhirPathFunctions.kt +++ b/prime-router/src/main/kotlin/fhirengine/translation/hl7/utils/FhirPathFunctions.kt @@ -1,7 +1,8 @@ package fhirengine.translation.hl7.utils +import org.hl7.fhir.r4.fhirpath.FHIRPathUtilityClasses.FunctionDetails import org.hl7.fhir.r4.model.Base -import org.hl7.fhir.r4.utils.FHIRPathUtilityClasses.FunctionDetails + /** * This interface contains the required method signatures required to implement custom FHIR functions */ diff --git a/prime-router/src/main/kotlin/fhirengine/translation/hl7/utils/FhirPathUtils.kt b/prime-router/src/main/kotlin/fhirengine/translation/hl7/utils/FhirPathUtils.kt index d658e2d73f2..e7328517f36 100644 --- a/prime-router/src/main/kotlin/fhirengine/translation/hl7/utils/FhirPathUtils.kt +++ b/prime-router/src/main/kotlin/fhirengine/translation/hl7/utils/FhirPathUtils.kt @@ -8,6 +8,9 @@ import gov.cdc.prime.router.fhirengine.translation.hl7.HL7ConversionException import gov.cdc.prime.router.fhirengine.translation.hl7.SchemaException import gov.cdc.prime.router.fhirengine.translation.hl7.schema.converter.ConverterSchemaElement import org.apache.logging.log4j.kotlin.Logging +import org.hl7.fhir.r4.fhirpath.ExpressionNode +import org.hl7.fhir.r4.fhirpath.FHIRLexer +import org.hl7.fhir.r4.fhirpath.FHIRPathEngine import org.hl7.fhir.r4.hapi.ctx.HapiWorkerContext import org.hl7.fhir.r4.model.Base import org.hl7.fhir.r4.model.BaseDateTimeType @@ -15,11 +18,8 @@ import org.hl7.fhir.r4.model.BooleanType import org.hl7.fhir.r4.model.Bundle import org.hl7.fhir.r4.model.DateTimeType import org.hl7.fhir.r4.model.DateType -import org.hl7.fhir.r4.model.ExpressionNode import org.hl7.fhir.r4.model.InstantType import org.hl7.fhir.r4.model.TimeType -import org.hl7.fhir.r4.utils.FHIRLexer.FHIRLexerException -import org.hl7.fhir.r4.utils.FHIRPathEngine import java.time.DateTimeException import java.time.LocalTime import java.time.format.DateTimeFormatter @@ -97,7 +97,7 @@ object FhirPathUtils : Logging { } else { pathEngine.evaluate(appContext, focusResource, bundle, bundle, expressionNode) } - } catch (e: FHIRLexerException) { + } catch (e: FHIRLexer.FHIRLexerException) { logger.error("${e.javaClass.name}: Syntax error in FHIR Path $expression.") emptyList() } catch (e: IndexOutOfBoundsException) { @@ -145,7 +145,7 @@ object FhirPathUtils : Logging { } } catch (e: Exception) { val msg = when (e) { - is FHIRLexerException -> "Syntax error in FHIR Path expression $expression" + is FHIRLexer.FHIRLexerException -> "Syntax error in FHIR Path expression $expression" is SchemaException -> e.message.toString() else -> "Unknown error while evaluating FHIR Path expression $expression for condition. " + diff --git a/prime-router/src/test/kotlin/fhirengine/translation/hl7/utils/ConstantResolverTests.kt b/prime-router/src/test/kotlin/fhirengine/translation/hl7/utils/ConstantResolverTests.kt index 7920025ac12..7f55193b5b0 100644 --- a/prime-router/src/test/kotlin/fhirengine/translation/hl7/utils/ConstantResolverTests.kt +++ b/prime-router/src/test/kotlin/fhirengine/translation/hl7/utils/ConstantResolverTests.kt @@ -86,24 +86,24 @@ class ConstantResolverTests { @Test fun `test fhir path resolver`() { mockkObject(FhirPathUtils) - assertFailure { FhirPathCustomResolver().resolveConstant(null, null, false) } - assertFailure { FhirPathCustomResolver().resolveConstant(null, "const1", false) } + assertFailure { FhirPathCustomResolver().resolveConstant(null, null, null, false, false) } + assertFailure { FhirPathCustomResolver().resolveConstant(null, null, "const1", false, false) } .hasClass(PathEngineException::class.java) val integerValue = 99 val urlPrefix = "https://reportstream.cdc.gov/fhir/StructureDefinition/" val constants = sortedMapOf("const1" to "'value1'", "int1" to "'$integerValue'", "rsext" to "'$urlPrefix'") val context = CustomContext.addConstants(constants, CustomContext(Bundle(), Bundle())) - assertThat(FhirPathCustomResolver().resolveConstant(context, "const2", false)).isEmpty() - assertThat(FhirPathCustomResolver().resolveConstant(context, "const1", false)).isNotNull() - var result = FhirPathCustomResolver().resolveConstant(context, "int1", false) + assertThat(FhirPathCustomResolver().resolveConstant(null, context, "const2", false, false)).isEmpty() + assertThat(FhirPathCustomResolver().resolveConstant(null, context, "const1", false, false)).isNotNull() + var result = FhirPathCustomResolver().resolveConstant(null, context, "int1", false, false) assertThat(result).isNotNull() assertThat(result).isNotEmpty() assertThat(result[0] is IntegerType).isTrue() assertThat((result[0] as IntegerType).value).isEqualTo(integerValue) // Now lets resolve a constant - result = FhirPathCustomResolver().resolveConstant(context, "const1", false) + result = FhirPathCustomResolver().resolveConstant(null, context, "const1", false, false) assertThat(result).isNotNull() assertThat(result.isNotEmpty()) assertThat(result[0].isPrimitive).isTrue() @@ -114,21 +114,21 @@ class ConstantResolverTests { // Test the ability to resolve constants with suffix val urlSuffix = "SomeSuffix" - result = FhirPathCustomResolver().resolveConstant(context, "`rsext-$urlSuffix`", false) + result = FhirPathCustomResolver().resolveConstant(null, context, "`rsext-$urlSuffix`", false, false) assertThat(result).isNotNull() assertThat(result.isNotEmpty()) assertThat(result[0].isPrimitive).isTrue() assertThat(result[0]).isInstanceOf(StringType::class.java) assertThat((result[0] as StringType).value).isEqualTo("$urlPrefix$urlSuffix") - result = FhirPathCustomResolver().resolveConstant(context, "`rsext`", false) + result = FhirPathCustomResolver().resolveConstant(null, context, "`rsext`", false, false) assertThat(result).isNotNull() assertThat(result.isNotEmpty()) assertThat(result[0].isPrimitive).isTrue() assertThat(result[0]).isInstanceOf(StringType::class.java) assertThat((result[0] as StringType).value).isEqualTo(urlPrefix) - result = FhirPathCustomResolver().resolveConstant(context, "unknownconst", false) + result = FhirPathCustomResolver().resolveConstant(null, context, "unknownconst", false, false) assertThat(result).isEmpty() } @@ -144,7 +144,7 @@ class ConstantResolverTests { val constants = sortedMapOf("const1" to "'value1'") // this does not matter but context wants something val context = CustomContext.addConstants(constants, CustomContext(Bundle(), Bundle())) - val result = FhirPathCustomResolver().resolveConstant(context, "const1", false) + val result = FhirPathCustomResolver().resolveConstant(null, context, "const1", false, false) assertThat(result).isNotNull() assertThat(result.isNotEmpty()) assertThat(result.size == 3) @@ -167,6 +167,7 @@ class ConstantResolverTests { val context = CustomContext(Bundle(), Bundle()) assertThat( FhirPathCustomResolver(CustomFhirPathFunctions()).executeFunction( + null, context, mutableListOf(Observation()), "livdTableLookup", @@ -180,6 +181,7 @@ class ConstantResolverTests { val context = CustomContext(Bundle(), Bundle()) assertFailure { FhirPathCustomResolver(CustomFhirPathFunctions()).executeFunction( + null, context, mutableListOf(Observation()), "unknown", @@ -198,15 +200,15 @@ class ConstantResolverTests { val bundle = Bundle() val customContext = CustomContext(bundle, bundle) - assertThat(FhirPathCustomResolver().resolveReference(customContext, org2Url, null)).isNull() + assertThat(FhirPathCustomResolver().resolveReference(null, customContext, org2Url, null)).isNull() bundle.addEntry().resource = org1 bundle.entry[0].fullUrl = "Organization/${org1.id}" - assertThat(FhirPathCustomResolver().resolveReference(customContext, org2Url, null)).isNull() + assertThat(FhirPathCustomResolver().resolveReference(null, customContext, org2Url, null)).isNull() bundle.addEntry().resource = org2 bundle.entry[1].fullUrl = org2Url - val reference = FhirPathCustomResolver().resolveReference(customContext, org2Url, null) + val reference = FhirPathCustomResolver().resolveReference(null, customContext, org2Url, null) assertThat(reference).isNotNull() assertThat(reference).isEqualTo(org2) } diff --git a/prime-router/src/test/kotlin/fhirengine/translation/hl7/utils/FhirPathUtilsTests.kt b/prime-router/src/test/kotlin/fhirengine/translation/hl7/utils/FhirPathUtilsTests.kt index e228719e1ca..173f4a61642 100644 --- a/prime-router/src/test/kotlin/fhirengine/translation/hl7/utils/FhirPathUtilsTests.kt +++ b/prime-router/src/test/kotlin/fhirengine/translation/hl7/utils/FhirPathUtilsTests.kt @@ -28,6 +28,7 @@ import io.mockk.spyk import io.mockk.verify import org.apache.logging.log4j.kotlin.KotlinLogger import org.hl7.fhir.exceptions.PathEngineException +import org.hl7.fhir.r4.fhirpath.FHIRLexer import org.hl7.fhir.r4.model.Bundle import org.hl7.fhir.r4.model.DateTimeType import org.hl7.fhir.r4.model.DateType @@ -37,7 +38,6 @@ import org.hl7.fhir.r4.model.InstantType import org.hl7.fhir.r4.model.Observation import org.hl7.fhir.r4.model.ServiceRequest import org.hl7.fhir.r4.model.TimeType -import org.hl7.fhir.r4.utils.FHIRLexer.FHIRLexerException import org.junit.jupiter.api.BeforeEach import java.util.Date import kotlin.test.Test @@ -69,7 +69,7 @@ class FhirPathUtilsTests { assertThat(FhirPathUtils.parsePath("")).isNull() // Invalid fhir path syntax - assertFailsWith { FhirPathUtils.parsePath("Bundle.#*($&id.exists()") } + assertFailsWith { FhirPathUtils.parsePath("Bundle.#*($&id.exists()") } } @Test @@ -101,7 +101,7 @@ class FhirPathUtilsTests { FhirPathUtils.evaluateCondition(null, bundle, bundle, bundle, path) } catch (e: Exception) { assertThat(e).isInstanceOf() - assertThat(e.cause).isNotNull().isInstanceOf() + assertThat(e.cause).isNotNull().isInstanceOf() } } @@ -193,7 +193,7 @@ class FhirPathUtilsTests { verify { mockedLogger.error( - "org.hl7.fhir.r4.utils.FHIRLexer\$FHIRLexerException: " + + "org.hl7.fhir.r4.fhirpath.FHIRLexer\$FHIRLexerException: " + "Syntax error in FHIR Path Bundle.#*(\$&id.exists()." ) } diff --git a/prime-router/src/testIntegration/resources/datatests/FHIR_to_HL7/sample_OML_20231013-0002.fhir b/prime-router/src/testIntegration/resources/datatests/FHIR_to_HL7/sample_OML_20231013-0002.fhir index 9fe84199462..1911df7fcdc 100644 --- a/prime-router/src/testIntegration/resources/datatests/FHIR_to_HL7/sample_OML_20231013-0002.fhir +++ b/prime-router/src/testIntegration/resources/datatests/FHIR_to_HL7/sample_OML_20231013-0002.fhir @@ -73,7 +73,7 @@ "eventCoding" : { "system" : "http://terminology.hl7.org/CodeSystem/v2-0003", "code" : "O21", - "display" : "OML - Laboratory order" + "display" : "OML^O21^OML_O21" }, "destination" : [ { diff --git a/settings.gradle.kts b/settings.gradle.kts index 8ae87b5128c..2a0e2ecd57d 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -13,4 +13,4 @@ sourceControl { } } rootProject.name = "prime-reportstream" -include("shared", "submissions", "prime-router") +include("shared", "submissions", "prime-router", "auth") diff --git a/submissions/build.gradle.kts b/submissions/build.gradle.kts index 4f7c29c7d79..4f3f4c9294e 100644 --- a/submissions/build.gradle.kts +++ b/submissions/build.gradle.kts @@ -14,6 +14,10 @@ extra["springCloudAzureVersion"] = "5.14.0" dependencies { implementation("org.springframework.boot:spring-boot-starter-web") + implementation("org.springframework.boot:spring-boot-starter-security") + implementation("org.springframework.boot:spring-boot-starter-oauth2-resource-server") + implementation("org.springframework.security:spring-security-oauth2-jose:6.3.3") + implementation("com.azure.spring:spring-cloud-azure-starter-storage") implementation("com.microsoft.azure:applicationinsights-runtime-attach:3.5.4") implementation("com.microsoft.azure:applicationinsights-web:3.5.4") diff --git a/submissions/src/main/kotlin/gov/cdc/prime/reportstream/submissions/config/SecurityConfig.kt b/submissions/src/main/kotlin/gov/cdc/prime/reportstream/submissions/config/SecurityConfig.kt new file mode 100644 index 00000000000..cdfa64f27d4 --- /dev/null +++ b/submissions/src/main/kotlin/gov/cdc/prime/reportstream/submissions/config/SecurityConfig.kt @@ -0,0 +1,31 @@ +package gov.cdc.prime.reportstream.submissions.config + +import org.springframework.context.annotation.Bean +import org.springframework.context.annotation.Configuration +import org.springframework.security.config.annotation.method.configuration.EnableMethodSecurity +import org.springframework.security.config.annotation.web.builders.HttpSecurity +import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity +import org.springframework.security.web.SecurityFilterChain + +/** + * Allow all requests sans any authn/authz checks. + */ +@Configuration +@EnableWebSecurity +@EnableMethodSecurity +class SecurityConfig { + @Bean + fun filterChain(http: HttpSecurity): SecurityFilterChain { + http + .authorizeHttpRequests { authorize -> + authorize + // TODO: add routes which require authentication here when required + .anyRequest().permitAll() // currently allow all requests unauthenticated + } + .oauth2ResourceServer { + it.jwt { } + } + + return http.build() + } +} \ No newline at end of file diff --git a/submissions/src/main/kotlin/gov/cdc/prime/reportstream/submissions/controllers/SubmissionController.kt b/submissions/src/main/kotlin/gov/cdc/prime/reportstream/submissions/controllers/SubmissionController.kt index 1e7612810d3..2de41c47a74 100644 --- a/submissions/src/main/kotlin/gov/cdc/prime/reportstream/submissions/controllers/SubmissionController.kt +++ b/submissions/src/main/kotlin/gov/cdc/prime/reportstream/submissions/controllers/SubmissionController.kt @@ -13,6 +13,9 @@ import gov.cdc.prime.reportstream.submissions.TelemetryService import org.slf4j.LoggerFactory import org.springframework.http.HttpStatus import org.springframework.http.ResponseEntity +import org.springframework.security.authorization.AuthorizationDeniedException +import org.springframework.security.oauth2.server.resource.authentication.BearerTokenAuthentication +import org.springframework.security.oauth2.server.resource.authentication.JwtAuthenticationToken import org.springframework.web.bind.MissingRequestHeaderException import org.springframework.web.bind.annotation.ControllerAdvice import org.springframework.web.bind.annotation.ExceptionHandler @@ -173,6 +176,16 @@ class SubmissionController( return ResponseEntity("Internal Server Error: ${e.message}", HttpStatus.INTERNAL_SERVER_ERROR) } + @ExceptionHandler(AuthorizationDeniedException::class) + fun handleAuthorizationException( + e: AuthorizationDeniedException, + auth: JwtAuthenticationToken + ): ResponseEntity { + logger.warn("Authorization denied for token attributes: ${auth.tokenAttributes}", e) + + return ResponseEntity.status(HttpStatus.FORBIDDEN).build() + } + /** * Handles exceptions of type IllegalArgumentException. * diff --git a/submissions/src/main/resources/application.properties b/submissions/src/main/resources/application.properties index 14a3fe573d2..a8750014f5d 100644 --- a/submissions/src/main/resources/application.properties +++ b/submissions/src/main/resources/application.properties @@ -3,4 +3,5 @@ server.port=8880 azure.storage.connection-string=${AZURE_STORAGE_CONNECTION_STRING:DefaultEndpointsProtocol=http;AccountName=devstoreaccount1;AccountKey=Eby8vdM02xNOcqFlqUwJPLlmEtlCDXJ1OUzFT50uSRZ6IFsuFq2UVErCz4I6tq/K1SZFPTOtr/KBHBeksoGMGw==;BlobEndpoint=http://localhost:10000/devstoreaccount1;QueueEndpoint=http://localhost:10001/devstoreaccount1;TableEndpoint=http://127.0.0.1:10002/devstoreaccount1;} azure.storage.container-name=${AZURE_STORAGE_CONTAINER_NAME:reports} azure.storage.queue-name=${AZURE_STORAGE_QUEUE_NAME:elr-fhir-receive} -azure.storage.table-name=${AZURE_STORAGE_TABLE_NAME:submission} \ No newline at end of file +azure.storage.table-name=${AZURE_STORAGE_TABLE_NAME:submission} +spring.security.oauth2.resourceserver.jwt.issuer-uri=https://reportstream.oktapreview.com/oauth2/ausekaai7gUuUtHda1d7 \ No newline at end of file