diff --git a/api-gateway/build.gradle.kts b/api-gateway/build.gradle.kts index f27f70ccf9..bb1d2a5d43 100644 --- a/api-gateway/build.gradle.kts +++ b/api-gateway/build.gradle.kts @@ -9,7 +9,7 @@ dependencies { implementation(libs.spring.cloud.starter.gateway) implementation(libs.spring.boot.starter.security) implementation(libs.spring.boot.starter.oauth2.client) - implementation(libs.spring.cloud.starter.kubernetes.client.config) + implementation(libs.spring.cloud.starter.kubernetes.fabric8.config) implementation(libs.spring.security.core) implementation(projects.authenticationService) diff --git a/authentication-service/build.gradle.kts b/authentication-service/build.gradle.kts index e3fb27980c..479ab12f89 100644 --- a/authentication-service/build.gradle.kts +++ b/authentication-service/build.gradle.kts @@ -23,8 +23,14 @@ kotlin { dependencies { implementation(projects.saveCloudCommon) implementation(libs.spring.boot.starter.security) + implementation(libs.spring.jdbc) implementation(libs.spring.security.core) - implementation("org.springframework:spring-jdbc") + implementation(libs.spring.security.config) + implementation(libs.spring.security.web) + implementation(libs.spring.boot.autoconfigure) { + because("This dependency contains `ConditionalOnCloudPlatform`") + } + implementation(libs.fabric8.kubernetes.client) testImplementation(libs.spring.security.test) testImplementation(libs.junit.jupiter.api) } diff --git a/authentication-service/src/main/kotlin/com/saveourtool/save/authservice/config/SecurityWebClientCustomizers.kt b/authentication-service/src/main/kotlin/com/saveourtool/save/authservice/config/SecurityWebClientCustomizers.kt new file mode 100644 index 0000000000..0b1104bbc2 --- /dev/null +++ b/authentication-service/src/main/kotlin/com/saveourtool/save/authservice/config/SecurityWebClientCustomizers.kt @@ -0,0 +1,101 @@ +/** + * Customization for spring `WebClient` + */ + +package com.saveourtool.save.authservice.config + +import com.saveourtool.save.authservice.utils.SA_HEADER_NAME +import com.saveourtool.save.utils.debug +import com.saveourtool.save.utils.getLogger +import org.springframework.beans.factory.annotation.Value + +import org.springframework.boot.autoconfigure.condition.ConditionalOnCloudPlatform +import org.springframework.boot.cloud.CloudPlatform +import org.springframework.boot.web.reactive.function.client.WebClientCustomizer +import org.springframework.context.annotation.Bean +import org.springframework.context.annotation.Configuration +import org.springframework.web.reactive.function.client.ClientRequest +import org.springframework.web.reactive.function.client.WebClient + +import java.nio.file.Path +import java.time.Duration +import java.util.concurrent.atomic.AtomicLong +import java.util.concurrent.atomic.AtomicReference + +import kotlin.io.path.readText + +/** + * A configuration class that can be used to import all related [WebClientCustomizer] beans. + */ +@Configuration +class SecurityWebClientCustomizers { + @Bean + @ConditionalOnCloudPlatform(CloudPlatform.KUBERNETES) + @Suppress("") + /** + * @param expirationTimeMinutes + * Service account token will be cached in memory for this number of minutes and re-read after it passes. + * See also [docs](https://kubernetes.io/docs/concepts/storage/projected-volumes/#serviceaccounttoken). + * @param tokenPath mount path for SA token as specified in Pod spec + */ + fun serviceAccountTokenHeaderWebClientCustomizer( + @Value("\${com.saveourtool.cloud.kubernetes.sa-token.expiration.minutes:5}") expirationTimeMinutes: Long, + @Value("\${com.saveourtool.cloud.kubernetes.sa-token.path:/var/run/secrets/tokens/service-account-projected-token}") tokenPath: String, + ) = ServiceAccountTokenHeaderWebClientCustomizer(expirationTimeMinutes, tokenPath) +} + +/** + * A [WebClientCustomizer] that appends Kubernetes' ServiceAccount token as a custom header. + * + * @param expirationTimeMinutes for how long token should be reused from memory before reading it from the file again. + */ +class ServiceAccountTokenHeaderWebClientCustomizer( + expirationTimeMinutes: Long, + tokenPath: String, +) : WebClientCustomizer { + @Suppress("GENERIC_VARIABLE_WRONG_DECLARATION") + private val logger = getLogger() + private val wrapper = ExpiringValueWrapper(Duration.ofMinutes(expirationTimeMinutes)) { + val token = Path.of(tokenPath).readText() + token + } + + override fun customize(builder: WebClient.Builder) { + builder.filter { request, next -> + val token = wrapper.getValue() + logger.debug { "Appending `$SA_HEADER_NAME` header to the request ${request.method()} to ${request.url()}" } + ClientRequest.from(request) + .header(SA_HEADER_NAME, token) + .build() + .let(next::exchange) + } + } +} + +/** + * A wrapper around a value of type [T] that caches it for [expirationTimeMillis] and then recalculates + * using [valueGetter] + * + * @param expirationTime value expiration time + * @property valueGetter a function to calculate the value of type [T] + */ +class ExpiringValueWrapper( + expirationTime: Duration, + private val valueGetter: () -> T, +) { + private val expirationTimeMillis = expirationTime.toMillis() + private val lastUpdateTimeMillis = AtomicLong(0) + private val value: AtomicReference = AtomicReference() + + /** + * @return cached value or refreshes the value and returns it + */ + fun getValue(): T { + val current = System.currentTimeMillis() + if (current - lastUpdateTimeMillis.get() > expirationTimeMillis) { + value.lazySet(valueGetter()) + lastUpdateTimeMillis.lazySet(current) + } + return value.get() + } +} diff --git a/authentication-service/src/main/kotlin/com/saveourtool/save/authservice/config/WebSecurityConfig.kt b/authentication-service/src/main/kotlin/com/saveourtool/save/authservice/config/WebSecurityConfig.kt index 4c8eb8c0c4..14b252cf0d 100644 --- a/authentication-service/src/main/kotlin/com/saveourtool/save/authservice/config/WebSecurityConfig.kt +++ b/authentication-service/src/main/kotlin/com/saveourtool/save/authservice/config/WebSecurityConfig.kt @@ -11,6 +11,8 @@ import com.saveourtool.save.v1 import org.springframework.beans.factory.annotation.Autowired import org.springframework.context.annotation.Bean import org.springframework.context.annotation.Profile +import org.springframework.core.Ordered +import org.springframework.core.annotation.Order import org.springframework.http.HttpStatus import org.springframework.security.access.expression.method.DefaultMethodSecurityExpressionHandler import org.springframework.security.config.annotation.method.configuration.EnableReactiveMethodSecurity @@ -20,22 +22,34 @@ import org.springframework.security.crypto.factory.PasswordEncoderFactories import org.springframework.security.crypto.password.PasswordEncoder import org.springframework.security.web.server.SecurityWebFilterChain import org.springframework.security.web.server.authentication.HttpStatusServerEntryPoint +import org.springframework.security.web.server.util.matcher.ServerWebExchangeMatchers import javax.annotation.PostConstruct +/** + * Common configuration for web security which exposes [SecurityWebFilterChain] beans. + * Note: configuration of [ServerHttpSecurity] should start with [ServerHttpSecurity.securityMatcher] invocation + * to be able to use multiple [SecurityWebFilterChain]s. See [comments form this answer](https://stackoverflow.com/a/54792674) + * for details. + */ @EnableWebFluxSecurity @EnableReactiveMethodSecurity @Profile("secure") -@Suppress("MISSING_KDOC_TOP_LEVEL", "MISSING_KDOC_CLASS_ELEMENTS", "MISSING_KDOC_ON_FUNCTION") +@Suppress("MISSING_KDOC_CLASS_ELEMENTS", "MISSING_KDOC_ON_FUNCTION") class WebSecurityConfig( private val authenticationManager: ConvertingAuthenticationManager, @Autowired private var defaultMethodSecurityExpressionHandler: DefaultMethodSecurityExpressionHandler ) { @Bean + @Order(Ordered.LOWEST_PRECEDENCE) fun securityWebFilterChain( http: ServerHttpSecurity ): SecurityWebFilterChain = http.run { - authorizeExchange() + securityMatcher( + // This `SecurityWebFilterChain` should be applicable to all requests not matched above + ServerWebExchangeMatchers.anyExchange() + ) + .authorizeExchange() .pathMatchers(*publicEndpoints.toTypedArray()) .permitAll() // resources for frontend @@ -120,7 +134,9 @@ class NoopWebSecurityConfig { @Bean fun securityWebFilterChain( http: ServerHttpSecurity - ): SecurityWebFilterChain = http.authorizeExchange() + ): SecurityWebFilterChain = http + .securityMatcher(ServerWebExchangeMatchers.anyExchange()) + .authorizeExchange() .anyExchange() .permitAll() .and() diff --git a/authentication-service/src/main/kotlin/com/saveourtool/save/authservice/security/ConvertingAuthenticationManager.kt b/authentication-service/src/main/kotlin/com/saveourtool/save/authservice/security/ConvertingAuthenticationManager.kt index 984f4b6584..557669eabe 100644 --- a/authentication-service/src/main/kotlin/com/saveourtool/save/authservice/security/ConvertingAuthenticationManager.kt +++ b/authentication-service/src/main/kotlin/com/saveourtool/save/authservice/security/ConvertingAuthenticationManager.kt @@ -6,6 +6,8 @@ import com.saveourtool.save.authservice.utils.username import com.saveourtool.save.entities.User import com.saveourtool.save.utils.blockingToMono +import org.springframework.beans.factory.annotation.Autowired +import org.springframework.context.annotation.Primary import org.springframework.security.authentication.BadCredentialsException import org.springframework.security.authentication.ReactiveAuthenticationManager import org.springframework.security.authentication.UsernamePasswordAuthenticationToken @@ -21,6 +23,7 @@ import reactor.kotlin.core.publisher.switchIfEmpty * where user identity is already guaranteed. */ @Component +@Primary class ConvertingAuthenticationManager( private val authenticationUserRepository: AuthenticationUserRepository, ) : ReactiveAuthenticationManager { diff --git a/authentication-service/src/main/kotlin/com/saveourtool/save/authservice/utils/KubernetesAuthenticationUtils.kt b/authentication-service/src/main/kotlin/com/saveourtool/save/authservice/utils/KubernetesAuthenticationUtils.kt new file mode 100644 index 0000000000..78af629508 --- /dev/null +++ b/authentication-service/src/main/kotlin/com/saveourtool/save/authservice/utils/KubernetesAuthenticationUtils.kt @@ -0,0 +1,219 @@ +/** + * Utilities to configure Kubernetes ServiceAccount token-based authentication in Spring Security. + */ + +package com.saveourtool.save.authservice.utils + +import com.saveourtool.save.utils.debug +import com.saveourtool.save.utils.getLogger + +import io.fabric8.kubernetes.api.model.authentication.TokenReview +import io.fabric8.kubernetes.client.KubernetesClient +import io.fabric8.kubernetes.client.utils.Serialization +import org.intellij.lang.annotations.Language +import org.springframework.boot.autoconfigure.condition.ConditionalOnCloudPlatform +import org.springframework.boot.cloud.CloudPlatform +import org.springframework.context.annotation.Bean +import org.springframework.context.annotation.Configuration +import org.springframework.context.annotation.Import +import org.springframework.core.annotation.Order +import org.springframework.http.HttpStatus +import org.springframework.security.authentication.BadCredentialsException +import org.springframework.security.authentication.ReactiveAuthenticationManager +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken +import org.springframework.security.config.web.server.SecurityWebFiltersOrder +import org.springframework.security.config.web.server.ServerHttpSecurity +import org.springframework.security.core.Authentication +import org.springframework.security.web.authentication.preauth.PreAuthenticatedAuthenticationToken +import org.springframework.security.web.server.SecurityWebFilterChain +import org.springframework.security.web.server.authentication.AuthenticationWebFilter +import org.springframework.security.web.server.authentication.HttpStatusServerEntryPoint +import org.springframework.security.web.server.authentication.ServerAuthenticationConverter +import org.springframework.security.web.server.util.matcher.NegatedServerWebExchangeMatcher +import org.springframework.security.web.server.util.matcher.ServerWebExchangeMatchers +import org.springframework.stereotype.Component +import org.springframework.web.server.ServerWebExchange +import reactor.core.publisher.Mono +import reactor.kotlin.core.publisher.switchIfEmpty +import reactor.kotlin.core.publisher.toMono + +const val SA_HEADER_NAME = "X-Service-Account-Token" + +/** + * A Configuration class that can be used to import all related beans to set up Spring Security + * to work with Kubernetes ServiceAccount tokens. + */ +@Configuration +@Import( + ServiceAccountTokenExtractorConverter::class, + ServiceAccountAuthenticatingManager::class, +) +@Suppress( + "AVOID_USING_UTILITY_CLASS", // Spring beans need to be declared inside `@Configuration` class. +) +class KubernetesAuthenticationUtils { + @ConditionalOnCloudPlatform(CloudPlatform.KUBERNETES) + @Bean + @Order(2) + @Suppress( + "MISSING_KDOC_CLASS_ELEMENTS", + "MISSING_KDOC_ON_FUNCTION", + ) + fun internalSecuredSecurityChain( + http: ServerHttpSecurity, + serviceAccountAuthenticatingManager: ServiceAccountAuthenticatingManager, + serviceAccountTokenExtractorConverter: ServiceAccountTokenExtractorConverter, + ): SecurityWebFilterChain = http.run { + securityMatcher( + NegatedServerWebExchangeMatcher( + ServerWebExchangeMatchers.pathMatchers("/api/**", "/sandbox/api/**") + ) + ) + .authorizeExchange() + .pathMatchers("/actuator/**") + // all requests to `/actuator` should be sent only from inside the cluster + // access to this port should be controlled by a NetworkPolicy + .permitAll() + .pathMatchers( + // FixMe: https://github.com/saveourtool/save-cloud/pull/1247 + "/internal/files/download-save-agent", + "/internal/files/download-save-cli", + "/internal/files/download", + "/internal/files/debug-info", + "/internal/test-suites-sources/download-snapshot-by-execution-id", + "/internal/saveTestResult", + "/heartbeat", + ) + .permitAll() + .and() + .authorizeExchange() + .pathMatchers("/**") + .authenticated() + .and() + .serviceAccountTokenAuthentication(serviceAccountTokenExtractorConverter, serviceAccountAuthenticatingManager) + .csrf() + .disable() + .logout() + .disable() + .formLogin() + .disable() + .build() + } + + /** + * No-op security config when not running in Kubernetes. + * FixMe: can be removed in favor of common `WebSecurityConfig` from authService? + */ + @ConditionalOnCloudPlatform(CloudPlatform.NONE) + @Bean + @Order(2) + @Suppress( + "MISSING_KDOC_CLASS_ELEMENTS", + "MISSING_KDOC_ON_FUNCTION", + "KDOC_WITHOUT_PARAM_TAG", + "KDOC_WITHOUT_RETURN_TAG", + ) + fun internalInsecureSecurityChain( + http: ServerHttpSecurity + ): SecurityWebFilterChain = http.run { + securityMatcher( + ServerWebExchangeMatchers.pathMatchers("/internal/**", "/actuator/**") + ) + .authorizeExchange() + .pathMatchers("/internal/**", "/actuator/**") + .permitAll() + .and() + .csrf() + .disable() + .build() + } +} + +/** + * A [ServerAuthenticationConverter] that attempts to convert a [ServerWebExchange] to an [Authentication] + * if it encounters a SA token in [SA_HEADER_NAME] header. + */ +@Component +@ConditionalOnCloudPlatform(CloudPlatform.KUBERNETES) +class ServiceAccountTokenExtractorConverter : ServerAuthenticationConverter { + @Suppress("GENERIC_VARIABLE_WRONG_DECLARATION") + private val logger = getLogger() + override fun convert(exchange: ServerWebExchange): Mono = Mono.justOrEmpty( + exchange.request.headers[SA_HEADER_NAME]?.firstOrNull() + ).map { token -> + logger.debug { "Starting to process `$SA_HEADER_NAME` of an incoming request [${exchange.request.method} ${exchange.request.uri}]" } + PreAuthenticatedAuthenticationToken("TokenSupplier", token) + } +} + +/** + * A [ReactiveAuthenticationManager] that is intended to be used together with [ServerAuthenticationConverter]. + * Attempts to authenticate an [Authentication] validating ServiceAccount token using TokeReview API. + */ +@Component +@ConditionalOnCloudPlatform(CloudPlatform.KUBERNETES) +class ServiceAccountAuthenticatingManager( + private val kubernetesClient: KubernetesClient, +) : ReactiveAuthenticationManager { + @Suppress("GENERIC_VARIABLE_WRONG_DECLARATION") + private val logger = getLogger() + override fun authenticate(authentication: Authentication): Mono = authentication.toMono() + .filter { it is PreAuthenticatedAuthenticationToken } + .map { preAuthenticatedAuthenticationToken -> + val tokenReview = tokenReviewSpec(preAuthenticatedAuthenticationToken.credentials as String) + logger.debug { + "Will create k8s resource from the following YAML:\n${tokenReview.prependIndent(" ")}" + } + val response = kubernetesClient.resource(tokenReview).createOrReplace() as TokenReview + logger.debug { + "Got the following response from the API server:\n${ + Serialization.yamlMapper().writeValueAsString(response).prependIndent(" ") + }" + } + response + } + .filter { response -> + val isAuthenticated = response.status.error.isNullOrEmpty() && response.status.authenticated + logger.debug { "After the response from TokenReview, request authentication is $isAuthenticated" } + isAuthenticated + } + .map { + with(authentication) { + UsernamePasswordAuthenticationToken.authenticated(principal, credentials, authorities) + } + } + .switchIfEmpty { + Mono.error { BadCredentialsException("Invalid token") } + } + + @Language("YAML") + private fun tokenReviewSpec(token: String): String = """ + |apiVersion: authentication.k8s.io/v1 + |kind: TokenReview + |metadata: + | name: service-account-validity-check + | namespace: ${kubernetesClient.namespace} + |spec: + | token: $token + """.trimMargin() +} + +/** + * Configures authentication and authorization using Kubernetes ServiceAccount tokens. + * This method requires two beans which can be imported with [KubernetesAuthenticationUtils] configuration class. + */ +@Suppress("KDOC_WITHOUT_PARAM_TAG", "KDOC_WITHOUT_RETURN_TAG") +fun ServerHttpSecurity.serviceAccountTokenAuthentication( + serviceAccountTokenExtractorConverter: ServiceAccountTokenExtractorConverter, + serviceAccountAuthenticatingManager: ServiceAccountAuthenticatingManager, +): ServerHttpSecurity = addFilterBefore( + AuthenticationWebFilter(serviceAccountAuthenticatingManager).apply { + setServerAuthenticationConverter(serviceAccountTokenExtractorConverter) + }, + SecurityWebFiltersOrder.HTTP_BASIC +) + .exceptionHandling { + it.authenticationEntryPoint( + HttpStatusServerEntryPoint(HttpStatus.UNAUTHORIZED) + ) + } diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 0e4dcd7031..a17956a3b6 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -29,6 +29,7 @@ testcontainers = "1.18.3" okhttp3 = "4.11.0" reckon = "0.18.0" commons-compress = "1.23.0" +commons-io = "2.11.0" zip4j = "2.11.5" ktoml = "0.5.0" springdoc = "1.7.0" @@ -97,19 +98,24 @@ spring-boot-starter-data-jpa = { module = "org.springframework.boot:spring-boot- spring-boot-starter-quartz = { module = "org.springframework.boot:spring-boot-starter-quartz" } spring-boot-starter-security = { module = "org.springframework.boot:spring-boot-starter-security" } spring-boot = { module = "org.springframework.boot:spring-boot" } +spring-boot-autoconfigure = { module = "org.springframework.boot:spring-boot-autoconfigure" } spring-boot-configuration-processor = { module = "org.springframework.boot:spring-boot-configuration-processor", version.ref = "spring-boot" } spring-security-core = { module = "org.springframework.security:spring-security-core" } +spring-security-web = { module = "org.springframework.security:spring-security-web" } +spring-security-config = { module = "org.springframework.security:spring-security-config" } spring-security-oauth2-client = { module = "org.springframework.security:spring-security-oauth2-client" } spring-security-test = { module = "org.springframework.security:spring-security-test" } spring-boot-gradle-plugin = { module = "org.springframework.boot:spring-boot-gradle-plugin", version.ref = "spring-boot" } spring-cloud-starter-gateway = { module = "org.springframework.cloud:spring-cloud-starter-gateway", version.ref = "spring-cloud" } spring-cloud-starter-kubernetes-client-config = { module = "org.springframework.cloud:spring-cloud-starter-kubernetes-client-config", version.ref = "spring-cloud-kubernetes" } +spring-cloud-starter-kubernetes-fabric8-config = { module = "org.springframework.cloud:spring-cloud-starter-kubernetes-fabric8-config", version.ref = "spring-cloud-kubernetes" } spring-boot-starter-oauth2-client = { module = "org.springframework.boot:spring-boot-starter-oauth2-client" } spring-data-jpa = { module = "org.springframework.data:spring-data-jpa" } spring-kafka = { module = "org.springframework.kafka:spring-kafka" } spring-kafka-test = { module = "org.springframework.kafka:spring-kafka-test" } spring-web = { module = "org.springframework:spring-web" } spring-webflux = { module = "org.springframework:spring-webflux" } +spring-jdbc = { module = "org.springframework:spring-jdbc" } spring-jdbc-starter = { module = "org.springframework.boot:spring-boot-starter-data-jdbc" } jackson-module-kotlin = { module = "com.fasterxml.jackson.module:jackson-module-kotlin" } @@ -188,6 +194,7 @@ diktat-gradle-plugin = { module = "org.cqfn.diktat:diktat-gradle-plugin", versio detekt-gradle-plugin = { module = "io.gitlab.arturbosch.detekt:detekt-gradle-plugin", version.ref = "detekt" } reckon-gradle-plugin = { module = "org.ajoberstar.reckon:reckon-gradle", version.ref = "reckon" } commons-compress = { module = "org.apache.commons:commons-compress", version.ref = "commons-compress" } +commons-io = { module = "commons-io:commons-io", version.ref = "commons-io" } zip4j = { module = "net.lingala.zip4j:zip4j", version.ref = "zip4j" } kotlinx-cli = { module = "org.jetbrains.kotlinx:kotlinx-cli", version.ref = "kotlinx-cli" } gradle-plugin-spotless = { module = "com.diffplug.spotless:spotless-plugin-gradle", version.ref = "spotless" } diff --git a/save-backend/build.gradle.kts b/save-backend/build.gradle.kts index 79ddfa8945..60e201a4d1 100644 --- a/save-backend/build.gradle.kts +++ b/save-backend/build.gradle.kts @@ -53,7 +53,7 @@ dependencies { implementation(libs.spring.boot.starter.security) implementation(libs.spring.security.core) implementation(libs.hibernate.micrometer) - implementation(libs.spring.cloud.starter.kubernetes.client.config) + implementation(libs.spring.cloud.starter.kubernetes.fabric8.config) implementation(libs.reactor.extra) implementation(libs.arrow.kt.core) implementation(project.dependencies.platform(libs.aws.sdk.bom)) @@ -64,6 +64,7 @@ dependencies { implementation(libs.ktor.client.content.negotiation) implementation(libs.ktor.serialization.kotlinx.json) implementation(libs.ktor.client.apache) + implementation(libs.commons.io) testImplementation(libs.spring.security.test) testImplementation(libs.kotlinx.serialization.json) testImplementation(projects.testUtils) diff --git a/save-backend/src/main/kotlin/com/saveourtool/save/backend/SaveApplication.kt b/save-backend/src/main/kotlin/com/saveourtool/save/backend/SaveApplication.kt index 4ff7501ce8..d96426e9da 100644 --- a/save-backend/src/main/kotlin/com/saveourtool/save/backend/SaveApplication.kt +++ b/save-backend/src/main/kotlin/com/saveourtool/save/backend/SaveApplication.kt @@ -1,5 +1,6 @@ package com.saveourtool.save.backend +import com.saveourtool.save.authservice.config.SecurityWebClientCustomizers import com.saveourtool.save.backend.configs.ConfigProperties import com.saveourtool.save.s3.DefaultS3Configuration import org.springframework.boot.SpringApplication @@ -20,7 +21,7 @@ internal typealias ByteBufferFluxResponse = FluxResponse */ @SpringBootApplication @EnableConfigurationProperties(ConfigProperties::class) -@Import(DefaultS3Configuration::class) +@Import(com.saveourtool.save.authservice.config.SecurityWebClientCustomizers::class, DefaultS3Configuration::class) class SaveApplication fun main(args: Array) { diff --git a/save-backend/src/main/kotlin/com/saveourtool/save/backend/configs/WebSecurityConfig.kt b/save-backend/src/main/kotlin/com/saveourtool/save/backend/configs/WebSecurityConfig.kt index 8a44dd0fc2..735673c393 100644 --- a/save-backend/src/main/kotlin/com/saveourtool/save/backend/configs/WebSecurityConfig.kt +++ b/save-backend/src/main/kotlin/com/saveourtool/save/backend/configs/WebSecurityConfig.kt @@ -9,6 +9,7 @@ import com.saveourtool.save.authservice.config.WebSecurityConfig import com.saveourtool.save.authservice.repository.AuthenticationUserRepository import com.saveourtool.save.authservice.security.ConvertingAuthenticationManager import com.saveourtool.save.authservice.service.AuthenticationUserDetailsService +import com.saveourtool.save.authservice.utils.KubernetesAuthenticationUtils import org.springframework.context.annotation.Import import org.springframework.context.annotation.Profile @@ -24,6 +25,7 @@ import org.springframework.security.config.annotation.web.reactive.EnableWebFlux ConvertingAuthenticationManager::class, AuthenticationUserDetailsService::class, AuthenticationUserRepository::class, + KubernetesAuthenticationUtils::class, ) @Suppress("MISSING_KDOC_TOP_LEVEL", "MISSING_KDOC_CLASS_ELEMENTS", "MISSING_KDOC_ON_FUNCTION") class BackendWebSecurityConfig diff --git a/save-backend/src/main/resources/bootstrap.yml b/save-backend/src/main/resources/bootstrap.yml index 07fa77fb0d..df67bb9c88 100644 --- a/save-backend/src/main/resources/bootstrap.yml +++ b/save-backend/src/main/resources/bootstrap.yml @@ -18,7 +18,8 @@ spring: kubernetes: enabled: true config: - enabled: false + enabled: true + paths: /home/cnb/config/application.properties secrets: enabled: true paths: diff --git a/save-cloud-charts/save-cloud/templates/_helpers.tpl b/save-cloud-charts/save-cloud/templates/_helpers.tpl index b14136597b..ffe1fe6d6a 100644 --- a/save-cloud-charts/save-cloud/templates/_helpers.tpl +++ b/save-cloud-charts/save-cloud/templates/_helpers.tpl @@ -79,4 +79,18 @@ configMap: items: - key: application.properties path: application.properties +{{- end}} + +{{- define "spring-boot.sa-token-mount" -}} +name: service-account-projected-token +mountPath: /var/run/secrets/tokens +{{- end }} + +{{- define "spring-boot.sa-token-volume" -}} +name: service-account-projected-token +projected: + sources: + - serviceAccountToken: + path: service-account-projected-token + expirationSeconds: 7200 {{- end}} \ No newline at end of file diff --git a/save-cloud-charts/save-cloud/templates/backend-deployment.yaml b/save-cloud-charts/save-cloud/templates/backend-deployment.yaml index 23bc58d43e..e33f562d53 100644 --- a/save-cloud-charts/save-cloud/templates/backend-deployment.yaml +++ b/save-cloud-charts/save-cloud/templates/backend-deployment.yaml @@ -18,6 +18,7 @@ spec: annotations: {{- include "pod.common.annotations" (dict "service" .Values.backend ) | nindent 8 }} spec: + serviceAccountName: microservice-sa restartPolicy: Always {{- include "cnb.securityContext" . | nindent 6 }} containers: @@ -37,6 +38,7 @@ spec: mountPath: {{ .Values.mysql.dbPasswordFile }} - name: s3-secrets mountPath: {{ .Values.s3.secretFile }} + - {{ include "spring-boot.sa-token-mount" . | indent 14 | trim }} {{- include "spring-boot.management" .Values.backend | nindent 10 }} resources: limits: @@ -111,3 +113,4 @@ spec: secretName: s3-secrets - name: migrations-data emptyDir: {} + - {{ include "spring-boot.sa-token-volume" (dict "service" .Values.backend) | indent 10 | trim }} diff --git a/save-cloud-charts/save-cloud/templates/demo-deployment.yaml b/save-cloud-charts/save-cloud/templates/demo-deployment.yaml index d17c1322f0..69a94555f2 100644 --- a/save-cloud-charts/save-cloud/templates/demo-deployment.yaml +++ b/save-cloud-charts/save-cloud/templates/demo-deployment.yaml @@ -57,6 +57,7 @@ spec: mountPath: {{ .Values.mysql.dbPasswordFile }} - name: s3-secrets mountPath: {{ .Values.s3.secretFile }} + - {{ include "spring-boot.sa-token-mount" . | indent 14 | trim }} {{- include "spring-boot.management" .Values.demo | nindent 10 }} resources: limits: @@ -130,3 +131,4 @@ spec: secretName: s3-secrets - name: migrations-data emptyDir: { } + - {{ include "spring-boot.sa-token-volume" (dict "service" .Values.demo) | indent 10 | trim }} diff --git a/save-cloud-charts/save-cloud/templates/demo-service-account.yaml b/save-cloud-charts/save-cloud/templates/demo-service-account.yaml index 48fe1bde21..18c5dc4f17 100644 --- a/save-cloud-charts/save-cloud/templates/demo-service-account.yaml +++ b/save-cloud-charts/save-cloud/templates/demo-service-account.yaml @@ -20,3 +20,19 @@ roleRef: apiGroup: rbac.authorization.k8s.io kind: Role name: jobs-executor + +--- + +# Bind orchestrator-sa to ClusterRole required for TokenReview access +apiVersion: rbac.authorization.k8s.io/v1 +kind: ClusterRoleBinding +metadata: + name: demo-microservice-role-binding +subjects: + - kind: ServiceAccount + name: demo-sa + namespace: {{ .Release.Namespace }} +roleRef: + apiGroup: rbac.authorization.k8s.io + kind: ClusterRole + name: microservice diff --git a/save-cloud-charts/save-cloud/templates/gateway-deployment.yaml b/save-cloud-charts/save-cloud/templates/gateway-deployment.yaml index 2c21db2c56..ec935b6134 100644 --- a/save-cloud-charts/save-cloud/templates/gateway-deployment.yaml +++ b/save-cloud-charts/save-cloud/templates/gateway-deployment.yaml @@ -16,6 +16,7 @@ spec: annotations: {{- include "pod.common.annotations" (dict "service" .Values.backend ) | nindent 8 }} spec: + serviceAccountName: microservice-sa restartPolicy: Always {{- include "cnb.securityContext" . | nindent 6 }} containers: @@ -27,6 +28,7 @@ spec: value: /home/cnb/secrets/oauth - name: JAVA_TOOL_OPTIONS value: -XX:ReservedCodeCacheSize=48M -Dreactor.netty.http.server.accessLogEnabled=true + - {{ include "spring-boot.sa-token-mount" . | indent 14 | trim }} {{- include "spring-boot.management" .Values.gateway | nindent 10 }} resources: limits: @@ -42,3 +44,4 @@ spec: - name: oauth-credentials secret: secretName: oauth-credentials + - {{ include "spring-boot.sa-token-volume" (dict "service" .Values.gateway) | indent 10 | trim }} diff --git a/save-cloud-charts/save-cloud/templates/microservice-service-account.yaml b/save-cloud-charts/save-cloud/templates/microservice-service-account.yaml new file mode 100644 index 0000000000..f33bd3fe46 --- /dev/null +++ b/save-cloud-charts/save-cloud/templates/microservice-service-account.yaml @@ -0,0 +1,57 @@ +apiVersion: v1 +kind: ServiceAccount +metadata: + name: microservice-sa + +--- + +# https://docs.spring.io/spring-cloud-kubernetes/docs/current/reference/html/#service-account +apiVersion: rbac.authorization.k8s.io/v1 +kind: Role +metadata: + name: microservice +rules: + - apiGroups: [""] # "" indicates the core API group + resources: [configmaps, secrets] + verbs: [list, get, watch] + +--- + +apiVersion: rbac.authorization.k8s.io/v1 +kind: RoleBinding +metadata: + name: microservice-role-binding +subjects: + - kind: ServiceAccount + name: microservice-sa +roleRef: + apiGroup: rbac.authorization.k8s.io + kind: Role + name: microservice + +--- + +# Give access to `TokenReview` to be able to validate incoming ServiceAccount tokens +apiVersion: rbac.authorization.k8s.io/v1 +kind: ClusterRole +metadata: + name: microservice +rules: + - apiGroups: ["authentication.k8s.io"] + resources: ["tokenreviews"] + verbs: ["create"] + +--- + +apiVersion: rbac.authorization.k8s.io/v1 +kind: ClusterRoleBinding +metadata: + name: microservice-role-binding +subjects: + - kind: ServiceAccount + name: microservice-sa + namespace: {{ .Release.Namespace }} +roleRef: + apiGroup: rbac.authorization.k8s.io + kind: ClusterRole + name: microservice \ No newline at end of file diff --git a/save-cloud-charts/save-cloud/templates/orchestrator-deployment.yaml b/save-cloud-charts/save-cloud/templates/orchestrator-deployment.yaml index d13133ff71..f81a08eece 100644 --- a/save-cloud-charts/save-cloud/templates/orchestrator-deployment.yaml +++ b/save-cloud-charts/save-cloud/templates/orchestrator-deployment.yaml @@ -47,6 +47,7 @@ spec: fieldPath: spec.nodeName volumeMounts: - {{ include "spring-boot.config-volume-mount" . | indent 14 | trim }} + - {{ include "spring-boot.sa-token-mount" . | indent 14 | trim }} {{- include "spring-boot.management" .Values.orchestrator | nindent 10 }} resources: limits: @@ -55,3 +56,4 @@ spec: memory: 600M volumes: - {{ include "spring-boot.config-volume" (dict "service" .Values.orchestrator) | indent 10 | trim }} + - {{ include "spring-boot.sa-token-volume" (dict "service" .Values.backend) | indent 10 | trim }} diff --git a/save-cloud-charts/save-cloud/templates/orchestrator-service-account.yaml b/save-cloud-charts/save-cloud/templates/orchestrator-service-account.yaml index cd072df883..d02945b5b2 100644 --- a/save-cloud-charts/save-cloud/templates/orchestrator-service-account.yaml +++ b/save-cloud-charts/save-cloud/templates/orchestrator-service-account.yaml @@ -44,3 +44,19 @@ roleRef: apiGroup: rbac.authorization.k8s.io kind: Role name: jobs-executor + +--- + +# Bind orchestrator-sa to ClusterRole required for TokenReview access +apiVersion: rbac.authorization.k8s.io/v1 +kind: ClusterRoleBinding +metadata: + name: orchestrator-microservice-role-binding +subjects: + - kind: ServiceAccount + name: orchestrator-sa + namespace: {{ .Release.Namespace }} +roleRef: + apiGroup: rbac.authorization.k8s.io + kind: ClusterRole + name: microservice diff --git a/save-cloud-charts/save-cloud/templates/preprocessor-configmap.yaml b/save-cloud-charts/save-cloud/templates/preprocessor-configmap.yaml index a192b05872..d494033400 100644 --- a/save-cloud-charts/save-cloud/templates/preprocessor-configmap.yaml +++ b/save-cloud-charts/save-cloud/templates/preprocessor-configmap.yaml @@ -9,3 +9,4 @@ data: server.shutdown=graceful management.endpoints.web.exposure.include=* management.server.port={{ .Values.preprocessor.managementPort }} + logging.level.com.saveourtool.save.configs.ServiceAccountTokenHeaderWebClientCustomizer=DEBUG diff --git a/save-cloud-charts/save-cloud/templates/preprocessor-deployment.yaml b/save-cloud-charts/save-cloud/templates/preprocessor-deployment.yaml index fa0d861072..60cbb6eddc 100644 --- a/save-cloud-charts/save-cloud/templates/preprocessor-deployment.yaml +++ b/save-cloud-charts/save-cloud/templates/preprocessor-deployment.yaml @@ -31,6 +31,7 @@ spec: - {{ include "spring-boot.config-volume-mount" . | indent 14 | trim }} - name: repos-storage mountPath: /home/cnb + - {{ include "spring-boot.sa-token-mount" . | indent 14 | trim }} {{- include "spring-boot.management" .Values.preprocessor | nindent 10 }} resources: limits: @@ -44,3 +45,4 @@ spec: # and each pod of preprocessor can `git clone` on its own. emptyDir: sizeLimit: 100Mi + - {{ include "spring-boot.sa-token-volume" (dict "service" .Values.backend) | indent 10 | trim }} diff --git a/save-demo/build.gradle.kts b/save-demo/build.gradle.kts index 3a4e08d2cd..858a3dfae8 100644 --- a/save-demo/build.gradle.kts +++ b/save-demo/build.gradle.kts @@ -30,7 +30,7 @@ dependencies { api(projects.saveCloudCommon) implementation(libs.save.common.jvm) - implementation(libs.spring.cloud.starter.kubernetes.client.config) + implementation(libs.spring.cloud.starter.kubernetes.fabric8.config) api(libs.ktor.client.auth) implementation(libs.ktor.client.core) diff --git a/save-orchestrator-common/build.gradle.kts b/save-orchestrator-common/build.gradle.kts index a3294099a1..f30e590c84 100644 --- a/save-orchestrator-common/build.gradle.kts +++ b/save-orchestrator-common/build.gradle.kts @@ -14,6 +14,7 @@ tasks.withType { dependencies { api(projects.saveCloudCommon) + api(projects.authenticationService) implementation(libs.dockerJava.core) implementation(libs.dockerJava.transport.httpclient5) implementation(libs.kotlinx.serialization.json.jvm) @@ -22,6 +23,7 @@ dependencies { implementation(libs.zip4j) implementation(libs.fabric8.kubernetes.client) implementation(libs.spring.kafka) + implementation(libs.spring.boot.starter.security) testImplementation(projects.testUtils) testImplementation(libs.fabric8.kubernetes.server.mock) testImplementation(libs.testcontainers) diff --git a/save-orchestrator-common/src/main/kotlin/com/saveourtool/save/orchestrator/config/KubernetesServiceAccountWebSecurityConfig.kt b/save-orchestrator-common/src/main/kotlin/com/saveourtool/save/orchestrator/config/KubernetesServiceAccountWebSecurityConfig.kt new file mode 100644 index 0000000000..536da9994b --- /dev/null +++ b/save-orchestrator-common/src/main/kotlin/com/saveourtool/save/orchestrator/config/KubernetesServiceAccountWebSecurityConfig.kt @@ -0,0 +1,17 @@ +package com.saveourtool.save.orchestrator.config + +import com.saveourtool.save.authservice.utils.KubernetesAuthenticationUtils +import org.springframework.boot.autoconfigure.condition.ConditionalOnCloudPlatform +import org.springframework.boot.cloud.CloudPlatform +import org.springframework.context.annotation.Import +import org.springframework.security.config.annotation.method.configuration.EnableReactiveMethodSecurity +import org.springframework.security.config.annotation.web.reactive.EnableWebFluxSecurity + +/** + * Configuration class to set up Spring Security + */ +@EnableWebFluxSecurity +@EnableReactiveMethodSecurity +@Import(KubernetesAuthenticationUtils::class) +@ConditionalOnCloudPlatform(CloudPlatform.KUBERNETES) +class KubernetesServiceAccountWebSecurityConfig diff --git a/save-orchestrator/build.gradle.kts b/save-orchestrator/build.gradle.kts index c5a1c0bcd6..8ef8e91103 100644 --- a/save-orchestrator/build.gradle.kts +++ b/save-orchestrator/build.gradle.kts @@ -23,7 +23,7 @@ dependencies { implementation(libs.commons.compress) implementation(libs.kotlinx.datetime) implementation(libs.zip4j) - implementation(libs.spring.cloud.starter.kubernetes.client.config) + implementation(libs.spring.cloud.starter.kubernetes.fabric8.config) implementation(libs.fabric8.kubernetes.client) implementation(libs.spring.kafka) testImplementation(projects.testUtils) diff --git a/save-orchestrator/src/main/kotlin/com/saveourtool/save/orchestrator/SaveOrchestrator.kt b/save-orchestrator/src/main/kotlin/com/saveourtool/save/orchestrator/SaveOrchestrator.kt index a01191fe57..8983807c44 100644 --- a/save-orchestrator/src/main/kotlin/com/saveourtool/save/orchestrator/SaveOrchestrator.kt +++ b/save-orchestrator/src/main/kotlin/com/saveourtool/save/orchestrator/SaveOrchestrator.kt @@ -1,13 +1,16 @@ package com.saveourtool.save.orchestrator +import com.saveourtool.save.authservice.config.SecurityWebClientCustomizers import org.springframework.boot.SpringApplication import org.springframework.boot.autoconfigure.SpringBootApplication +import org.springframework.context.annotation.Import /** * An entrypoint for spring boot for save-orchestrator */ @SpringBootApplication -open class SaveOrchestrator +@Import(SecurityWebClientCustomizers::class) +class SaveOrchestrator fun main(args: Array) { SpringApplication.run(SaveOrchestrator::class.java, *args) diff --git a/save-orchestrator/src/main/kotlin/com/saveourtool/save/orchestrator/service/BackendOrchestratorAgentService.kt b/save-orchestrator/src/main/kotlin/com/saveourtool/save/orchestrator/service/BackendOrchestratorAgentService.kt index 021c263c2c..fac8ae8d28 100644 --- a/save-orchestrator/src/main/kotlin/com/saveourtool/save/orchestrator/service/BackendOrchestratorAgentService.kt +++ b/save-orchestrator/src/main/kotlin/com/saveourtool/save/orchestrator/service/BackendOrchestratorAgentService.kt @@ -11,10 +11,11 @@ import com.saveourtool.save.execution.ExecutionStatus import com.saveourtool.save.execution.ExecutionUpdateDto import com.saveourtool.save.spring.utils.applyAll import com.saveourtool.save.utils.* + import org.slf4j.Logger import org.springframework.beans.factory.annotation.Value import org.springframework.boot.web.reactive.function.client.WebClientCustomizer - +import org.springframework.http.ResponseEntity import org.springframework.stereotype.Component import org.springframework.web.reactive.function.client.WebClient import org.springframework.web.reactive.function.client.bodyToMono diff --git a/save-preprocessor/build.gradle.kts b/save-preprocessor/build.gradle.kts index d8d8d35b25..4dc080bad7 100644 --- a/save-preprocessor/build.gradle.kts +++ b/save-preprocessor/build.gradle.kts @@ -8,6 +8,7 @@ plugins { dependencies { implementation(projects.saveCloudCommon) + implementation(projects.authenticationService) testImplementation(projects.testUtils) implementation(libs.save.common.jvm) implementation(libs.save.core.jvm) { diff --git a/save-preprocessor/src/main/kotlin/com/saveourtool/save/preprocessor/SavePreprocessor.kt b/save-preprocessor/src/main/kotlin/com/saveourtool/save/preprocessor/SavePreprocessor.kt index a99c6b4dac..beeda260b2 100644 --- a/save-preprocessor/src/main/kotlin/com/saveourtool/save/preprocessor/SavePreprocessor.kt +++ b/save-preprocessor/src/main/kotlin/com/saveourtool/save/preprocessor/SavePreprocessor.kt @@ -1,17 +1,28 @@ package com.saveourtool.save.preprocessor +import com.saveourtool.save.authservice.config.SecurityWebClientCustomizers import com.saveourtool.save.preprocessor.config.ConfigProperties + import org.springframework.boot.SpringApplication +import org.springframework.boot.actuate.autoconfigure.security.reactive.ReactiveManagementWebSecurityAutoConfiguration import org.springframework.boot.autoconfigure.SpringBootApplication +import org.springframework.boot.autoconfigure.security.reactive.ReactiveSecurityAutoConfiguration +import org.springframework.boot.autoconfigure.security.reactive.ReactiveUserDetailsServiceAutoConfiguration import org.springframework.boot.context.properties.EnableConfigurationProperties +import org.springframework.context.annotation.Import import org.springframework.web.reactive.config.EnableWebFlux /** * An entrypoint for spring for save-preprocessor */ -@SpringBootApplication +@SpringBootApplication(exclude = [ + ReactiveSecurityAutoConfiguration::class, + ReactiveUserDetailsServiceAutoConfiguration::class, + ReactiveManagementWebSecurityAutoConfiguration::class, +]) @EnableWebFlux @EnableConfigurationProperties(ConfigProperties::class) +@Import(SecurityWebClientCustomizers::class) class SavePreprocessor fun main(args: Array) { diff --git a/save-sandbox/build.gradle.kts b/save-sandbox/build.gradle.kts index b31de4f1a6..dc7aebecd6 100644 --- a/save-sandbox/build.gradle.kts +++ b/save-sandbox/build.gradle.kts @@ -27,7 +27,7 @@ tasks.withType { dependencies { implementation(projects.saveOrchestratorCommon) implementation(libs.zip4j) - implementation(libs.spring.cloud.starter.kubernetes.client.config) + implementation(libs.spring.cloud.starter.kubernetes.fabric8.config) implementation(libs.hibernate.jpa21.api) implementation(libs.save.plugins.warn.jvm) implementation(projects.authenticationService) diff --git a/save-sandbox/src/main/kotlin/com/saveourtool/save/sandbox/SaveSandbox.kt b/save-sandbox/src/main/kotlin/com/saveourtool/save/sandbox/SaveSandbox.kt index 023e03971b..493fc033f6 100644 --- a/save-sandbox/src/main/kotlin/com/saveourtool/save/sandbox/SaveSandbox.kt +++ b/save-sandbox/src/main/kotlin/com/saveourtool/save/sandbox/SaveSandbox.kt @@ -1,7 +1,8 @@ package com.saveourtool.save.sandbox +import com.saveourtool.save.authservice.config.SecurityWebClientCustomizers +import com.saveourtool.save.orchestrator.config.ConfigProperties import com.saveourtool.save.s3.DefaultS3Configuration -import com.saveourtool.save.sandbox.config.ConfigProperties import org.springframework.boot.SpringApplication import org.springframework.boot.autoconfigure.SpringBootApplication import org.springframework.boot.autoconfigure.domain.EntityScan @@ -13,7 +14,7 @@ import org.springframework.context.annotation.Import */ @SpringBootApplication @EnableConfigurationProperties(ConfigProperties::class) -@Import(DefaultS3Configuration::class) +@Import(SecurityWebClientCustomizers::class, DefaultS3Configuration::class) @EntityScan( "com.saveourtool.save.entities", "com.saveourtool.save.sandbox.entity",