Skip to content

Commit

Permalink
Added vault context resolver
Browse files Browse the repository at this point in the history
  • Loading branch information
sksamuel committed May 27, 2023
1 parent e09e79f commit 362608f
Show file tree
Hide file tree
Showing 6 changed files with 203 additions and 5 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -57,7 +57,7 @@ class AwsOps(private val client: AWSSecretsManager) {
} else {
val map = runCatching { Json.Default.decodeFromString<Map<String, String>>(secret) }.getOrElse { emptyMap() }
map[index]?.valid()
?: ConfigFailure.ResolverError(
?: ConfigFailure.ResolverFailure(
"Index '$index' not present in AWS secret '${result.name}'. Present keys are ${map.keys.joinToString(",")}"
).invalid()
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -61,10 +61,12 @@ sealed interface ConfigFailure {
"Could not detect parser for file extension '.$file' - available parsers are ${map.keys.joinToString(", ")}"
}

@Deprecated("Switching to resolvers")
data class PreprocessorWarning(val message: String) : ConfigFailure {
override fun description(): String = message
}

@Deprecated("Switching to resolvers")
data class PreprocessorFailure(val message: String, val t: Throwable) : ConfigFailure {
override fun description(): String =
message + System.lineSeparator() + t.message + System.lineSeparator() + t.stackTraceToString()
Expand Down Expand Up @@ -115,10 +117,14 @@ sealed interface ConfigFailure {
override fun description(): String = failures.map { it.description() }.list.joinToString("\n\n")
}

data class ResolverError(val message: String) : ConfigFailure {
data class ResolverFailure(val message: String) : ConfigFailure {
override fun description(): String = message
}

data class ResolverException(val message: String, val throwable: Throwable) : ConfigFailure {
override fun description(): String = message + "\n" + throwable
}

data class InvalidDiscriminatorField(val kclass: KClass<*>, val field: String) : ConfigFailure {
override fun description(): String = "Invalid discriminator field to select sealed subtype. Must specify `$field` to be a valid subtype of `${kclass.java.name}`."
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -81,14 +81,15 @@ class ConfigLoaderBuilder private constructor() {
}

/**
* Returns a [ConfigLoaderBuilder] with all defaults applied.
* Returns a [ConfigLoaderBuilder] with all defaults applied, using resolvers in place of preprocessors.
*
* This means that the default [Decoder]s, [Resolver]s, [ParameterMapper]s, [PropertySource]s,
* and [Parser]s are all registered.
*
* If you wish to avoid adding defaults, for example to avoid certain decoders or sources, then
* use [empty] to obtain an empty ConfigLoaderBuilder and call the various addDefault methods manually.
*/
@ExperimentalHoplite
fun create(): ConfigLoaderBuilder {
return empty()
.addDefaultDecoders()
Expand Down Expand Up @@ -163,10 +164,14 @@ class ConfigLoaderBuilder private constructor() {
* Adds the given [Resolver]s to the end of the resolvers list.
*/
fun addResolvers(resolvers: Iterable<Resolver>): ConfigLoaderBuilder = apply {
require(preprocessors.isEmpty()) { "Preprocessors cannot be used with resolvers. Preprocessors will be removed in Hoplite 3.0" }
require(preprocessors.isEmpty()) { "Preprocessors cannot be used alongside resolvers. Call removePreprocessors() before adding any resolver. Preprocessors will be removed in Hoplite 3.0" }
this.resolvers.addAll(resolvers)
}

fun removePreprocessors() = apply {
preprocessors.clear()
}

/**
* Adds the given [Resolver]s to the end of the resolvers list.
* Adding a resolver removes all preprocessors as the two do not work together.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -80,7 +80,7 @@ abstract class ContextResolver : Resolver {
return replacement.flatMap {
when {
it == null && context.contextResolverMode == ContextResolverMode.Silent -> node.valid()
it == null -> ConfigFailure.ResolverError("Could not resolve '$path'").invalid()
it == null -> ConfigFailure.ResolverFailure("Could not resolve '$path'").invalid()
else -> node.copy(value = node.value.replaceRange(result.range, it)).valid()
}
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
package com.sksamuel.hoplite.vault

import com.sksamuel.hoplite.ConfigFailure
import com.sksamuel.hoplite.ConfigResult
import com.sksamuel.hoplite.DecoderContext
import com.sksamuel.hoplite.Node
import com.sksamuel.hoplite.Reporter
import com.sksamuel.hoplite.StringNode
import com.sksamuel.hoplite.fp.invalid
import com.sksamuel.hoplite.fp.valid
import com.sksamuel.hoplite.resolver.ContextResolver
import org.springframework.vault.core.VaultKeyValueOperationsSupport
import org.springframework.vault.core.VaultTemplate

class VaultContextResolver(
private val createClient: () -> VaultTemplate,
private val report: Boolean = false,
private val apiVersion: VaultKeyValueOperationsSupport.KeyValueBackend = VaultKeyValueOperationsSupport.KeyValueBackend.KV_2
) : ContextResolver() {

private val client by lazy { createClient() }

override val contextKey: String = "vault"
override val default: Boolean = false

private val tokenRegex = "(.+)\\s+(.+)".toRegex()

override fun lookup(path: String, node: StringNode, root: Node, context: DecoderContext): ConfigResult<String?> {
return when (val match = tokenRegex.matchEntire(path)) {
null -> ConfigFailure.ResolverFailure("Must specify vault key at '${path}'").invalid()
else -> fetchSecret(match.groupValues[1], match.groupValues[2], context.reporter)
}
}

private fun fetchSecret(
path: String,
key: String,
reporter: Reporter,
): ConfigResult<String> = runCatching {

val paths = path.split("/")
if (paths.size < 2) return ConfigFailure.ResolverFailure("Invalid vault path '$path'").invalid()

val ops = client.opsForKeyValue(paths[0], apiVersion)
val pathSecret = ops.get(paths.drop(1).joinToString("/"))
?: return ConfigFailure.ResolverFailure("Vault path '$path' not found").invalid()

if (report) {
reporter.report(
"Vault Lookups",
mapOf("Path" to path, "Key" to key, "Lease Id" to pathSecret.leaseId)
)
}

val data = pathSecret.data
if (data == null) {
ConfigFailure.ResolverFailure("No data at path '$path' in Vault").invalid()
} else {
val value = data[key]
value?.toString()?.valid()
?: ConfigFailure.ResolverFailure("Vault key '$key' not found in path '$path'").invalid()
}
}.getOrElse {
ConfigFailure.ResolverException("Failed loading secret '$path $key' from Vault", it).invalid()
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,121 @@
package com.sksamuel.hoplite.vault

import com.sksamuel.hoplite.ConfigException
import com.sksamuel.hoplite.ConfigLoaderBuilder
import com.sksamuel.hoplite.parsers.PropsPropertySource
import io.kotest.assertions.throwables.shouldThrow
import io.kotest.core.extensions.install
import io.kotest.core.spec.style.FunSpec
import io.kotest.extensions.testcontainers.TestContainerExtension
import io.kotest.matchers.shouldBe
import io.kotest.matchers.string.shouldContain
import org.springframework.vault.authentication.TokenAuthentication
import org.springframework.vault.client.VaultEndpoint
import org.springframework.vault.core.VaultTemplate
import org.testcontainers.utility.DockerImageName
import org.testcontainers.vault.VaultContainer
import java.util.Properties
import kotlin.collections.set

class VaultContextResolverTest : FunSpec({

val image = DockerImageName.parse("vault:1.9.7")
val container = VaultContainer(image)
.withVaultToken("my-token")
.withSecretInVault("secret/testing", "secret1=topsecret!")
.withSecretInVault("secret/testing/nested", "secret2=bottomsecret!")

val ext = TestContainerExtension(container)
install(ext)

test("placeholder should be detected and used") {

val props = Properties()
props["foo"] = "vault://secret/testing secret1"
props["bar"] = "vault://secret/testing/nested secret2"

val endpoint = VaultEndpoint.create(container.host, container.firstMappedPort)
endpoint.scheme = "http" // test container doesn't use https

val template = VaultTemplate(endpoint, TokenAuthentication("my-token"))

val config = ConfigLoaderBuilder.default()
.removePreprocessors()
.addResolver(VaultContextResolver({ template }))
.addPropertySource(PropsPropertySource(props))
.build()
.loadConfigOrThrow<ConfigHolder>()

config.foo.shouldBe("topsecret!")
config.bar.shouldBe("bottomsecret!")
}

test("invalid vault placeholder error") {

val props = Properties()
props["foo"] = "vault://secret qwerty"
props["bar"] = "vault://secret/testing/nested"

val endpoint = VaultEndpoint.create(container.host, container.firstMappedPort)
endpoint.scheme = "http" // test container doesn't use https

val template = VaultTemplate(endpoint, TokenAuthentication("my-token"))

shouldThrow<ConfigException> {
ConfigLoaderBuilder.default()
.removePreprocessors()
.addResolver(VaultContextResolver({ template }))
.addPropertySource(PropsPropertySource(props))
.build()
.loadConfigOrThrow<ConfigHolder>()
}.message
.shouldContain("Invalid vault path 'secret'")
.shouldContain("Must specify vault key at 'secret/testing/nested'")
}

test("missing keys error message") {

val props = Properties()
props["foo"] = "vault://secret/testing a"
props["bar"] = "vault://secret/testing b"

val endpoint = VaultEndpoint.create(container.host, container.firstMappedPort)
endpoint.scheme = "http" // test container doesn't use https

val template = VaultTemplate(endpoint, TokenAuthentication("my-token"))

shouldThrow<ConfigException> {
ConfigLoaderBuilder.default()
.removePreprocessors()
.addResolver(VaultContextResolver({ template }))
.addPropertySource(PropsPropertySource(props))
.build()
.loadConfigOrThrow<ConfigHolder>()
}.message
.shouldContain("Vault key 'a' not found in path 'secret/testing'")
.shouldContain("Vault key 'b' not found in path 'secret/testing'")
}

test("missing path error message") {

val props = Properties()
props["foo"] = "vault://secret/foo secret1"
props["bar"] = "vault://secret/bar secret2"

val endpoint = VaultEndpoint.create(container.host, container.firstMappedPort)
endpoint.scheme = "http" // test container doesn't use https

val template = VaultTemplate(endpoint, TokenAuthentication("my-token"))

shouldThrow<ConfigException> {
ConfigLoaderBuilder.default()
.removePreprocessors()
.addResolver(VaultContextResolver({ template }))
.addPropertySource(PropsPropertySource(props))
.build()
.loadConfigOrThrow<ConfigHolder>()
}.message
.shouldContain("Vault path 'secret/foo' not found")
.shouldContain("Vault path 'secret/bar' not found")
}
})

0 comments on commit 362608f

Please sign in to comment.