├── project ├── build.properties ├── .sbtopts ├── plugins.sbt ├── Versioning.scala ├── Dependencies.scala └── Settings.scala ├── secret-provider └── src │ ├── test │ ├── resources │ │ ├── k8s-token │ │ ├── mockito-extensions │ │ │ └── org.mockito.plugins.MockMaker │ │ ├── keystore.jks │ │ ├── log4j.properties │ │ └── cert.pem │ ├── scala │ │ └── io │ │ │ └── lenses │ │ │ └── connect │ │ │ └── secrets │ │ │ ├── TmpDirUtil.scala │ │ │ ├── async │ │ │ └── AsyncFunctionLoopTest.scala │ │ │ ├── utils │ │ │ └── ConfigDataBuilderTest.scala │ │ │ ├── connect │ │ │ └── EncodingTest.scala │ │ │ ├── providers │ │ │ ├── AesDecodingTestHelper.scala │ │ │ ├── Aes256DecodingHelperTest.scala │ │ │ ├── ENVSecretProviderTest.scala │ │ │ ├── DecodeTest.scala │ │ │ ├── VaultHelperTest.scala │ │ │ └── Aes256DecodingProviderTest.scala │ │ │ ├── cache │ │ │ └── TtlTest.scala │ │ │ ├── io │ │ │ └── FileWriterOnceTest.scala │ │ │ └── config │ │ │ └── SecretTypeConfigTest.scala │ └── java │ │ └── io │ │ └── lenses │ │ └── connect │ │ └── secrets │ │ └── vault │ │ ├── VaultTestUtils.java │ │ └── MockVault.java │ ├── main │ ├── scala │ │ └── io │ │ │ └── lenses │ │ │ └── connect │ │ │ └── secrets │ │ │ ├── providers │ │ │ ├── SecretHelper.scala │ │ │ ├── AWSSecretProvider.scala │ │ │ ├── SecretProvider.scala │ │ │ ├── Aes256DecodingProvider.scala │ │ │ ├── VaultSecretProvider.scala │ │ │ ├── ENVSecretProvider.scala │ │ │ ├── Aes256DecodingHelper.scala │ │ │ ├── AzureHelper.scala │ │ │ ├── AzureSecretProvider.scala │ │ │ ├── AWSHelper.scala │ │ │ └── VaultHelper.scala │ │ │ ├── utils │ │ │ ├── ExceptionUtils.scala │ │ │ ├── ConfigDataBuilder.scala │ │ │ ├── WithRetry.scala │ │ │ └── EncodingAndId.scala │ │ │ ├── config │ │ │ ├── FileWriterOptions.scala │ │ │ ├── ENVProviderConfig.scala │ │ │ ├── AbstractConfigExtensions.scala │ │ │ ├── Aes256ProviderConfig.scala │ │ │ ├── AWSCredentials.scala │ │ │ ├── SecretTypeConfig.scala │ │ │ ├── AzureProviderConfig.scala │ │ │ ├── AWSProviderSettings.scala │ │ │ ├── AzureProviderSettings.scala │ │ │ ├── AWSProviderConfig.scala │ │ │ ├── VaultProviderSettings.scala │ │ │ └── VaultProviderConfig.scala │ │ │ ├── cache │ │ │ ├── TtlCache.scala │ │ │ └── ValueWithTtl.scala │ │ │ ├── async │ │ │ └── AsyncFunctionLoop.scala │ │ │ ├── io │ │ │ └── FileWriter.scala │ │ │ └── package.scala │ └── resources │ │ └── META-INF │ │ └── services │ │ └── org.apache.kafka.common.config.provider.ConfigProvider │ └── it │ ├── scala │ └── io │ │ └── lenses │ │ └── connect │ │ └── secrets │ │ ├── testcontainers │ │ ├── connect │ │ │ ├── ConfigProperty.scala │ │ │ ├── ConnectorConfiguration.scala │ │ │ ├── ConfigProviderConfig.scala │ │ │ └── KafkaConnectClient.scala │ │ ├── SingleContainer.scala │ │ ├── testcontainers.scala │ │ ├── VaultContainer.scala │ │ ├── KafkaConnectContainer.scala │ │ └── scalatest │ │ │ └── SecretProviderContainerPerSuite.scala │ │ └── integration │ │ ├── SecretProviderRestResponse.scala │ │ ├── SecretProviderRestResponseTest.scala │ │ └── VaultSecretProviderIT.scala │ └── resources │ └── logback.xml ├── .sbtopts ├── .gitignore ├── .scalafmt.conf ├── .github └── workflows │ ├── publish.yml │ └── build.yml ├── test-sink └── src │ └── main │ └── scala │ └── io │ └── lenses │ └── connect │ └── secrets │ └── test │ └── vault │ ├── TestSinkTask.scala │ ├── VaultState.scala │ ├── TestSinkConnector.scala │ └── VaultStateValidator.scala ├── README.md └── LICENSE /project/build.properties: -------------------------------------------------------------------------------- 1 | sbt.version=1.8.2 -------------------------------------------------------------------------------- /secret-provider/src/test/resources/k8s-token: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /.sbtopts: -------------------------------------------------------------------------------- 1 | -J-Xmx4G 2 | -J-Xms1024M 3 | -J-Xss2M 4 | -J-XX:MaxMetaspaceSize=2G -------------------------------------------------------------------------------- /project/.sbtopts: -------------------------------------------------------------------------------- 1 | -J-Xmx4G 2 | -J-Xms1024M 3 | -J-Xss2M 4 | -J-XX:MaxMetaspaceSize=2G -------------------------------------------------------------------------------- /secret-provider/src/test/resources/mockito-extensions/org.mockito.plugins.MockMaker: -------------------------------------------------------------------------------- 1 | mock-maker-inline -------------------------------------------------------------------------------- /project/plugins.sbt: -------------------------------------------------------------------------------- 1 | addSbtPlugin("com.eed3si9n" % "sbt-assembly" % "2.1.4") 2 | 3 | addSbtPlugin("org.scalameta" % "sbt-scalafmt" % "2.5.0") 4 | -------------------------------------------------------------------------------- /secret-provider/src/test/resources/keystore.jks: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lensesio/secret-provider/HEAD/secret-provider/src/test/resources/keystore.jks -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.class 2 | *.log 3 | /.bloop/ 4 | /.gradle/ 5 | /.idea/ 6 | /.metals/ 7 | /.vscode/ 8 | /out/ 9 | /.settings/ 10 | /bin/ 11 | /build/ 12 | /.classpath 13 | /.project 14 | target/ 15 | /.bsp/ 16 | .DS_Store 17 | -------------------------------------------------------------------------------- /secret-provider/src/main/scala/io/lenses/connect/secrets/providers/SecretHelper.scala: -------------------------------------------------------------------------------- 1 | package io.lenses.connect.secrets.providers 2 | 3 | import io.lenses.connect.secrets.cache.ValueWithTtl 4 | 5 | trait SecretHelper { 6 | 7 | def lookup(path: String): Either[Throwable, ValueWithTtl[Map[String, String]]] 8 | 9 | def close(): Unit = () 10 | } 11 | -------------------------------------------------------------------------------- /secret-provider/src/test/scala/io/lenses/connect/secrets/TmpDirUtil.scala: -------------------------------------------------------------------------------- 1 | package io.lenses.connect.secrets 2 | 3 | import java.nio.file.FileSystems 4 | 5 | object TmpDirUtil { 6 | 7 | val separator: String = FileSystems.getDefault.getSeparator 8 | 9 | def getTempDir: String = 10 | System.getProperty("java.io.tmpdir").stripSuffix(separator) 11 | } 12 | -------------------------------------------------------------------------------- /secret-provider/src/main/resources/META-INF/services/org.apache.kafka.common.config.provider.ConfigProvider: -------------------------------------------------------------------------------- 1 | io.lenses.connect.secrets.providers.AzureSecretProvider 2 | io.lenses.connect.secrets.providers.VaultSecretProvider 3 | io.lenses.connect.secrets.providers.AWSSecretProvider 4 | io.lenses.connect.secrets.providers.Aes256DecodingProvider 5 | io.lenses.connect.secrets.providers.ENVSecretProvider -------------------------------------------------------------------------------- /project/Versioning.scala: -------------------------------------------------------------------------------- 1 | import java.util.regex.Matcher 2 | import java.util.regex.Pattern 3 | 4 | case class SemanticVersioning(version: String) { 5 | 6 | private val versionPattern: Pattern = 7 | Pattern.compile("([1-9]\\d*)\\.(\\d+)\\.(\\d+)(?:-([a-zA-Z0-9]+))?") 8 | private val matcher: Matcher = versionPattern.matcher(version) 9 | 10 | def majorMinor = { 11 | require(matcher.matches()) 12 | s"${matcher.group(1)}.${matcher.group(2)}" 13 | } 14 | 15 | } 16 | -------------------------------------------------------------------------------- /secret-provider/src/test/resources/log4j.properties: -------------------------------------------------------------------------------- 1 | # 2 | # /* 3 | # * Copyright 2017-2020 Lenses.io Ltd 4 | # */ 5 | # 6 | 7 | # suppress inspection "UnusedProperty" for whole file 8 | log4j.rootLogger=INFO,stdout 9 | 10 | #stdout 11 | log4j.appender.stdout=org.apache.log4j.ConsoleAppender 12 | log4j.appender.stdout.layout=org.apache.log4j.PatternLayout 13 | log4j.appender.stdout.layout.conversionPattern=%d{ISO8601} %-5p [%t] [%c] [%M:%L] %m%n 14 | 15 | #Turn down cassandra logging 16 | log4j.logger.com.jcabi.manifests=ERROR -------------------------------------------------------------------------------- /secret-provider/src/main/scala/io/lenses/connect/secrets/utils/ExceptionUtils.scala: -------------------------------------------------------------------------------- 1 | package io.lenses.connect.secrets.utils 2 | 3 | import org.apache.kafka.connect.errors.ConnectException 4 | 5 | object ExceptionUtils { 6 | 7 | def failWithEx[T](error: String, ex: Throwable): Either[Throwable, T] = 8 | failWithEx(error, Some(ex)) 9 | 10 | def failWithEx[T]( 11 | error: String, 12 | ex: Option[Throwable] = Option.empty, 13 | ): Either[Throwable, T] = 14 | Left( 15 | new ConnectException(error, ex.orNull), 16 | ) 17 | } 18 | -------------------------------------------------------------------------------- /secret-provider/src/main/scala/io/lenses/connect/secrets/utils/ConfigDataBuilder.scala: -------------------------------------------------------------------------------- 1 | package io.lenses.connect.secrets.utils 2 | 3 | import io.lenses.connect.secrets.cache.ValueWithTtl 4 | import org.apache.kafka.common.config.ConfigData 5 | 6 | import java.time.Clock 7 | import scala.jdk.CollectionConverters.MapHasAsJava 8 | 9 | object ConfigDataBuilder { 10 | 11 | def apply(valueWithTtl: ValueWithTtl[Map[String, String]])(implicit clock: Clock) = 12 | new ConfigData( 13 | valueWithTtl.value.asJava, 14 | valueWithTtl.ttlAndExpiry.map(t => Long.box(t.ttlRemaining.toMillis)).orNull, 15 | ) 16 | 17 | } 18 | -------------------------------------------------------------------------------- /secret-provider/src/it/scala/io/lenses/connect/secrets/testcontainers/connect/ConfigProperty.scala: -------------------------------------------------------------------------------- 1 | package io.lenses.connect.secrets.testcontainers.connect 2 | 3 | trait CnfVal[T <: Any] { 4 | def get: T 5 | } 6 | 7 | case class StringCnfVal(s: String) extends CnfVal[String] { 8 | override def get: String = s 9 | } 10 | 11 | case class IntCnfVal(s: Int) extends CnfVal[Int] { 12 | override def get: Int = s 13 | } 14 | 15 | case class LongCnfVal(s: Long) extends CnfVal[Long] { 16 | override def get: Long = s 17 | } 18 | 19 | case class BooleanCnfVal(s: Boolean) extends CnfVal[Boolean] { 20 | override def get: Boolean = s 21 | } 22 | -------------------------------------------------------------------------------- /secret-provider/src/it/scala/io/lenses/connect/secrets/testcontainers/SingleContainer.scala: -------------------------------------------------------------------------------- 1 | package io.lenses.connect.secrets.testcontainers 2 | 3 | import org.testcontainers.containers.GenericContainer 4 | import org.testcontainers.containers.Network 5 | 6 | abstract class SingleContainer[T <: GenericContainer[_]] { 7 | 8 | def container: T 9 | 10 | def start(): Unit = container.start() 11 | 12 | def stop(): Unit = container.stop() 13 | 14 | def withNetwork(network: Network): this.type = { 15 | container.withNetwork(network) 16 | this 17 | } 18 | 19 | def withExposedPorts(ports: Integer*): this.type = { 20 | container.withExposedPorts(ports: _*) 21 | this 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /secret-provider/src/main/scala/io/lenses/connect/secrets/config/FileWriterOptions.scala: -------------------------------------------------------------------------------- 1 | package io.lenses.connect.secrets.config 2 | 3 | import io.lenses.connect.secrets.connect.FILE_DIR 4 | import io.lenses.connect.secrets.connect.WRITE_FILES 5 | import io.lenses.connect.secrets.io.FileWriterOnce 6 | import org.apache.kafka.common.config.AbstractConfig 7 | 8 | import java.nio.file.Paths 9 | 10 | object FileWriterOptions { 11 | def apply(config: AbstractConfig): Option[FileWriterOptions] = 12 | Option.when(config.getBoolean(WRITE_FILES)) { 13 | FileWriterOptions(config.getString(FILE_DIR)) 14 | } 15 | } 16 | 17 | case class FileWriterOptions( 18 | fileDir: String, 19 | ) { 20 | def createFileWriter(): FileWriterOnce = 21 | new FileWriterOnce(Paths.get(fileDir, "secrets")) 22 | 23 | } 24 | -------------------------------------------------------------------------------- /secret-provider/src/main/scala/io/lenses/connect/secrets/utils/WithRetry.scala: -------------------------------------------------------------------------------- 1 | /* 2 | * 3 | * * Copyright 2017-2020 Lenses.io Ltd 4 | * 5 | */ 6 | package io.lenses.connect.secrets.utils 7 | 8 | import scala.annotation.tailrec 9 | import scala.concurrent.duration.FiniteDuration 10 | import scala.util.Failure 11 | import scala.util.Success 12 | import scala.util.Try 13 | 14 | trait WithRetry { 15 | 16 | @tailrec 17 | protected final def withRetry[T]( 18 | retry: Int = 5, 19 | interval: Option[FiniteDuration], 20 | )(thunk: => T, 21 | ): T = 22 | Try { 23 | thunk 24 | } match { 25 | case Failure(t) => 26 | if (retry == 0) throw t 27 | interval.foreach(sleepValue => Thread.sleep(sleepValue.toMillis)) 28 | withRetry(retry - 1, interval)(thunk) 29 | case Success(value) => value 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /secret-provider/src/it/scala/io/lenses/connect/secrets/testcontainers/connect/ConnectorConfiguration.scala: -------------------------------------------------------------------------------- 1 | package io.lenses.connect.secrets.testcontainers.connect 2 | 3 | import org.json4s.DefaultFormats 4 | import org.json4s.native.Serialization 5 | 6 | case class ConnectorConfiguration( 7 | name: String, 8 | config: Map[String, CnfVal[_]], 9 | ) { 10 | 11 | implicit val formats: DefaultFormats.type = DefaultFormats 12 | 13 | def toJson(): String = { 14 | val mergedConfigMap = config + ("tasks.max" -> IntCnfVal(1)) 15 | Serialization.write( 16 | Map[String, Any]( 17 | "name" -> name, 18 | "config" -> transformConfigMap(mergedConfigMap), 19 | ), 20 | ) 21 | } 22 | 23 | private def transformConfigMap( 24 | originalMap: Map[String, CnfVal[_]], 25 | ): Map[String, Any] = originalMap.view.mapValues(_.get).toMap 26 | 27 | } 28 | -------------------------------------------------------------------------------- /secret-provider/src/test/scala/io/lenses/connect/secrets/async/AsyncFunctionLoopTest.scala: -------------------------------------------------------------------------------- 1 | /* 2 | * 3 | * * Copyright 2017-2020 Lenses.io Ltd 4 | * 5 | */ 6 | 7 | package io.lenses.connect.secrets.async 8 | 9 | import org.scalatest.funsuite.AnyFunSuite 10 | import org.scalatest.matchers.should.Matchers 11 | 12 | import java.util.concurrent.CountDownLatch 13 | import java.util.concurrent.TimeUnit 14 | import scala.concurrent.duration.DurationInt 15 | 16 | class AsyncFunctionLoopTest extends AnyFunSuite with Matchers { 17 | test("it loops 5 times in 10 seconds with 2s delay") { 18 | val countDownLatch = new CountDownLatch(5) 19 | val looper = new AsyncFunctionLoop(2.seconds, "test")( 20 | countDownLatch.countDown(), 21 | ) 22 | looper.start() 23 | countDownLatch.await(11000, TimeUnit.MILLISECONDS) shouldBe true 24 | looper.close() 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /secret-provider/src/main/scala/io/lenses/connect/secrets/config/ENVProviderConfig.scala: -------------------------------------------------------------------------------- 1 | /* 2 | * 3 | * * Copyright 2017-2020 Lenses.io Ltd 4 | * 5 | */ 6 | 7 | package io.lenses.connect.secrets.config 8 | 9 | import io.lenses.connect.secrets.connect.FILE_DIR 10 | import io.lenses.connect.secrets.connect.FILE_DIR_DESC 11 | import org.apache.kafka.common.config.ConfigDef.Importance 12 | import org.apache.kafka.common.config.ConfigDef.Type 13 | import org.apache.kafka.common.config.AbstractConfig 14 | import org.apache.kafka.common.config.ConfigDef 15 | 16 | import java.util 17 | 18 | object ENVProviderConfig { 19 | val config = new ConfigDef().define( 20 | FILE_DIR, 21 | Type.STRING, 22 | "", 23 | Importance.MEDIUM, 24 | FILE_DIR_DESC, 25 | ) 26 | } 27 | 28 | case class ENVProviderConfig(props: util.Map[String, _]) extends AbstractConfig(ENVProviderConfig.config, props) 29 | -------------------------------------------------------------------------------- /secret-provider/src/test/scala/io/lenses/connect/secrets/utils/ConfigDataBuilderTest.scala: -------------------------------------------------------------------------------- 1 | package io.lenses.connect.secrets.utils 2 | 3 | import io.lenses.connect.secrets.cache.ValueWithTtl 4 | import org.scalatest.funsuite.AnyFunSuite 5 | import org.scalatest.matchers.should.Matchers 6 | 7 | import java.time.Clock 8 | import java.time.Duration 9 | import java.time.temporal.ChronoUnit._ 10 | class ConfigDataBuilderTest extends AnyFunSuite with Matchers { 11 | 12 | implicit val clock = Clock.systemDefaultZone() 13 | 14 | test("Converts to the expected java structure") { 15 | val map = ValueWithTtl( 16 | Option(Duration.of(1, MINUTES)), 17 | Option.empty[Duration], 18 | Map[String, String]( 19 | "secret" -> "12345", 20 | ), 21 | ) 22 | val ret = ConfigDataBuilder(map) 23 | ret.ttl().longValue() should be > 0L 24 | ret.data().get("secret") should be("12345") 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /secret-provider/src/it/scala/io/lenses/connect/secrets/testcontainers/connect/ConfigProviderConfig.scala: -------------------------------------------------------------------------------- 1 | package io.lenses.connect.secrets.testcontainers.connect 2 | 3 | case class ConfigProviders(configProviders: Seq[ConfigProvider]) { 4 | def toEnvMap: Map[String, String] = { 5 | val providersEnv = "CONNECT_CONFIG_PROVIDERS" -> configProviders.map(_.name).mkString(",") 6 | (providersEnv +: configProviders.flatMap(_.toEnvMap)).toMap 7 | } 8 | } 9 | 10 | case class ConfigProvider( 11 | name: String, 12 | `class`: String, 13 | props: Map[String, String], 14 | ) { 15 | private val classProp = s"CONNECT_CONFIG_PROVIDERS_${name.toUpperCase}_CLASS" 16 | private val paramPrefix = s"CONNECT_CONFIG_PROVIDERS.${name.toUpperCase}_PARAM_" 17 | 18 | def toEnvMap: Map[String, String] = { 19 | val classEnv = classProp -> `class` 20 | val envs = props.map { 21 | case (pk, pv) => paramPrefix + pk.replace(".", "_").toUpperCase -> pv 22 | } 23 | envs + classEnv 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /secret-provider/src/it/scala/io/lenses/connect/secrets/testcontainers/testcontainers.scala: -------------------------------------------------------------------------------- 1 | package io.lenses.connect.secrets 2 | 3 | import cats.effect.IO 4 | import org.testcontainers.containers.KafkaContainer 5 | import software.amazon.awssdk.core.internal.waiters.{ ResponseOrException => AWSResponseOrException } 6 | package object testcontainers { 7 | 8 | implicit class KafkaContainerOps(kafkaContainer: KafkaContainer) { 9 | val kafkaPort = 9092 10 | 11 | def bootstrapServers: String = 12 | s"PLAINTEXT://${kafkaContainer.getNetworkAliases.get(0)}:$kafkaPort" 13 | } 14 | 15 | implicit class ResponseOrException[R](responseOrException: AWSResponseOrException[R]) { 16 | 17 | def toIO(): IO[R] = IO.fromEither(toEither()) 18 | 19 | def toEither(): Either[Throwable, R] = 20 | Either.cond( 21 | responseOrException.response().isPresent, 22 | responseOrException.response().get(), 23 | responseOrException.exception().get(), 24 | ) 25 | } 26 | 27 | } 28 | -------------------------------------------------------------------------------- /secret-provider/src/main/scala/io/lenses/connect/secrets/config/AbstractConfigExtensions.scala: -------------------------------------------------------------------------------- 1 | /* 2 | * 3 | * * Copyright 2017-2020 Lenses.io Ltd 4 | * 5 | */ 6 | 7 | package io.lenses.connect.secrets.config 8 | 9 | import org.apache.kafka.common.config.AbstractConfig 10 | import org.apache.kafka.common.config.types.Password 11 | import org.apache.kafka.connect.errors.ConnectException 12 | 13 | object AbstractConfigExtensions { 14 | implicit class AbstractConfigExtension(val config: AbstractConfig) extends AnyVal { 15 | def getStringOrThrowOnNull(field: String): String = 16 | Option(config.getString(field)).getOrElse(raiseException(field)) 17 | 18 | def getPasswordOrThrowOnNull(field: String): Password = { 19 | val password = config.getPassword(field) 20 | if (password == null) raiseException(field) 21 | password 22 | } 23 | 24 | private def raiseException(fieldName: String) = throw new ConnectException( 25 | s"$fieldName not set", 26 | ) 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /.scalafmt.conf: -------------------------------------------------------------------------------- 1 | version=2.6.1 2 | maxColumn = 120 3 | preset = IntelliJ 4 | align.preset = most 5 | align.openParenCallSite = true 6 | align.tokens.add = [{code = "="},{code = ":"}] 7 | align.multiline = false 8 | align.arrowEnumeratorGenerator = false 9 | assumeStandardLibraryStripMargin = true 10 | newlines.source=keep 11 | newlines.sometimesBeforeColonInMethodReturnType = false 12 | newlines.alwaysBeforeMultilineDef = false 13 | verticalMultiline.atDefnSite = true 14 | verticalMultiline.excludeDanglingParens = [] 15 | newlines.implicitParamListModifierForce = [before,after] 16 | optIn.breakChainOnFirstMethodDot = false 17 | optIn.configStyleArguments = true 18 | runner.optimizer.forceConfigStyleOnOffset = 120 19 | runner.optimizer.forceConfigStyleMinArgCount = 1 20 | trailingCommas = always 21 | spaces.inImportCurlyBraces = true 22 | 23 | rewrite.rules = [ 24 | PreferCurlyFors, 25 | RedundantBraces, 26 | ExpandImportSelectors, 27 | RedundantParens 28 | ] 29 | rewrite.redundantBraces.generalExpressions = false 30 | 31 | project.git = true 32 | -------------------------------------------------------------------------------- /secret-provider/src/it/scala/io/lenses/connect/secrets/integration/SecretProviderRestResponse.scala: -------------------------------------------------------------------------------- 1 | package io.lenses.connect.secrets.integration 2 | 3 | import io.github.jopenlibs.vault.rest.RestResponse 4 | import org.json4s.DefaultFormats 5 | import org.json4s._ 6 | import org.json4s.native.JsonMethods._ 7 | 8 | case class DataResponse( 9 | data: Map[String, Any], 10 | metadata: Map[String, Any], 11 | ) 12 | case class SecretProviderRestResponse( 13 | requestId: String, 14 | leaseId: String, 15 | renewable: Boolean, 16 | leaseDuration: BigInt, 17 | data: DataResponse, 18 | wrapInfo: Any, // todo what is this? 19 | warnings: Any, // todo 20 | auth: Any, // todo 21 | ) 22 | 23 | case object SecretProviderRestResponse { 24 | 25 | def fromRestResponse(rr: RestResponse) = 26 | fromJson(new String(rr.getBody)) 27 | 28 | def fromJson(json: String) = { 29 | implicit val formats: DefaultFormats.type = DefaultFormats 30 | 31 | parse(json).camelizeKeys.extract[SecretProviderRestResponse] 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /secret-provider/src/main/scala/io/lenses/connect/secrets/utils/EncodingAndId.scala: -------------------------------------------------------------------------------- 1 | /* 2 | * 3 | * * Copyright 2017-2020 Lenses.io Ltd 4 | * 5 | */ 6 | package io.lenses.connect.secrets.utils 7 | 8 | import io.lenses.connect.secrets.connect.Encoding 9 | import io.lenses.connect.secrets.connect.Encoding.Encoding 10 | 11 | case class EncodingAndId(encoding: Option[Encoding], id: Option[String]) 12 | object EncodingAndId { 13 | val Separator = "_" 14 | private val encodingPrioritised = List( 15 | Encoding.UTF8_FILE, 16 | Encoding.BASE64_FILE, 17 | Encoding.BASE64, 18 | Encoding.UTF8, 19 | ) 20 | def from(key: String): EncodingAndId = 21 | Option(key).map(_.trim).filter(_.nonEmpty).fold(EncodingAndId(None, None)) { 22 | value => 23 | val encoding = encodingPrioritised 24 | .map(v => v.toString.toLowerCase() -> v) 25 | .collectFirst { case (v, e) if value.toLowerCase.startsWith(v) => e } 26 | .map(identity) 27 | 28 | val id = encoding.flatMap { e => 29 | val v = value.drop(e.toString.length).trim 30 | if (v.isEmpty) None 31 | else Some(v.drop(1)) 32 | } 33 | EncodingAndId(encoding, id) 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /secret-provider/src/main/scala/io/lenses/connect/secrets/config/Aes256ProviderConfig.scala: -------------------------------------------------------------------------------- 1 | package io.lenses.connect.secrets.config 2 | 3 | import io.lenses.connect.secrets.connect.FILE_DIR 4 | import io.lenses.connect.secrets.connect.FILE_DIR_DESC 5 | import org.apache.kafka.common.config.ConfigDef.Importance 6 | import org.apache.kafka.common.config.ConfigDef.Type 7 | import org.apache.kafka.common.config.AbstractConfig 8 | import org.apache.kafka.common.config.ConfigDef 9 | 10 | import java.util 11 | 12 | object Aes256ProviderConfig { 13 | val SECRET_KEY = "aes256.key" 14 | 15 | val config = new ConfigDef() 16 | .define( 17 | SECRET_KEY, 18 | Type.PASSWORD, 19 | "", 20 | Importance.MEDIUM, 21 | "Key used to decode AES256 encoded values", 22 | ) 23 | .define( 24 | FILE_DIR, 25 | Type.STRING, 26 | "", 27 | Importance.MEDIUM, 28 | FILE_DIR_DESC, 29 | ) 30 | } 31 | 32 | case class Aes256ProviderConfig(props: util.Map[String, _]) extends AbstractConfig(Aes256ProviderConfig.config, props) { 33 | def aes256Key: String = getPassword(Aes256ProviderConfig.SECRET_KEY).value() 34 | def fileWriterOptions: FileWriterOptions = FileWriterOptions(getString(FILE_DIR)) 35 | } 36 | -------------------------------------------------------------------------------- /.github/workflows/publish.yml: -------------------------------------------------------------------------------- 1 | name: Publish 2 | 3 | on: 4 | push: 5 | tags: 6 | - '*' 7 | 8 | jobs: 9 | build: 10 | 11 | runs-on: ubuntu-latest 12 | 13 | steps: 14 | - name: Checkout 15 | uses: actions/checkout@v2 16 | - name: Setup Scala 17 | uses: olafurpg/setup-scala@v11 18 | with: 19 | java-version: openjdk@1.14 20 | - name: Get the tag 21 | id: get_tag 22 | run: echo ::set-output name=VERSION::${GITHUB_REF/refs\/tags\//} 23 | - name: Assembly 24 | run: sbt "project secret-provider" assembly 25 | env: 26 | LENSES_TAG_NAME: ${{ steps.get_tag.outputs.VERSION }} 27 | - name: Copy file 28 | run: cp secret-provider/target/libs/secret-provider-assembly-${{ steps.get_tag.outputs.VERSION }}.jar secret-provider/target/libs/secret-provider-${{ steps.get_tag.outputs.VERSION }}-all.jar 29 | - name: Release to Github 30 | uses: softprops/action-gh-release@v1 31 | with: 32 | files: | 33 | secret-provider/target/libs/secret-provider-${{ steps.get_tag.outputs.VERSION }}-all.jar 34 | env: 35 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 36 | LENSES_TAG_NAME: ${{ steps.get_tag.outputs.VERSION }} 37 | -------------------------------------------------------------------------------- /secret-provider/src/test/scala/io/lenses/connect/secrets/connect/EncodingTest.scala: -------------------------------------------------------------------------------- 1 | package io.lenses.connect.secrets.connect 2 | 3 | import org.scalatest.funsuite.AnyFunSuite 4 | import org.scalatest.matchers.should.Matchers 5 | import org.scalatest.prop.TableDrivenPropertyChecks 6 | 7 | class EncodingTest extends AnyFunSuite with Matchers with TableDrivenPropertyChecks { 8 | 9 | private val validEncodingsWithoutHyphens = Table( 10 | ("input", "expectedResult"), 11 | ("BASE64", Encoding.BASE64), 12 | ("base64", Encoding.BASE64), 13 | ("BASE-64", Encoding.BASE64), 14 | ("BASE-64_FILE", Encoding.BASE64_FILE), 15 | ("base64_file", Encoding.BASE64_FILE), 16 | ("UTF8", Encoding.UTF8), 17 | ("utf8", Encoding.UTF8), 18 | ("UTF-8", Encoding.UTF8), 19 | ("UTF-8_FILE", Encoding.UTF8_FILE), 20 | ("utf8_file", Encoding.UTF8_FILE), 21 | ) 22 | 23 | test("withoutHyphensInsensitiveOpt should recognize valid encodings") { 24 | forAll(validEncodingsWithoutHyphens) { (input, expectedResult) => 25 | Encoding.withoutHyphensInsensitiveOpt(input) should be(Some(expectedResult)) 26 | } 27 | } 28 | 29 | test("withoutHyphensInsensitiveOpt should return None for an invalid input 'UNKNOWN-ENCODING'") { 30 | Encoding.withoutHyphensInsensitiveOpt("UNKNOWN-ENCODING") should be(None) 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /secret-provider/src/main/scala/io/lenses/connect/secrets/cache/TtlCache.scala: -------------------------------------------------------------------------------- 1 | package io.lenses.connect.secrets.cache 2 | 3 | import com.typesafe.scalalogging.LazyLogging 4 | 5 | import java.time.Clock 6 | import scala.collection.concurrent.TrieMap 7 | 8 | class TtlCache[V](fnGetMissingValue: String => Either[Throwable, ValueWithTtl[V]])(implicit clock: Clock) 9 | extends LazyLogging { 10 | 11 | private val cache = TrieMap[String, ValueWithTtl[V]]() 12 | 13 | def cachingWithTtl( 14 | key: String, 15 | fnCondition: V => Boolean = _ => true, 16 | fnFilterReturnKeys: ValueWithTtl[V] => ValueWithTtl[V] = identity, 17 | ): Either[Throwable, ValueWithTtl[V]] = { 18 | get(key, 19 | Seq( 20 | v => v.isAlive, 21 | v => fnCondition(v.value), 22 | ), 23 | ).fold(fnGetMissingValue(key).map(put(key, _)))(Right(_)) 24 | }.map(fnFilterReturnKeys) 25 | 26 | private def get( 27 | key: String, 28 | fnConditions: Seq[ValueWithTtl[V] => Boolean] = Seq(_ => true), 29 | ): Option[ValueWithTtl[V]] = 30 | cache.get(key).filter(value => fnConditions.forall(_(value))) 31 | 32 | private def put( 33 | key: String, 34 | value: ValueWithTtl[V], 35 | ): ValueWithTtl[V] = { 36 | cache.put(key, value) 37 | value 38 | } 39 | 40 | } 41 | -------------------------------------------------------------------------------- /secret-provider/src/it/scala/io/lenses/connect/secrets/integration/SecretProviderRestResponseTest.scala: -------------------------------------------------------------------------------- 1 | package io.lenses.connect.secrets.integration 2 | 3 | import org.scalatest.funsuite.AnyFunSuite 4 | import org.scalatest.matchers.should.Matchers 5 | 6 | class SecretProviderRestResponseTest extends AnyFunSuite with Matchers { 7 | 8 | private val sampleJson = 9 | """{"request_id":"41398853-3a2f-ba6b-7c37-23b201941071","lease_id":"","renewable":false,"lease_duration":20,"data": {"data":{"myVaultSecretKey":"myVaultSecretValue"}, "metadata":{"created_time":"2023-02-28T10:49:56.854615Z","custom_metadata":null,"deletion_time":"","destroyed":false,"version":1}},"wrap_info":null,"warnings":null,"auth":null}""" 10 | 11 | test("Should unmarshal sample json") { 12 | val sprr = SecretProviderRestResponse.fromJson(sampleJson) 13 | 14 | sprr.requestId should be("41398853-3a2f-ba6b-7c37-23b201941071") 15 | sprr.leaseDuration should be(20) 16 | sprr.data.data should be(Map[String, Any]("myVaultSecretKey" -> "myVaultSecretValue")) 17 | sprr.data.metadata.toSet should be( 18 | Set( 19 | "createdTime" -> "2023-02-28T10:49:56.854615Z", 20 | "customMetadata" -> null, 21 | "deletionTime" -> "", 22 | "destroyed" -> false, 23 | "version" -> Integer.valueOf(1), 24 | ), 25 | ) 26 | } 27 | 28 | } 29 | -------------------------------------------------------------------------------- /secret-provider/src/main/scala/io/lenses/connect/secrets/cache/ValueWithTtl.scala: -------------------------------------------------------------------------------- 1 | package io.lenses.connect.secrets.cache 2 | 3 | import java.time.Clock 4 | import java.time.Duration 5 | import java.time.OffsetDateTime 6 | 7 | case class Ttl(originalTtl: Duration, expiry: OffsetDateTime) { 8 | 9 | def ttlRemaining(implicit clock: Clock): Duration = 10 | Duration.between(clock.instant(), expiry.toInstant) 11 | 12 | def isAlive(implicit clock: Clock) = expiry.toInstant.isAfter(clock.instant()) 13 | 14 | } 15 | 16 | object Ttl { 17 | def apply(ttl: Option[Duration], defaultTtl: Option[Duration])(implicit clock: Clock): Option[Ttl] = 18 | ttl.orElse(defaultTtl).map(finalTtl => Ttl(finalTtl, ttlToExpiry(finalTtl))) 19 | 20 | def ttlToExpiry(ttl: Duration)(implicit clock: Clock): OffsetDateTime = { 21 | val offset = clock.getZone.getRules.getOffset(clock.instant()) 22 | val offsetDateTime = clock.instant().plus(ttl).atOffset(offset) 23 | offsetDateTime 24 | } 25 | 26 | } 27 | 28 | case class ValueWithTtl[V](ttlAndExpiry: Option[Ttl], value: V) { 29 | def isAlive(implicit clock: Clock): Boolean = ttlAndExpiry.exists(_.isAlive) 30 | } 31 | 32 | object ValueWithTtl { 33 | def apply[V](ttl: Option[Duration], defaultTtl: Option[Duration], value: V)(implicit clock: Clock): ValueWithTtl[V] = 34 | ValueWithTtl(Ttl(ttl, defaultTtl), value) 35 | } 36 | -------------------------------------------------------------------------------- /secret-provider/src/test/resources/cert.pem: -------------------------------------------------------------------------------- 1 | -----BEGIN CERTIFICATE----- 2 | MIIDhjCCAm6gAwIBAgIES40FSTANBgkqhkiG9w0BAQsFADBrMQswCQYDVQQGEwJV 3 | UzERMA8GA1UECBMIQW55c3RhdGUxEDAOBgNVBAcTB0FueXRvd24xETAPBgNVBAoT 4 | CFRlc3QgT3JnMRAwDgYDVQQLEwdUZXN0IE9VMRIwEAYDVQQDEwlUZXN0IFVzZXIw 5 | HhcNMTYwMjE2MTcwNDQ3WhcNMTYwNTE2MTcwNDQ3WjBrMQswCQYDVQQGEwJVUzER 6 | MA8GA1UECBMIQW55c3RhdGUxEDAOBgNVBAcTB0FueXRvd24xETAPBgNVBAoTCFRl 7 | c3QgT3JnMRAwDgYDVQQLEwdUZXN0IE9VMRIwEAYDVQQDEwlUZXN0IFVzZXIwggEi 8 | MA0GCSqGSIb3DQEBAQUAA4IBDwAwggEKAoIBAQCHNAd93WjoDl7EYddxqpAd9FGo 9 | yvFA0900tmLJWmD3YPXhkOkXO38E//tS9KkXD39tDsDwHxw53iF1SmzgrHvzJzQv 10 | GjR5rvp7KjMhv/wlpED2E4FR/q2WigoXVtzpOwc4fk4PizBZV4fkSOtiQA0LEoQo 11 | chw8wp7OI1tzE5iISKggD0N9EOJUzwQIcAgkAdaYEP9Fd2YMgTJAiHSakOgQowKQ 12 | QGmIbKg0YWici9tiojwNCuNlcp1kBEUi4odO6BxRs8RKk6McvHCu1+2SSlxctGGU 13 | 8kFKsF92/sULxvHAOovYspKdBJfw2f088Hnfw3jSgaWWQNB+oilVsfECx1BPAgMB 14 | AAGjMjAwMA8GA1UdEQQIMAaHBH8AAAEwHQYDVR0OBBYEFHuppZEESxlasbK5aq4L 15 | vF/IhtseMA0GCSqGSIb3DQEBCwUAA4IBAQBK9g8sWk6jCPekk2VjK6aKYIs4BB79 16 | xsaj41hdjoMwVRSelSpsmJE34/Vflqy+SBrf59czvk/UqJIYSrHRx7M0hpfIChmq 17 | qNEj5NKY+MFBuOTt4r/Wv3tbBTf2CMs4hLnkevhleNLxJhAjvh7r52U+uE8l6O11 18 | dsQRVXOSGnwdnvInVTs1ilxdTQh680DEU0q26P3o36N3Oxxgls2ZC3ExnLJnOofh 19 | j01l6cYhI06RtFPzJtv5sICCkYGMDKSIsWUndmurZjLAjsAKPT/RePeqyW0dKY5Z 20 | jtC+YAg5i3O0DLhERsDZECIp56oqsYxATuoHVzbjorM2ua2pUcuIR0p3 21 | -----END CERTIFICATE----- 22 | -------------------------------------------------------------------------------- /test-sink/src/main/scala/io/lenses/connect/secrets/test/vault/TestSinkTask.scala: -------------------------------------------------------------------------------- 1 | package io.lenses.connect.secrets.test.vault 2 | 3 | import com.typesafe.scalalogging.LazyLogging 4 | import VaultStateValidator.validateSecret 5 | import org.apache.kafka.connect.sink.SinkRecord 6 | import org.apache.kafka.connect.sink.SinkTask 7 | import org.apache.kafka.connect.sink.SinkTaskContext 8 | 9 | import java.util 10 | import scala.jdk.CollectionConverters.CollectionHasAsScala 11 | import scala.jdk.CollectionConverters.MapHasAsScala 12 | 13 | class TestSinkTask extends SinkTask with LazyLogging { 14 | 15 | private implicit var vaultState: VaultState = _ 16 | 17 | override def start(props: util.Map[String, String]): Unit = { 18 | val scalaProps = props.asScala.toMap 19 | logger.info("start(props:{})", scalaProps) 20 | vaultState = VaultState(scalaProps) 21 | logger.info("start(vaultState:{})", vaultState) 22 | } 23 | 24 | override def put(records: util.Collection[SinkRecord]): Unit = { 25 | logger.info("put(records:{})", records.asScala) 26 | validateSecret() 27 | } 28 | 29 | override def stop(): Unit = 30 | logger.info("stop()") 31 | 32 | override def initialize(context: SinkTaskContext): Unit = { 33 | super.initialize(context) 34 | logger.info("initialize(configs:{})", context.configs().asScala) 35 | } 36 | 37 | override def version(): String = "0.0.1a" 38 | } 39 | -------------------------------------------------------------------------------- /secret-provider/src/main/scala/io/lenses/connect/secrets/config/AWSCredentials.scala: -------------------------------------------------------------------------------- 1 | package io.lenses.connect.secrets.config 2 | 3 | import org.apache.kafka.common.config.types.Password 4 | import org.apache.kafka.connect.errors.ConnectException 5 | 6 | import scala.util.Try 7 | 8 | case class AWSCredentials(accessKey: String, secretKey: Password) 9 | 10 | object AWSCredentials { 11 | def apply(configs: AWSProviderConfig): Either[Throwable, AWSCredentials] = 12 | for { 13 | accessKey <- Try(configs.getString(AWSProviderConfig.AWS_ACCESS_KEY)).toEither 14 | secretKey <- Try(configs.getPassword(AWSProviderConfig.AWS_SECRET_KEY)).toEither 15 | 16 | accessKeyValidated <- Either.cond(accessKey.nonEmpty, 17 | accessKey, 18 | new ConnectException( 19 | s"${AWSProviderConfig.AWS_ACCESS_KEY} not set", 20 | ), 21 | ) 22 | secretKeyValidated <- Either.cond(secretKey.value().nonEmpty, 23 | secretKey, 24 | new ConnectException( 25 | s"${AWSProviderConfig.AWS_SECRET_KEY} not set", 26 | ), 27 | ) 28 | } yield { 29 | AWSCredentials(accessKeyValidated, secretKeyValidated) 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /.github/workflows/build.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: [pull_request] 4 | 5 | jobs: 6 | build: 7 | runs-on: ubuntu-latest 8 | 9 | steps: 10 | - name: Checkout 11 | uses: actions/checkout@v2 12 | - name: Setup Scala 13 | uses: olafurpg/setup-scala@v11 14 | with: 15 | java-version: openjdk@1.14 16 | - name: Test 17 | run: sbt +test 18 | 19 | dependency-check: 20 | timeout-minutes: 30 21 | runs-on: ubuntu-latest 22 | steps: 23 | - name: Checkout 24 | uses: actions/checkout@v2 25 | - name: Setup Scala 26 | uses: olafurpg/setup-scala@v11 27 | with: 28 | java-version: openjdk@1.14 29 | - name: Assembly 30 | run: sbt "project secret-provider;set assembly / test := {}" assembly 31 | - name: Dependency Check 32 | uses: dependency-check/Dependency-Check_Action@1.1.0 33 | env: 34 | # actions/setup-java@v1 changes JAVA_HOME so it needs to be reset to match the depcheck image 35 | JAVA_HOME: /opt/jdk 36 | with: 37 | project: secret-provider-deps 38 | path: secret-provider/target/libs/ 39 | format: 'HTML' 40 | - name: Upload Test results 41 | uses: actions/upload-artifact@master 42 | with: 43 | name: secret-provider-depcheck-results 44 | path: ${{github.workspace}}/reports 45 | -------------------------------------------------------------------------------- /test-sink/src/main/scala/io/lenses/connect/secrets/test/vault/VaultState.scala: -------------------------------------------------------------------------------- 1 | package io.lenses.connect.secrets.test.vault 2 | 3 | import io.github.jopenlibs.vault.Vault 4 | import io.github.jopenlibs.vault.VaultConfig 5 | 6 | case class VaultState( 7 | vault: Vault, 8 | secretValue: String, 9 | secretPath: String, 10 | secretKey: String, 11 | ) 12 | 13 | object VaultState { 14 | def apply(props: Map[String, String]): VaultState = { 15 | 16 | val vaultHost = props.getOrElse( 17 | "test.sink.vault.host", 18 | throw new IllegalStateException("No test.sink.vault.host"), 19 | ) 20 | val vaultToken = props.getOrElse( 21 | "test.sink.vault.token", 22 | throw new IllegalStateException("No test.sink.vault.token"), 23 | ) 24 | val secret = props.getOrElse( 25 | "test.sink.secret.value", 26 | throw new IllegalStateException("No test.sink.secret.value"), 27 | ) 28 | val secretPath = props.getOrElse( 29 | "test.sink.secret.path", 30 | throw new IllegalStateException("No test.sink.secret.path"), 31 | ) 32 | val secretKey = props.getOrElse( 33 | "test.sink.secret.key", 34 | throw new IllegalStateException("No test.sink.secret.key"), 35 | ) 36 | VaultState( 37 | new Vault( 38 | new VaultConfig() 39 | .address(vaultHost) 40 | .token(vaultToken) 41 | .build(), 42 | ), 43 | secret, 44 | secretPath, 45 | secretKey, 46 | ) 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /secret-provider/src/main/scala/io/lenses/connect/secrets/config/SecretTypeConfig.scala: -------------------------------------------------------------------------------- 1 | package io.lenses.connect.secrets.config 2 | 3 | import io.lenses.connect.secrets.config.SecretType.SecretType 4 | import org.apache.kafka.common.config.ConfigDef 5 | import org.apache.kafka.common.config.ConfigDef.Importance 6 | import org.apache.kafka.common.config.ConfigDef.Type 7 | import org.apache.kafka.connect.errors.ConnectException 8 | 9 | import scala.util.Try 10 | 11 | object SecretType extends Enumeration { 12 | 13 | type SecretType = Value 14 | val STRING, JSON = Value 15 | } 16 | object SecretTypeConfig { 17 | val SECRET_TYPE: String = "secret.type" 18 | 19 | def addSecretTypeToConfigDef(configDef: ConfigDef): ConfigDef = 20 | configDef.define( 21 | SECRET_TYPE, 22 | Type.STRING, 23 | "", 24 | Importance.LOW, 25 | "Type of secret to retrieve (string, json)", 26 | ) 27 | 28 | def lookupAndValidateSecretTypeValue(propLookup: String => String): SecretType = { 29 | for { 30 | secretTypeString <- Try(propLookup(SecretTypeConfig.SECRET_TYPE)).toOption.filterNot(st => 31 | st == null || st.isEmpty, 32 | ) 33 | secretTypeEnum <- Try { 34 | SecretType.withName(secretTypeString.toUpperCase) 35 | }.toEither.left.map(_ => 36 | throw new ConnectException( 37 | s"$secretTypeString is not a valid secret type. Please check your ${SecretTypeConfig.SECRET_TYPE} property.", 38 | ), 39 | ).toOption 40 | } yield secretTypeEnum 41 | }.getOrElse(SecretType.JSON) 42 | 43 | } 44 | -------------------------------------------------------------------------------- /secret-provider/src/test/scala/io/lenses/connect/secrets/providers/AesDecodingTestHelper.scala: -------------------------------------------------------------------------------- 1 | package io.lenses.connect.secrets.providers 2 | 3 | import io.lenses.connect.secrets.providers.Aes256DecodingHelper.INITIALISATION_VECTOR_SEPARATOR 4 | 5 | import java.util.Base64 6 | import javax.crypto.Cipher 7 | import javax.crypto.spec.IvParameterSpec 8 | import javax.crypto.spec.SecretKeySpec 9 | import scala.util.Try 10 | 11 | object AesDecodingTestHelper { 12 | private val AES = "AES" 13 | 14 | def encrypt(s: String, key: String): String = { 15 | val iv = InitializationVector() 16 | encryptBytes(s.getBytes("UTF-8"), iv, key) 17 | .map(encrypted => 18 | base64Encode(iv.bytes) + INITIALISATION_VECTOR_SEPARATOR + base64Encode( 19 | encrypted, 20 | ), 21 | ) 22 | .get 23 | } 24 | 25 | private def encryptBytes( 26 | bytes: Array[Byte], 27 | iv: InitializationVector, 28 | key: String, 29 | ): Try[Array[Byte]] = 30 | for { 31 | cipher <- getCipher(Cipher.ENCRYPT_MODE, iv, key) 32 | encrypted <- Try(cipher.doFinal(bytes)) 33 | } yield encrypted 34 | 35 | private def getCipher( 36 | mode: Int, 37 | iv: InitializationVector, 38 | key: String, 39 | ): Try[Cipher] = 40 | Try { 41 | val cipher = Cipher.getInstance("AES/CBC/PKCS5Padding") 42 | val ivSpec = new IvParameterSpec(iv.bytes) 43 | val secret = new SecretKeySpec(key.getBytes("UTF-8"), AES) 44 | cipher.init(mode, secret, ivSpec) 45 | cipher 46 | } 47 | 48 | private def base64Encode(bytes: Array[Byte]) = 49 | Base64.getEncoder().encodeToString(bytes) 50 | } 51 | -------------------------------------------------------------------------------- /secret-provider/src/test/scala/io/lenses/connect/secrets/cache/TtlTest.scala: -------------------------------------------------------------------------------- 1 | package io.lenses.connect.secrets.cache 2 | 3 | import org.scalatest.OptionValues 4 | import org.scalatest.funsuite.AnyFunSuite 5 | import org.scalatest.matchers.should.Matchers 6 | 7 | import java.time.temporal.ChronoUnit._ 8 | import java.time.Clock 9 | import java.time.Duration 10 | import java.time.Instant 11 | import java.time.ZoneId 12 | 13 | class TtlTest extends AnyFunSuite with Matchers with OptionValues { 14 | implicit val clock = Clock.fixed(Instant.EPOCH, ZoneId.systemDefault()) 15 | 16 | test("no durations should lead to empty TTL") { 17 | Ttl(Option.empty, Option.empty) should be(Option.empty) 18 | } 19 | 20 | test("ttl returned with record should be used") { 21 | val ttl = Ttl(Some(Duration.of(5, MINUTES)), Option.empty) 22 | ttl.value.originalTtl should be(Duration.of(5, MINUTES)) 23 | ttl.value.expiry should be(Instant.EPOCH.plus(5, MINUTES).atOffset(toOffset)) 24 | } 25 | 26 | private def toOffset = 27 | clock.getZone.getRules.getOffset(clock.instant()) 28 | 29 | test("default ttl should apply if not available") { 30 | val ttl = Ttl(Option.empty, Some(Duration.of(5, MINUTES))) 31 | ttl.value.originalTtl should be(Duration.of(5, MINUTES)) 32 | ttl.value.expiry should be(Instant.EPOCH.plus(5, MINUTES).atOffset(toOffset)) 33 | } 34 | 35 | test("record ttl should be preferred over default") { 36 | val ttl = Ttl(Some(Duration.of(10, MINUTES)), Some(Duration.of(5, MINUTES))) 37 | ttl.value.originalTtl should be(Duration.of(10, MINUTES)) 38 | ttl.value.expiry should be(Instant.EPOCH.plus(10, MINUTES).atOffset(toOffset)) 39 | } 40 | 41 | } 42 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ![Action Status](https://github.com/lensesio/secret-provider/workflows/CI/badge.svg) 2 | [![FOSSA Status](https://app.fossa.com/api/projects/git%2Bgithub.com%2Flensesio%2Fsecret-provider.svg?type=shield)](https://app.fossa.com/projects/git%2Bgithub.com%2Flensesio%2Fsecret-provider?ref=badge_shield) 3 | [](https://docs.lenses.io/4.0/integrations/connectors/secret-providers/) 4 | [![License](https://img.shields.io/badge/License-Apache%202.0-blue.svg)](https://opensource.org/licenses/Apache-2.0) 5 | 6 | # Secret Provider 7 | 8 | Secret provider for Kafka to provide indirect look up of configuration values. 9 | 10 | ## Description 11 | 12 | External secret providers allow for indirect references to be placed in an 13 | applications' configuration, so for example, that secrets are not exposed in the 14 | Worker API endpoints of Kafka Connect. 15 | 16 | For [Documentation](https://docs.lenses.io/5.3/connectors/connect-secrets/). 17 | 18 | 19 | ## Contributing 20 | 21 | We'd love to accept your contributions! Please use GitHub pull requests: fork 22 | the repo, develop and test your code, 23 | [semantically commit](http://karma-runner.github.io/1.0/dev/git-commit-msg.html) 24 | and submit a pull request. Thanks! 25 | 26 | ### Building 27 | 28 | ***Requires SBT to build.*** 29 | 30 | To build the (scala 2.12 and 2.13) assemblies for use with Kafka Connect (also runs tests): 31 | 32 | ```bash 33 | sbt +assembly 34 | ``` 35 | 36 | To run tests: 37 | 38 | ```bash 39 | sbt +test 40 | ``` 41 | 42 | 43 | ## License 44 | [![FOSSA Status](https://app.fossa.com/api/projects/git%2Bgithub.com%2Flensesio%2Fsecret-provider.svg?type=large)](https://app.fossa.com/projects/git%2Bgithub.com%2Flensesio%2Fsecret-provider?ref=badge_large) 45 | -------------------------------------------------------------------------------- /test-sink/src/main/scala/io/lenses/connect/secrets/test/vault/TestSinkConnector.scala: -------------------------------------------------------------------------------- 1 | package io.lenses.connect.secrets.test.vault 2 | 3 | import com.typesafe.scalalogging.LazyLogging 4 | import org.apache.kafka.common.config.ConfigDef 5 | import org.apache.kafka.common.config.ConfigDef.Importance 6 | import org.apache.kafka.connect.connector.Task 7 | import org.apache.kafka.connect.sink.SinkConnector 8 | 9 | import java.util 10 | import scala.jdk.CollectionConverters.MapHasAsScala 11 | import scala.jdk.CollectionConverters.SeqHasAsJava 12 | 13 | class TestSinkConnector extends SinkConnector with LazyLogging { 14 | 15 | private val storedProps: util.Map[String, String] = 16 | new util.HashMap[String, String]() 17 | 18 | override def start(props: util.Map[String, String]): Unit = { 19 | logger.info("start(props:{})", props.asScala) 20 | storedProps.putAll(props) 21 | } 22 | 23 | override def taskClass(): Class[_ <: Task] = classOf[TestSinkTask] 24 | 25 | override def taskConfigs( 26 | maxTasks: Int, 27 | ): util.List[util.Map[String, String]] = { 28 | logger.info("testConfigs(maxTasks:{})", maxTasks) 29 | List.fill(1)(storedProps).asJava 30 | } 31 | 32 | override def stop(): Unit = { 33 | logger.info("stop()") 34 | () 35 | } 36 | 37 | override def config(): ConfigDef = new ConfigDef() 38 | .define("test.sink.vault.host", ConfigDef.Type.STRING, Importance.LOW, "host") 39 | .define("test.sink.vault.token", ConfigDef.Type.STRING, Importance.LOW, "token") 40 | .define("test.sink.secret.value", ConfigDef.Type.STRING, Importance.LOW, "value") 41 | .define("test.sink.secret.path", ConfigDef.Type.STRING, Importance.LOW, "path") 42 | .define("test.sink.secret.key", ConfigDef.Type.PASSWORD, Importance.LOW, "key") 43 | 44 | override def version(): String = "0.0.1a" 45 | } 46 | -------------------------------------------------------------------------------- /secret-provider/src/test/scala/io/lenses/connect/secrets/io/FileWriterOnceTest.scala: -------------------------------------------------------------------------------- 1 | package io.lenses.connect.secrets.io 2 | 3 | import org.scalatest.BeforeAndAfterAll 4 | import org.scalatest.funsuite.AnyFunSuite 5 | import org.scalatest.matchers.should.Matchers 6 | 7 | import java.io.File 8 | import java.nio.file.Paths 9 | import java.util.UUID 10 | import scala.io.Source 11 | import scala.util.Success 12 | import scala.util.Try 13 | import scala.util.Using 14 | 15 | class FileWriterOnceTest extends AnyFunSuite with Matchers with BeforeAndAfterAll { 16 | private val folder = new File(UUID.randomUUID().toString) 17 | folder.deleteOnExit() 18 | private val writer = new FileWriterOnce(folder.toPath) 19 | 20 | override protected def beforeAll(): Unit = 21 | folder.mkdir() 22 | 23 | override protected def afterAll(): Unit = { 24 | folder.listFiles().foreach(f => Try(f.delete())) 25 | folder.delete() 26 | } 27 | 28 | test("writes the file") { 29 | val content = Array(1, 2, 3).map(_.toByte) 30 | writer.write("thisone", content, "key1") 31 | val file = Paths.get(folder.toPath.toString, "thisone").toFile 32 | file.exists() shouldBe true 33 | 34 | Using(Source.fromFile(file))(_.size) shouldBe Success(content.length) 35 | } 36 | 37 | test("does not write the file twice") { 38 | val content1 = Array(1, 2, 3).map(_.toByte) 39 | val fileName = "thissecond" 40 | writer.write(fileName, content1, "key1") 41 | val file = Paths.get(folder.toPath.toString, fileName).toFile 42 | file.exists() shouldBe true 43 | 44 | Using(Source.fromFile(file))(_.size) shouldBe Success(content1.length) 45 | 46 | val content2 = Array(1, 2, 3, 4, 5, 6, 7, 8).map(_.toByte) 47 | writer.write(fileName, content2, "key1") 48 | Using(Source.fromFile(file))(_.size) shouldBe Success(content1.length) 49 | 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /secret-provider/src/test/scala/io/lenses/connect/secrets/config/SecretTypeConfigTest.scala: -------------------------------------------------------------------------------- 1 | package io.lenses.connect.secrets.config 2 | 3 | import org.apache.kafka.connect.errors.ConnectException 4 | import org.mockito.Mockito.when 5 | import org.scalatest.OptionValues 6 | import org.scalatest.funsuite.AnyFunSuite 7 | import org.scalatest.matchers.should.Matchers 8 | import org.scalatestplus.mockito.MockitoSugar 9 | 10 | class SecretTypeConfigTest extends AnyFunSuite with Matchers with OptionValues with MockitoSugar { 11 | 12 | test("lookupAndValidateSecretTypeValue should throw accept valid types") { 13 | SecretTypeConfig.lookupAndValidateSecretTypeValue(mockSecretReturn("string")) should be(SecretType.STRING) 14 | SecretTypeConfig.lookupAndValidateSecretTypeValue(mockSecretReturn("STRING")) should be(SecretType.STRING) 15 | SecretTypeConfig.lookupAndValidateSecretTypeValue(mockSecretReturn("json")) should be(SecretType.JSON) 16 | SecretTypeConfig.lookupAndValidateSecretTypeValue(mockSecretReturn("JSON")) should be(SecretType.JSON) 17 | } 18 | 19 | test("lookupAndValidateSecretTypeValue should throw error on invalid type") { 20 | val secRetFn = mockSecretReturn("nonExistentSecretType") 21 | intercept[ConnectException] { 22 | SecretTypeConfig.lookupAndValidateSecretTypeValue(secRetFn) 23 | }.getMessage should startWith("nonExistentSecretType is not a valid secret type") 24 | } 25 | 26 | test("lookupAndValidateSecretTypeValue should return json by default") { 27 | SecretTypeConfig.lookupAndValidateSecretTypeValue(mockSecretReturn(null)) should be(SecretType.JSON) 28 | SecretTypeConfig.lookupAndValidateSecretTypeValue(mockSecretReturn("")) should be(SecretType.JSON) 29 | } 30 | 31 | private def mockSecretReturn(out: String): String => String = { 32 | val mockFn = mock[String => String] 33 | when(mockFn.apply(SecretTypeConfig.SECRET_TYPE)).thenReturn(out) 34 | mockFn 35 | } 36 | 37 | } 38 | -------------------------------------------------------------------------------- /secret-provider/src/it/resources/logback.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | System.out 21 | 22 | %d{ISO8601} %-5p %X{dbz.connectorType}|%X{dbz.connectorName}|%X{dbz.connectorContext} %m [%c]%n 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | -------------------------------------------------------------------------------- /secret-provider/src/main/scala/io/lenses/connect/secrets/config/AzureProviderConfig.scala: -------------------------------------------------------------------------------- 1 | /* 2 | * 3 | * * Copyright 2017-2020 Lenses.io Ltd 4 | * 5 | */ 6 | 7 | package io.lenses.connect.secrets.config 8 | 9 | import io.lenses.connect.secrets.connect._ 10 | import org.apache.kafka.common.config.ConfigDef.Importance 11 | import org.apache.kafka.common.config.ConfigDef.Type 12 | import org.apache.kafka.common.config.AbstractConfig 13 | import org.apache.kafka.common.config.ConfigDef 14 | 15 | import java.util 16 | 17 | object AzureProviderConfig { 18 | 19 | val AZURE_CLIENT_ID = "azure.client.id" 20 | val AZURE_TENANT_ID = "azure.tenant.id" 21 | val AZURE_SECRET_ID = "azure.secret.id" 22 | val AUTH_METHOD = "azure.auth.method" 23 | 24 | val config: ConfigDef = new ConfigDef() 25 | .define( 26 | AZURE_CLIENT_ID, 27 | Type.STRING, 28 | null, 29 | Importance.HIGH, 30 | "Azure client id for the service principal", 31 | ) 32 | .define( 33 | AZURE_TENANT_ID, 34 | Type.STRING, 35 | null, 36 | Importance.HIGH, 37 | "Azure tenant id for the service principal", 38 | ) 39 | .define( 40 | AZURE_SECRET_ID, 41 | Type.PASSWORD, 42 | null, 43 | Importance.HIGH, 44 | "Azure secret id for the service principal", 45 | ) 46 | .define( 47 | AUTH_METHOD, 48 | Type.STRING, 49 | AuthMode.CREDENTIALS.toString, 50 | Importance.MEDIUM, 51 | """ 52 | |Azure authenticate method, 'credentials' to use the provided credentials or 53 | |'default' for the standard Azure provider chain 54 | |Default is 'credentials' 55 | |""".stripMargin, 56 | ) 57 | .define( 58 | FILE_DIR, 59 | Type.STRING, 60 | "", 61 | Importance.MEDIUM, 62 | FILE_DIR_DESC, 63 | ) 64 | } 65 | 66 | case class AzureProviderConfig(props: util.Map[String, _]) extends AbstractConfig(AzureProviderConfig.config, props) 67 | -------------------------------------------------------------------------------- /secret-provider/src/main/scala/io/lenses/connect/secrets/providers/AWSSecretProvider.scala: -------------------------------------------------------------------------------- 1 | /* 2 | * 3 | * * Copyright 2017-2020 Lenses.io Ltd 4 | * 5 | */ 6 | 7 | package io.lenses.connect.secrets.providers 8 | 9 | import io.lenses.connect.secrets.config.AWSProviderConfig 10 | import io.lenses.connect.secrets.config.AWSProviderSettings 11 | import org.apache.kafka.common.config.ConfigData 12 | import org.apache.kafka.common.config.provider.ConfigProvider 13 | import io.lenses.connect.secrets.providers.AWSHelper._ 14 | import java.time.Clock 15 | import java.util 16 | import software.amazon.awssdk.services.secretsmanager.SecretsManagerClient 17 | 18 | class AWSSecretProvider(testClient: Option[SecretsManagerClient]) extends ConfigProvider { 19 | 20 | def this() = { 21 | this(Option.empty); 22 | } 23 | 24 | private implicit val clock: Clock = Clock.systemDefaultZone() 25 | 26 | private var secretProvider: Option[SecretProvider] = None 27 | 28 | override def configure(configs: util.Map[String, _]): Unit = { 29 | val settings = AWSProviderSettings(AWSProviderConfig(props = configs)) 30 | val awsClient = testClient.getOrElse(createClient(settings)) 31 | val helper = new AWSHelper(awsClient, 32 | settings.defaultTtl, 33 | fileWriterCreateFn = () => settings.fileWriterOpts.map(_.createFileWriter()), 34 | settings.secretType, 35 | ) 36 | secretProvider = Some( 37 | new SecretProvider( 38 | "AWSSecretProvider", 39 | helper.lookup, 40 | helper.close, 41 | ), 42 | ) 43 | } 44 | 45 | override def get(path: String): ConfigData = 46 | secretProvider.fold(throw new IllegalStateException("No client defined"))(_.get(path)) 47 | 48 | override def get(path: String, keys: util.Set[String]): ConfigData = 49 | secretProvider.fold(throw new IllegalStateException("No client defined"))(_.get(path, keys)) 50 | 51 | override def close(): Unit = secretProvider.foreach(_.close()) 52 | 53 | } 54 | -------------------------------------------------------------------------------- /secret-provider/src/main/scala/io/lenses/connect/secrets/async/AsyncFunctionLoop.scala: -------------------------------------------------------------------------------- 1 | /* 2 | * 3 | * * Copyright 2017-2020 Lenses.io Ltd 4 | * 5 | */ 6 | 7 | package io.lenses.connect.secrets.async 8 | 9 | import com.typesafe.scalalogging.StrictLogging 10 | 11 | import java.util.concurrent.atomic.AtomicBoolean 12 | import java.util.concurrent.atomic.AtomicLong 13 | import java.util.concurrent.Executors 14 | import java.util.concurrent.TimeUnit 15 | import scala.concurrent.duration.Duration 16 | 17 | class AsyncFunctionLoop(interval: Duration, description: String)(thunk: => Unit) 18 | extends AutoCloseable 19 | with StrictLogging { 20 | 21 | private val running = new AtomicBoolean(false) 22 | private val success = new AtomicLong(0L) 23 | private val failure = new AtomicLong(0L) 24 | private val executorService = Executors.newFixedThreadPool(1) 25 | 26 | def start(): Unit = { 27 | if (!running.compareAndSet(false, true)) { 28 | throw new IllegalStateException(s"$description already running.") 29 | } 30 | logger.info( 31 | s"Starting $description loop with an interval of ${interval.toMillis}ms.", 32 | ) 33 | executorService.submit( 34 | new Runnable { 35 | override def run(): Unit = 36 | while (running.get()) { 37 | try { 38 | Thread.sleep(interval.toMillis) 39 | thunk 40 | success.incrementAndGet() 41 | } catch { 42 | case _: InterruptedException => 43 | case t: Throwable => 44 | logger.warn(s"Failed to run function $description", t) 45 | failure.incrementAndGet() 46 | } 47 | } 48 | }, 49 | ) 50 | } 51 | 52 | override def close(): Unit = 53 | if (running.compareAndSet(true, false)) { 54 | executorService.shutdownNow() 55 | executorService.awaitTermination(10000, TimeUnit.MILLISECONDS) 56 | } 57 | 58 | def successRate: Long = success.get() 59 | def failureRate: Long = failure.get() 60 | } 61 | -------------------------------------------------------------------------------- /test-sink/src/main/scala/io/lenses/connect/secrets/test/vault/VaultStateValidator.scala: -------------------------------------------------------------------------------- 1 | package io.lenses.connect.secrets.test.vault 2 | 3 | import com.typesafe.scalalogging.LazyLogging 4 | 5 | import java.time.Instant 6 | import java.time.LocalDateTime 7 | import java.time.ZoneId 8 | import scala.collection.mutable 9 | import scala.jdk.CollectionConverters.MapHasAsScala 10 | 11 | object VaultStateValidator extends LazyLogging { 12 | 13 | private case class VaultSecret(created: LocalDateTime, expiry: LocalDateTime, secretValue: String) 14 | 15 | def validateSecret()(implicit vaultState: VaultState) = { 16 | 17 | val vaultSecret = getSecretFromVault() 18 | 19 | val nowInst = LocalDateTime.now() 20 | 21 | logger.info( 22 | "Secret info (created: {}, expires: {}, now: {})", 23 | vaultSecret.created, 24 | vaultSecret.expiry, 25 | nowInst, 26 | ) 27 | 28 | require(nowInst.isAfter(vaultSecret.created), "secret isn't active yet") 29 | require(nowInst.isBefore(vaultSecret.expiry), "secret has expired") 30 | 31 | } 32 | 33 | private def getSecretFromVault()(implicit vaultState: VaultState): VaultSecret = { 34 | val vaultSecret = vaultState.vault.logical().read(vaultState.secretPath) 35 | val vaultData = vaultSecret.getData.asScala 36 | val secretValue = vaultData.getOrElse( 37 | vaultState.secretKey, 38 | throw new IllegalStateException("Secret has no value"), 39 | ) 40 | VaultSecret( 41 | extractDateFromVaultData(vaultData, "created"), 42 | extractDateFromVaultData(vaultData, "expires"), 43 | secretValue, 44 | ) 45 | } 46 | 47 | private def extractDateFromVaultData(vaultData: mutable.Map[String, String], fieldName: String) = { 48 | val timeMillis = vaultData 49 | .get(fieldName) 50 | .map(java.lang.Long.valueOf) 51 | .getOrElse(throw new IllegalStateException(s"Secret has no $fieldName time")) 52 | 53 | Instant 54 | .ofEpochMilli(timeMillis) 55 | .atZone(ZoneId.systemDefault()) 56 | .toLocalDateTime 57 | } 58 | 59 | } 60 | -------------------------------------------------------------------------------- /secret-provider/src/main/scala/io/lenses/connect/secrets/config/AWSProviderSettings.scala: -------------------------------------------------------------------------------- 1 | /* 2 | * 3 | * * Copyright 2017-2020 Lenses.io Ltd 4 | * 5 | */ 6 | 7 | package io.lenses.connect.secrets.config 8 | 9 | import io.lenses.connect.secrets.config.SecretType.SecretType 10 | import io.lenses.connect.secrets.connect.AuthMode.AuthMode 11 | import io.lenses.connect.secrets.connect._ 12 | 13 | import java.time.Duration 14 | import java.time.temporal.ChronoUnit 15 | import scala.util.Try 16 | 17 | case class AWSProviderSettings( 18 | region: String, 19 | credentials: Option[AWSCredentials], 20 | authMode: AuthMode, 21 | fileWriterOpts: Option[FileWriterOptions], 22 | defaultTtl: Option[Duration], 23 | endpointOverride: Option[String], 24 | secretType: SecretType, 25 | ) 26 | 27 | import io.lenses.connect.secrets.config.AbstractConfigExtensions._ 28 | object AWSProviderSettings { 29 | def apply(configs: AWSProviderConfig): AWSProviderSettings = { 30 | // TODO: Validate all configs in one step and provide all errors together 31 | val region = configs.getStringOrThrowOnNull(AWSProviderConfig.AWS_REGION) 32 | 33 | val endpointOverride = 34 | Try(configs.getString(AWSProviderConfig.ENDPOINT_OVERRIDE)).toOption.filterNot(_.trim.isEmpty) 35 | val authMode = 36 | getAuthenticationMethod(configs.getString(AWSProviderConfig.AUTH_METHOD)) 37 | 38 | val awsCredentials: Option[AWSCredentials] = Option.when(authMode == AuthMode.CREDENTIALS) { 39 | AWSCredentials(configs).left.map(throw _).merge 40 | } 41 | 42 | val secretType = SecretTypeConfig.lookupAndValidateSecretTypeValue(configs.getString) 43 | 44 | new AWSProviderSettings( 45 | region = region, 46 | credentials = awsCredentials, 47 | authMode = authMode, 48 | fileWriterOpts = FileWriterOptions(configs), 49 | defaultTtl = 50 | Option(configs.getLong(SECRET_DEFAULT_TTL).toLong).filterNot(_ == 0L).map(Duration.of(_, ChronoUnit.MILLIS)), 51 | endpointOverride = endpointOverride, 52 | secretType = secretType, 53 | ) 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /secret-provider/src/main/scala/io/lenses/connect/secrets/io/FileWriter.scala: -------------------------------------------------------------------------------- 1 | /* 2 | * 3 | * * Copyright 2017-2020 Lenses.io Ltd 4 | * 5 | */ 6 | package io.lenses.connect.secrets.io 7 | 8 | import com.typesafe.scalalogging.StrictLogging 9 | import io.lenses.connect.secrets.utils.WithRetry 10 | 11 | import java.io.BufferedOutputStream 12 | import java.io.FileOutputStream 13 | import java.nio.file.attribute.PosixFilePermissions 14 | import java.nio.file.Files 15 | import java.nio.file.Path 16 | import java.nio.file.Paths 17 | import scala.concurrent.duration._ 18 | import scala.util.Try 19 | 20 | trait FileWriter { 21 | def write(fileName: String, content: Array[Byte], key: String): Path 22 | } 23 | 24 | class FileWriterOnce(rootPath: Path) extends FileWriter with WithRetry with StrictLogging { 25 | 26 | private val folderPermissions = PosixFilePermissions.fromString("rwx------") 27 | private val filePermissions = PosixFilePermissions.fromString("rw-------") 28 | private val folderAttributes = 29 | PosixFilePermissions.asFileAttribute(folderPermissions) 30 | private val fileAttributes = 31 | PosixFilePermissions.asFileAttribute(filePermissions) 32 | 33 | if (!rootPath.toFile.exists) 34 | Files.createDirectories(rootPath, folderAttributes) 35 | 36 | def write(fileName: String, content: Array[Byte], key: String): Path = { 37 | val fullPath = Paths.get(rootPath.toString, fileName) 38 | val file = fullPath.toFile 39 | if (file.exists()) fullPath 40 | else { 41 | val tempPath = Paths.get(rootPath.toString, fileName + ".bak") 42 | val tempFile = tempPath.toFile 43 | withRetry(10, Some(500.milliseconds)) { 44 | if (tempFile.exists()) tempFile.delete() 45 | Files.createFile(tempPath, fileAttributes) 46 | val fos = 47 | new BufferedOutputStream(new FileOutputStream(tempFile)) 48 | try { 49 | fos.write(content) 50 | fos.flush() 51 | logger.info( 52 | s"Payload written to [${file.getAbsolutePath}] for key [$key]", 53 | ) 54 | } finally { 55 | Try(fos.close()) 56 | } 57 | Try(tempFile.renameTo(file)) 58 | } 59 | fullPath 60 | } 61 | } 62 | } 63 | -------------------------------------------------------------------------------- /secret-provider/src/main/scala/io/lenses/connect/secrets/config/AzureProviderSettings.scala: -------------------------------------------------------------------------------- 1 | /* 2 | * 3 | * * Copyright 2017-2020 Lenses.io Ltd 4 | * 5 | */ 6 | 7 | package io.lenses.connect.secrets.config 8 | 9 | import com.typesafe.scalalogging.StrictLogging 10 | import io.lenses.connect.secrets.connect.AuthMode.AuthMode 11 | import io.lenses.connect.secrets.connect._ 12 | import org.apache.kafka.common.config.types.Password 13 | import org.apache.kafka.connect.errors.ConnectException 14 | 15 | case class AzureProviderSettings( 16 | clientId: String, 17 | tenantId: String, 18 | secretId: Password, 19 | authMode: AuthMode, 20 | fileDir: String, 21 | ) 22 | 23 | import io.lenses.connect.secrets.config.AbstractConfigExtensions._ 24 | object AzureProviderSettings extends StrictLogging { 25 | def apply(config: AzureProviderConfig): AzureProviderSettings = { 26 | 27 | val authMode = getAuthenticationMethod( 28 | config.getString(AzureProviderConfig.AUTH_METHOD), 29 | ) 30 | 31 | if (authMode == AuthMode.CREDENTIALS) { 32 | val clientId = 33 | config.getStringOrThrowOnNull(AzureProviderConfig.AZURE_CLIENT_ID) 34 | val tenantId = 35 | config.getStringOrThrowOnNull(AzureProviderConfig.AZURE_TENANT_ID) 36 | val secretId = 37 | config.getPasswordOrThrowOnNull(AzureProviderConfig.AZURE_SECRET_ID) 38 | 39 | if (clientId.isEmpty) 40 | throw new ConnectException( 41 | s"${AzureProviderConfig.AZURE_CLIENT_ID} not set", 42 | ) 43 | if (tenantId.isEmpty) 44 | throw new ConnectException( 45 | s"${AzureProviderConfig.AZURE_TENANT_ID} not set", 46 | ) 47 | if (secretId.value().isEmpty) 48 | throw new ConnectException( 49 | s"${AzureProviderConfig.AZURE_SECRET_ID} not set", 50 | ) 51 | } 52 | 53 | val fileDir = config.getString(FILE_DIR) 54 | 55 | AzureProviderSettings( 56 | clientId = config.getString(AzureProviderConfig.AZURE_CLIENT_ID), 57 | tenantId = config.getString(AzureProviderConfig.AZURE_TENANT_ID), 58 | secretId = config.getPassword(AzureProviderConfig.AZURE_SECRET_ID), 59 | authMode = authMode, 60 | fileDir = fileDir, 61 | ) 62 | } 63 | } 64 | -------------------------------------------------------------------------------- /secret-provider/src/main/scala/io/lenses/connect/secrets/providers/SecretProvider.scala: -------------------------------------------------------------------------------- 1 | package io.lenses.connect.secrets.providers 2 | 3 | import com.typesafe.scalalogging.LazyLogging 4 | import io.lenses.connect.secrets.cache.TtlCache 5 | import io.lenses.connect.secrets.cache.ValueWithTtl 6 | import io.lenses.connect.secrets.utils.ConfigDataBuilder 7 | import org.apache.kafka.common.config.ConfigData 8 | import org.apache.kafka.connect.errors.ConnectException 9 | 10 | import java.time.Clock 11 | import java.util 12 | import scala.jdk.CollectionConverters.SetHasAsScala 13 | 14 | class SecretProvider( 15 | providerName: String, 16 | getSecretValue: String => Either[Throwable, ValueWithTtl[Map[String, String]]], 17 | closeFn: () => Unit = () => (), 18 | )( 19 | implicit 20 | clock: Clock, 21 | ) extends LazyLogging { 22 | 23 | private val cache = new TtlCache[Map[String, String]](k => getSecretValue(k)) 24 | 25 | // lookup secrets at a path 26 | def get(path: String): ConfigData = { 27 | logger.debug(" -> {}.get(path: {})", providerName, path) 28 | val sec = cache 29 | .cachingWithTtl( 30 | path, 31 | ) 32 | .fold( 33 | ex => throw new ConnectException(ex), 34 | ConfigDataBuilder(_), 35 | ) 36 | logger.debug(" <- {}.get(path: {}, ttl: {})", providerName, path, sec.ttl()) 37 | sec 38 | } 39 | 40 | // get secret keys at a path 41 | def get(path: String, keys: util.Set[String]): ConfigData = { 42 | logger.debug(" -> {}.get(path: {}, keys: {})", providerName, path, keys.asScala) 43 | val sec = cache.cachingWithTtl( 44 | path, 45 | fnCondition = s => keys.asScala.subsetOf(s.keySet), 46 | fnFilterReturnKeys = filter(_, keys.asScala.toSet), 47 | ).fold( 48 | ex => throw new ConnectException(ex), 49 | ConfigDataBuilder(_), 50 | ) 51 | logger.debug(" <- {}.get(path: {}, keys: {}, ttl: {})", providerName, path, keys.asScala, sec.ttl()) 52 | sec 53 | } 54 | 55 | def close(): Unit = closeFn() 56 | 57 | private def filter( 58 | configData: ValueWithTtl[Map[String, String]], 59 | keys: Set[String], 60 | ): ValueWithTtl[Map[String, String]] = 61 | configData.copy(value = configData.value.filter { case (k, _) => keys.contains(k) }) 62 | 63 | } 64 | -------------------------------------------------------------------------------- /secret-provider/src/test/scala/io/lenses/connect/secrets/providers/Aes256DecodingHelperTest.scala: -------------------------------------------------------------------------------- 1 | package io.lenses.connect.secrets.providers 2 | 3 | import io.lenses.connect.secrets.providers.Aes256DecodingHelper.INITIALISATION_VECTOR_SEPARATOR 4 | import org.scalatest.matchers.should.Matchers 5 | import org.scalatest.prop.TableDrivenPropertyChecks 6 | import org.scalatest.wordspec.AnyWordSpec 7 | 8 | import java.util.UUID.randomUUID 9 | import scala.util.Random.nextString 10 | import scala.util.Success 11 | 12 | class Aes256DecodingHelperTest extends AnyWordSpec with Matchers with TableDrivenPropertyChecks { 13 | 14 | import AesDecodingTestHelper.encrypt 15 | 16 | "AES-256 decorer" should { 17 | "not be created for invalid key length" in { 18 | val secretKey = randomUUID.toString.take(16) 19 | Aes256DecodingHelper.init(secretKey) shouldBe Symbol("left") 20 | } 21 | 22 | "not be able to decrypt message for uncrecognized key" in new TestContext { 23 | forAll(inputs) { text => 24 | val otherAes256 = newEncryption(key) 25 | 26 | val encrypted = encrypt(text, generateKey()) 27 | 28 | otherAes256.decrypt(encrypted) should not be text 29 | } 30 | } 31 | 32 | "decrypt encrypted text" in new TestContext { 33 | forAll(inputs) { text: String => 34 | val aes256 = newEncryption(key) 35 | val encrypted = encrypt(text, key) 36 | 37 | aes256.decrypt(encrypted) shouldBe Success(text) 38 | } 39 | } 40 | 41 | "decrypt same text prefixed with different initialization vector" in new TestContext { 42 | forAll(inputs) { text: String => 43 | val aes256 = newEncryption(key) 44 | val encrypted1 = encrypt(text, key) 45 | val encrypted2 = encrypt(text, key) 46 | removePrefix(encrypted1) should not be removePrefix(encrypted2) 47 | 48 | aes256.decrypt(encrypted1) shouldBe aes256.decrypt(encrypted2) 49 | } 50 | } 51 | } 52 | 53 | trait TestContext { 54 | val key = generateKey() 55 | 56 | def generateKey(): String = randomUUID.toString.take(32) 57 | 58 | val inputs = Table( 59 | "string to decode", 60 | "", 61 | nextString(length = 1), 62 | nextString(length = 10), 63 | nextString(length = 100), 64 | nextString(length = 1000), 65 | nextString(length = 10000), 66 | ) 67 | 68 | def removePrefix(s: String) = 69 | s.split(INITIALISATION_VECTOR_SEPARATOR).tail.head 70 | 71 | def newEncryption(k: String) = 72 | Aes256DecodingHelper.init(k).fold(m => throw new Exception(m), identity) 73 | } 74 | } 75 | -------------------------------------------------------------------------------- /secret-provider/src/main/scala/io/lenses/connect/secrets/providers/Aes256DecodingProvider.scala: -------------------------------------------------------------------------------- 1 | package io.lenses.connect.secrets.providers 2 | 3 | import io.lenses.connect.secrets.config.Aes256ProviderConfig 4 | import io.lenses.connect.secrets.connect.decodeKey 5 | import io.lenses.connect.secrets.io.FileWriter 6 | import io.lenses.connect.secrets.utils.EncodingAndId 7 | import org.apache.kafka.common.config.ConfigData 8 | import org.apache.kafka.common.config.ConfigException 9 | import org.apache.kafka.common.config.provider.ConfigProvider 10 | import org.apache.kafka.connect.errors.ConnectException 11 | 12 | import java.util 13 | import scala.jdk.CollectionConverters._ 14 | 15 | class Aes256DecodingProvider extends ConfigProvider { 16 | 17 | private var decoder: Option[Aes256DecodingHelper] = None 18 | 19 | private var fileWriter: FileWriter = _ 20 | 21 | override def configure(configs: util.Map[String, _]): Unit = { 22 | val aes256Cfg = Aes256ProviderConfig(configs) 23 | val aes256Key = aes256Cfg.aes256Key 24 | val writeDir = aes256Cfg.fileWriterOptions 25 | 26 | decoder = Option(aes256Key) 27 | .map(Aes256DecodingHelper.init) 28 | .map(_.fold(e => throw new ConfigException(e), identity)) 29 | fileWriter = writeDir.createFileWriter() 30 | } 31 | 32 | override def get(path: String): ConfigData = 33 | new ConfigData(Map.empty[String, String].asJava) 34 | 35 | override def get(path: String, keys: util.Set[String]): ConfigData = { 36 | val encodingAndId = EncodingAndId.from(path) 37 | decoder match { 38 | case Some(d) => 39 | def decrypt(key: String): String = { 40 | val decrypted = d 41 | .decrypt(key) 42 | .fold( 43 | e => throw new ConnectException("Failed to decrypt the secret.", e), 44 | identity, 45 | ) 46 | decodeKey( 47 | key = key, 48 | value = decrypted, 49 | encoding = encodingAndId.encoding, 50 | writeFileFn = { content => 51 | encodingAndId.id match { 52 | case Some(value) => 53 | fileWriter.write(value, content, key).toString 54 | case None => 55 | throw new ConnectException( 56 | s"Invalid argument received for key:$key. Expecting a file identifier.", 57 | ) 58 | } 59 | }, 60 | ) 61 | } 62 | 63 | new ConfigData(keys.asScala.map(k => k -> decrypt(k)).toMap.asJava) 64 | case None => 65 | throw new ConnectException("decoder is not configured.") 66 | } 67 | } 68 | 69 | override def close(): Unit = {} 70 | } 71 | -------------------------------------------------------------------------------- /secret-provider/src/main/scala/io/lenses/connect/secrets/config/AWSProviderConfig.scala: -------------------------------------------------------------------------------- 1 | /* 2 | * 3 | * * Copyright 2017-2020 Lenses.io Ltd 4 | * 5 | */ 6 | 7 | package io.lenses.connect.secrets.config 8 | 9 | import io.lenses.connect.secrets.connect._ 10 | import org.apache.kafka.common.config.AbstractConfig 11 | import org.apache.kafka.common.config.ConfigDef 12 | import org.apache.kafka.common.config.ConfigDef.Importance 13 | import org.apache.kafka.common.config.ConfigDef.Type 14 | 15 | import java.util 16 | 17 | object AWSProviderConfig { 18 | 19 | val AWS_REGION: String = "aws.region" 20 | val AWS_ACCESS_KEY: String = "aws.access.key" 21 | val AWS_SECRET_KEY: String = "aws.secret.key" 22 | val AUTH_METHOD: String = "aws.auth.method" 23 | val ENDPOINT_OVERRIDE: String = "aws.endpoint.override" 24 | 25 | val config: ConfigDef = { 26 | val cDef = new ConfigDef() 27 | .define( 28 | AWS_REGION, 29 | Type.STRING, 30 | Importance.HIGH, 31 | "AWS region the Secrets manager is in", 32 | ) 33 | .define( 34 | AWS_ACCESS_KEY, 35 | Type.STRING, 36 | "", 37 | Importance.HIGH, 38 | "AWS access key", 39 | ) 40 | .define( 41 | AWS_SECRET_KEY, 42 | Type.PASSWORD, 43 | "", 44 | Importance.HIGH, 45 | "AWS password key", 46 | ) 47 | .define( 48 | AUTH_METHOD, 49 | Type.STRING, 50 | AuthMode.CREDENTIALS.toString, 51 | Importance.HIGH, 52 | """ 53 | | AWS authenticate method, 'credentials' to use the provided credentials 54 | | or 'default' for the standard AWS provider chain. 55 | | Default is 'credentials' 56 | |""".stripMargin, 57 | ) 58 | .define( 59 | WRITE_FILES, 60 | Type.BOOLEAN, 61 | false, 62 | Importance.MEDIUM, 63 | WRITE_FILES_DESC, 64 | ) 65 | .define( 66 | FILE_DIR, 67 | Type.STRING, 68 | "", 69 | Importance.MEDIUM, 70 | FILE_DIR_DESC, 71 | ) 72 | .define( 73 | SECRET_DEFAULT_TTL, 74 | Type.LONG, 75 | SECRET_DEFAULT_TTL_DEFAULT, 76 | Importance.MEDIUM, 77 | "Default TTL to apply in case a secret has no TTL", 78 | ) 79 | .define( 80 | ENDPOINT_OVERRIDE, 81 | Type.STRING, 82 | "", 83 | Importance.LOW, 84 | "URL of endpoint override (eg for custom Secret Provider implementations)", 85 | ) 86 | SecretTypeConfig.addSecretTypeToConfigDef(cDef) 87 | } 88 | } 89 | 90 | case class AWSProviderConfig(props: util.Map[String, _]) extends AbstractConfig(AWSProviderConfig.config, props) 91 | -------------------------------------------------------------------------------- /secret-provider/src/main/scala/io/lenses/connect/secrets/providers/VaultSecretProvider.scala: -------------------------------------------------------------------------------- 1 | /* 2 | * 3 | * * Copyright 2017-2020 Lenses.io Ltd 4 | * 5 | */ 6 | 7 | package io.lenses.connect.secrets.providers 8 | 9 | import io.github.jopenlibs.vault.Vault 10 | import io.lenses.connect.secrets.async.AsyncFunctionLoop 11 | import io.lenses.connect.secrets.config.VaultProviderConfig 12 | import io.lenses.connect.secrets.config.VaultSettings 13 | import io.lenses.connect.secrets.providers.VaultHelper.createClient 14 | import org.apache.kafka.common.config.ConfigData 15 | import org.apache.kafka.common.config.provider.ConfigProvider 16 | import org.apache.kafka.connect.errors.ConnectException 17 | 18 | import java.time.Clock 19 | import java.util 20 | 21 | class VaultSecretProvider() extends ConfigProvider { 22 | private implicit val clock: Clock = Clock.systemDefaultZone() 23 | 24 | private var maybeVaultClient: Option[Vault] = None 25 | private var tokenRenewal: Option[AsyncFunctionLoop] = None 26 | private var secretProvider: Option[SecretProvider] = None 27 | 28 | def getClient: Option[Vault] = maybeVaultClient 29 | 30 | // configure the vault client 31 | override def configure(configs: util.Map[String, _]): Unit = { 32 | val settings = VaultSettings(VaultProviderConfig(configs)) 33 | val vaultClient = createClient(settings) 34 | 35 | val helper = new VaultHelper( 36 | vaultClient, 37 | settings.defaultTtl, 38 | fileWriterCreateFn = () => settings.fileWriterOpts.map(_.createFileWriter()), 39 | ) 40 | 41 | secretProvider = Some(new SecretProvider(getClass.getSimpleName, helper.lookup)) 42 | maybeVaultClient = Some(vaultClient) 43 | createRenewalLoop(settings) 44 | } 45 | 46 | private def createRenewalLoop(settings: VaultSettings): Unit = { 47 | val renewalLoop = { 48 | new AsyncFunctionLoop(settings.tokenRenewal, "Vault Token Renewal")( 49 | renewToken(), 50 | ) 51 | } 52 | tokenRenewal = Some(renewalLoop) 53 | renewalLoop.start() 54 | } 55 | 56 | def tokenRenewalSuccess: Long = tokenRenewal.map(_.successRate).getOrElse(-1) 57 | def tokenRenewalFailure: Long = tokenRenewal.map(_.failureRate).getOrElse(-1) 58 | 59 | private def renewToken(): Unit = 60 | maybeVaultClient.foreach(client => client.auth().renewSelf()) 61 | 62 | override def close(): Unit = 63 | tokenRenewal.foreach(_.close()) 64 | 65 | override def get(path: String): ConfigData = 66 | secretProvider.fold(throw new ConnectException("Vault client is not set."))(_.get(path)) 67 | 68 | override def get(path: String, keys: util.Set[String]): ConfigData = 69 | secretProvider.fold(throw new ConnectException("Vault client is not set."))(_.get(path, keys)) 70 | } 71 | -------------------------------------------------------------------------------- /secret-provider/src/main/scala/io/lenses/connect/secrets/providers/ENVSecretProvider.scala: -------------------------------------------------------------------------------- 1 | /* 2 | * 3 | * * Copyright 2017-2020 Lenses.io Ltd 4 | * 5 | */ 6 | 7 | package io.lenses.connect.secrets.providers 8 | 9 | import io.lenses.connect.secrets.config.ENVProviderConfig 10 | import io.lenses.connect.secrets.connect.FILE_DIR 11 | import io.lenses.connect.secrets.connect.decode 12 | import io.lenses.connect.secrets.connect.decodeToBytes 13 | import io.lenses.connect.secrets.connect.fileWriter 14 | import org.apache.kafka.common.config.ConfigData 15 | import org.apache.kafka.common.config.provider.ConfigProvider 16 | import org.apache.kafka.connect.errors.ConnectException 17 | 18 | import java.nio.file.FileSystems 19 | import java.util 20 | import scala.jdk.CollectionConverters._ 21 | 22 | class ENVSecretProvider extends ConfigProvider { 23 | 24 | var vars = Map.empty[String, String] 25 | var fileDir: String = "" 26 | private val separator: String = FileSystems.getDefault.getSeparator 27 | private val BASE64_FILE = "(ENV-mounted-base64:)(.*$)".r 28 | private val UTF8_FILE = "(ENV-mounted:)(.*$)".r 29 | private val BASE64 = "(ENV-base64:)(.*$)".r 30 | 31 | override def get(path: String): ConfigData = 32 | new ConfigData(Map.empty[String, String].asJava) 33 | 34 | override def get(path: String, keys: util.Set[String]): ConfigData = { 35 | val data = 36 | keys.asScala 37 | .map { key => 38 | val envVarVal = 39 | vars.getOrElse( 40 | key, 41 | throw new ConnectException( 42 | s"Failed to lookup environment variable [$key]", 43 | ), 44 | ) 45 | 46 | // match the value to see if its coming from contains 47 | // the value metadata pattern 48 | envVarVal match { 49 | case BASE64_FILE(_, v) => 50 | //decode and write to file 51 | val fileName = s"$fileDir$separator${key.toLowerCase}" 52 | fileWriter(fileName, decodeToBytes(key, v), key) 53 | (key, fileName) 54 | 55 | case UTF8_FILE(_, v) => 56 | val fileName = s"$fileDir$separator${key.toLowerCase}" 57 | fileWriter(fileName, v.getBytes(), key) 58 | (key, fileName) 59 | 60 | case BASE64(_, v) => 61 | (key, decode(key, v)) 62 | 63 | case _ => 64 | (key, envVarVal) 65 | } 66 | } 67 | .toMap 68 | .asJava 69 | 70 | new ConfigData(data) 71 | } 72 | 73 | override def configure(configs: util.Map[String, _]): Unit = { 74 | vars = System.getenv().asScala.toMap 75 | val config = ENVProviderConfig(configs) 76 | fileDir = config.getString(FILE_DIR).stripSuffix(separator) 77 | } 78 | 79 | override def close(): Unit = {} 80 | } 81 | -------------------------------------------------------------------------------- /secret-provider/src/test/scala/io/lenses/connect/secrets/providers/ENVSecretProviderTest.scala: -------------------------------------------------------------------------------- 1 | /* 2 | * 3 | * * Copyright 2017-2020 Lenses.io Ltd 4 | * 5 | */ 6 | 7 | package io.lenses.connect.secrets.providers 8 | 9 | import org.apache.kafka.common.config.ConfigTransformer 10 | import org.apache.kafka.common.config.provider.ConfigProvider 11 | import org.scalatest.matchers.should.Matchers 12 | import org.scalatest.wordspec.AnyWordSpec 13 | 14 | import java.nio.file.FileSystems 15 | import java.util.Base64 16 | import scala.io.Source 17 | import scala.jdk.CollectionConverters._ 18 | import scala.util.Success 19 | import scala.util.Using 20 | 21 | class ENVSecretProviderTest extends AnyWordSpec with Matchers { 22 | 23 | val separator: String = FileSystems.getDefault.getSeparator 24 | val tmp: String = 25 | System 26 | .getProperty("java.io.tmpdir") 27 | .stripSuffix(separator) + separator + "provider-tests-env" 28 | 29 | "should filter and match" in { 30 | val provider = new ENVSecretProvider() 31 | provider.vars = Map( 32 | "RANDOM" -> "somevalue", 33 | "CONNECT_CASSANDRA_PASSWORD" -> "secret", 34 | "BASE64" -> s"ENV-base64:${Base64.getEncoder.encodeToString("my-base64-secret".getBytes)}", 35 | "BASE64_FILE" -> s"ENV-mounted-base64:${Base64.getEncoder.encodeToString("my-base64-secret".getBytes)}", 36 | "UTF8_FILE" -> s"ENV-mounted:my-secret", 37 | ) 38 | provider.fileDir = tmp 39 | 40 | val data = provider.get("", Set("CONNECT_CASSANDRA_PASSWORD").asJava) 41 | data.data().get("CONNECT_CASSANDRA_PASSWORD") shouldBe "secret" 42 | data.data().containsKey("RANDOM") shouldBe false 43 | 44 | val data2 = 45 | provider.get("", Set("CONNECT_CASSANDRA_PASSWORD", "RANDOM").asJava) 46 | data2.data().get("CONNECT_CASSANDRA_PASSWORD") shouldBe "secret" 47 | data2.data().containsKey("RANDOM") shouldBe true 48 | 49 | val data3 = provider.get("", Set("BASE64").asJava) 50 | data3.data().get("BASE64") shouldBe "my-base64-secret" 51 | 52 | val data4 = provider.get("", Set("BASE64_FILE").asJava) 53 | val outputFile = data4.data().get("BASE64_FILE") 54 | outputFile shouldBe s"$tmp${separator}base64_file" 55 | 56 | Using(Source.fromFile(outputFile))(_.getLines().mkString) shouldBe Success( 57 | "my-base64-secret", 58 | ) 59 | 60 | val data5 = provider.get("", Set("UTF8_FILE").asJava) 61 | val outputFile5 = data5.data().get("UTF8_FILE") 62 | outputFile5 shouldBe s"$tmp${separator}utf8_file" 63 | 64 | Using(Source.fromFile(outputFile5))(_.getLines().mkString) shouldBe Success( 65 | "my-secret", 66 | ) 67 | 68 | } 69 | 70 | "check transformer" in { 71 | 72 | val provider = new ENVSecretProvider() 73 | provider.vars = Map("CONNECT_PASSWORD" -> "secret") 74 | 75 | // check the workerconfigprovider 76 | val map = new java.util.HashMap[String, ConfigProvider]() 77 | map.put("env", provider) 78 | val transformer = new ConfigTransformer(map) 79 | val props2 = 80 | Map("mykey" -> "${env::CONNECT_PASSWORD}").asJava 81 | val data = transformer.transform(props2) 82 | data.data().containsKey("value") 83 | data.data().get("mykey") shouldBe "secret" 84 | } 85 | } 86 | -------------------------------------------------------------------------------- /secret-provider/src/test/java/io/lenses/connect/secrets/vault/VaultTestUtils.java: -------------------------------------------------------------------------------- 1 | /* 2 | * 3 | * * Copyright 2017-2020 Lenses.io Ltd 4 | * 5 | */ 6 | 7 | package io.lenses.connect.secrets.vault; 8 | 9 | import io.github.jopenlibs.vault.json.Json; 10 | import io.github.jopenlibs.vault.json.JsonObject; 11 | import org.apache.commons.io.IOUtils; 12 | import org.eclipse.jetty.server.*; 13 | import org.eclipse.jetty.util.ssl.SslContextFactory; 14 | 15 | import jakarta.servlet.http.HttpServletRequest; 16 | import java.io.IOException; 17 | import java.util.Collections; 18 | import java.util.Map; 19 | import java.util.Optional; 20 | 21 | import static java.util.function.Function.identity; 22 | import static java.util.stream.Collectors.toMap; 23 | 24 | /** 25 | *

Utilities used by all of the Vault-related unit test classes under 26 | * src/test/java/com/bettercloud/vault, to setup and shutdown mock Vault server implementations.

27 | */ 28 | @SuppressWarnings("unchecked") 29 | public class VaultTestUtils { 30 | 31 | private VaultTestUtils() { 32 | } 33 | 34 | public static Server initHttpMockVault(final MockVault mock) { 35 | final Server server = new Server(8999); 36 | server.setHandler(mock); 37 | return server; 38 | } 39 | 40 | public static Server initHttpsMockVault(final MockVault mock) { 41 | final Server server = new Server(); 42 | final SslContextFactory.Server sslContextFactory = new SslContextFactory.Server(); 43 | sslContextFactory.setKeyStorePath(VaultTestUtils.class.getResource("/keystore.jks").toExternalForm()); 44 | sslContextFactory.setKeyStorePassword("password"); 45 | sslContextFactory.setKeyManagerPassword("password"); 46 | final HttpConfiguration https = new HttpConfiguration(); 47 | https.addCustomizer(new SecureRequestCustomizer()); 48 | final ServerConnector sslConnector = new ServerConnector( 49 | server, 50 | new SslConnectionFactory(sslContextFactory, "http/1.1"), 51 | new HttpConnectionFactory(https) 52 | ); 53 | sslConnector.setPort(9998); 54 | server.setConnectors(new Connector[]{sslConnector}); 55 | 56 | server.setHandler(mock); 57 | return server; 58 | } 59 | 60 | public static void shutdownMockVault(final Server server) throws Exception { 61 | int attemptCount = 0; 62 | while (!server.isStopped() && attemptCount < 5) { 63 | attemptCount++; 64 | server.stop(); 65 | Thread.sleep(1000); 66 | } 67 | } 68 | 69 | public static Optional readRequestBody(HttpServletRequest request) { 70 | try { 71 | StringBuilder requestBuffer = new StringBuilder(); 72 | IOUtils.readLines(request.getReader()).forEach(requestBuffer::append); 73 | String string = requestBuffer.toString(); 74 | return string.isEmpty() ? Optional.empty() : Optional.of(Json.parse(string).asObject()); 75 | } catch (IOException e) { 76 | return Optional.empty(); 77 | } 78 | } 79 | 80 | public static Map readRequestHeaders(HttpServletRequest request) { 81 | return Collections.list(request.getHeaderNames()).stream().collect(toMap(identity(), request::getHeader)); 82 | } 83 | 84 | } 85 | -------------------------------------------------------------------------------- /secret-provider/src/test/scala/io/lenses/connect/secrets/providers/DecodeTest.scala: -------------------------------------------------------------------------------- 1 | /* 2 | * 3 | * * Copyright 2017-2020 Lenses.io Ltd 4 | * 5 | */ 6 | 7 | package io.lenses.connect.secrets.providers 8 | 9 | import io.lenses.connect.secrets.connect 10 | import io.lenses.connect.secrets.connect.Encoding 11 | import org.apache.commons.io.FileUtils 12 | import org.scalatest.matchers.should.Matchers 13 | import org.scalatest.wordspec.AnyWordSpec 14 | 15 | import java.io.File 16 | import java.nio.file.FileSystems 17 | import java.time.OffsetDateTime 18 | import java.util.Base64 19 | 20 | class DecodeTest extends AnyWordSpec with Matchers { 21 | 22 | val separator: String = FileSystems.getDefault.getSeparator 23 | val tmp: String = 24 | System.getProperty("java.io.tmpdir") + separator + "decoder-tests" 25 | 26 | def cleanUp(fileName: String): AnyVal = { 27 | val tmpFile = new File(fileName) 28 | if (tmpFile.exists) tmpFile.delete() 29 | } 30 | 31 | "should decode UTF" in { 32 | connect.decodeKey(None, "my-key", "secret", _ => fail("No files here")) shouldBe "secret" 33 | connect.decodeKey(Some(Encoding.UTF8), "my-key", "secret", _ => fail("No files here")) shouldBe "secret" 34 | } 35 | 36 | "should decode BASE64" in { 37 | val value = Base64.getEncoder.encodeToString("secret".getBytes) 38 | connect.decodeKey(Some(Encoding.BASE64), s"my-key", value, _ => fail("No files here")) shouldBe "secret" 39 | } 40 | 41 | "should decode BASE64 and write to a file" in { 42 | val fileName = s"${tmp}my-file-base64" 43 | 44 | val value = Base64.getEncoder.encodeToString("secret".getBytes) 45 | var written = false 46 | connect.decodeKey( 47 | Some(Encoding.BASE64_FILE), 48 | s"my-key", 49 | value, 50 | { _ => 51 | written = true 52 | fileName 53 | }, 54 | ) shouldBe fileName 55 | written shouldBe true 56 | } 57 | 58 | "should decode and write a jks" in { 59 | val fileName = s"${tmp}my-file-base64-jks" 60 | val jksFile: String = 61 | getClass.getClassLoader.getResource("keystore.jks").getPath 62 | val fileContent = FileUtils.readFileToByteArray(new File(jksFile)) 63 | val jksEncoded = Base64.getEncoder.encodeToString(fileContent) 64 | 65 | var written = false 66 | connect.decodeKey( 67 | Some(Encoding.BASE64_FILE), 68 | s"keystore.jks", 69 | jksEncoded, 70 | { _ => 71 | written = true 72 | fileName 73 | }, 74 | ) shouldBe fileName 75 | 76 | written shouldBe true 77 | } 78 | 79 | "should decode UTF8 and write to a file" in { 80 | val fileName = s"${tmp}my-file-utf8" 81 | var written = false 82 | 83 | connect.decodeKey( 84 | Some(Encoding.UTF8_FILE), 85 | s"my-key", 86 | "secret", 87 | { _ => 88 | written = true 89 | fileName 90 | }, 91 | ) shouldBe fileName 92 | written shouldBe true 93 | } 94 | 95 | "min list test" in { 96 | val now = OffsetDateTime.now() 97 | val secrets = Map( 98 | "ke3" -> ("value", Some(OffsetDateTime.now().plusHours(3))), 99 | "key1" -> ("value", Some(now)), 100 | "key2" -> ("value", Some(OffsetDateTime.now().plusHours(1))), 101 | ) 102 | 103 | val (expiry, _) = connect.getSecretsAndExpiry(secrets) 104 | expiry shouldBe Some(now) 105 | } 106 | } 107 | -------------------------------------------------------------------------------- /secret-provider/src/main/scala/io/lenses/connect/secrets/providers/Aes256DecodingHelper.scala: -------------------------------------------------------------------------------- 1 | package io.lenses.connect.secrets.providers 2 | 3 | import java.security.SecureRandom 4 | import java.util.Base64 5 | import javax.crypto.Cipher 6 | import javax.crypto.spec.IvParameterSpec 7 | import javax.crypto.spec.SecretKeySpec 8 | import scala.util.Failure 9 | import scala.util.Try 10 | 11 | private[providers] object Aes256DecodingHelper { 12 | 13 | /** characters used to separate initialisation vector from encoded text */ 14 | val INITIALISATION_VECTOR_SEPARATOR = " " 15 | 16 | private val BYTES_AMOUNT = 32 17 | private val CHARSET = "UTF-8" 18 | 19 | /** 20 | * Initializes AES256 decoder for valid key or fails for invalid key 21 | * 22 | * @param key key of 32 bytes 23 | * @return AES256 decoder 24 | */ 25 | def init(key: String): Either[String, Aes256DecodingHelper] = 26 | key.getBytes(CHARSET).length match { 27 | case BYTES_AMOUNT => 28 | Right(new Aes256DecodingHelper(key, INITIALISATION_VECTOR_SEPARATOR)) 29 | case n => 30 | Left(s"Invalid secret key length ($n - required $BYTES_AMOUNT bytes)") 31 | } 32 | } 33 | 34 | private[providers] class Aes256DecodingHelper private ( 35 | key: String, 36 | ivSeparator: String, 37 | ) { 38 | 39 | import Aes256DecodingHelper.CHARSET 40 | import B64._ 41 | 42 | private val secret = new SecretKeySpec(key.getBytes(CHARSET), "AES") 43 | 44 | def decrypt(s: String): Try[String] = 45 | for { 46 | (iv, encoded) <- InitializationVector.extractInitialisationVector( 47 | s, 48 | ivSeparator, 49 | ) 50 | decoded <- base64Decode(encoded) 51 | decrypted <- decryptBytes(iv, decoded) 52 | } yield new String(decrypted, CHARSET) 53 | 54 | private def decryptBytes( 55 | iv: InitializationVector, 56 | bytes: Array[Byte], 57 | ): Try[Array[Byte]] = 58 | for { 59 | cipher <- getCipher(Cipher.DECRYPT_MODE, iv) 60 | encrypted <- Try(cipher.doFinal(bytes)) 61 | } yield encrypted 62 | 63 | private def getCipher(mode: Int, iv: InitializationVector): Try[Cipher] = 64 | Try { 65 | val cipher = Cipher.getInstance("AES/CBC/PKCS5Padding") 66 | val ivSpec = new IvParameterSpec(iv.bytes) 67 | cipher.init(mode, secret, ivSpec) 68 | cipher 69 | } 70 | } 71 | 72 | private case class InitializationVector private (bytes: Array[Byte]) 73 | 74 | private object InitializationVector { 75 | 76 | import B64._ 77 | 78 | private val random = new SecureRandom() 79 | private val length = 16 80 | 81 | def apply(): InitializationVector = { 82 | val bytes = Array.fill[Byte](length)(0.byteValue) 83 | random.nextBytes(bytes) 84 | 85 | new InitializationVector(bytes) 86 | } 87 | 88 | def extractInitialisationVector( 89 | s: String, 90 | ivSeparator: String, 91 | ): Try[(InitializationVector, String)] = 92 | s.indexOf(ivSeparator) match { 93 | case -1 => 94 | Failure( 95 | new IllegalStateException( 96 | "Invalid format: missing initialization vector", 97 | ), 98 | ) 99 | case i => 100 | base64Decode(s.substring(0, i)).map(b => (new InitializationVector(b), s.substring(i + 1))) 101 | } 102 | } 103 | 104 | private object B64 { 105 | def base64Decode(s: String): Try[Array[Byte]] = 106 | Try(Base64.getDecoder().decode(s)) 107 | } 108 | -------------------------------------------------------------------------------- /secret-provider/src/test/java/io/lenses/connect/secrets/vault/MockVault.java: -------------------------------------------------------------------------------- 1 | /* 2 | * 3 | * * Copyright 2017-2020 Lenses.io Ltd 4 | * 5 | */ 6 | 7 | package io.lenses.connect.secrets.vault; 8 | 9 | import io.github.jopenlibs.vault.json.JsonObject; 10 | import org.eclipse.jetty.server.Request; 11 | import org.eclipse.jetty.server.handler.AbstractHandler; 12 | 13 | import jakarta.servlet.ServletException; 14 | import jakarta.servlet.http.HttpServletRequest; 15 | import jakarta.servlet.http.HttpServletResponse; 16 | import java.io.IOException; 17 | import java.util.Map; 18 | import java.util.Optional; 19 | 20 | import static io.lenses.connect.secrets.vault.VaultTestUtils.readRequestBody; 21 | import static io.lenses.connect.secrets.vault.VaultTestUtils.readRequestHeaders; 22 | 23 | /** 24 | *

This class is used to mock out a Vault server in unit tests involving retry logic. As it extends Jetty's 25 | * AbstractHandler, it can be passed to an embedded Jetty server and respond to actual (albeit 26 | * localhost) HTTP requests.

27 | * 28 | *

This basic version simply responds to requests with a pre-determined HTTP status code and response body. 29 | * Example usage:

30 | * 31 | *
32 | *
{@code
33 |  * final Server server = new Server(8999);
34 |  * server.setHandler( new MockVault(200, "{\"data\":{\"value\":\"mock\"}}") );
35 |  * server.start();
36 |  *
37 |  * final VaultConfig vaultConfig = new VaultConfig().address("http://127.0.0.1:8999").token("mock_token").build();
38 |  * final Vault vault = new Vault(vaultConfig);
39 |  * final LogicalResponse response = vault.logical().read("secret/hello");
40 |  *
41 |  * assertEquals(200, response.getRestResponse().getStatus());
42 |  * assertEquals("mock", response.getData().get("value"));
43 |  *
44 |  * VaultTestUtils.shutdownMockVault(server);
45 |  * }
46 | *
47 | */ 48 | public class MockVault extends AbstractHandler { 49 | 50 | private int mockStatus; 51 | private String mockResponse; 52 | private JsonObject requestBody; 53 | private Map requestHeaders; 54 | private String requestUrl; 55 | 56 | MockVault() { 57 | } 58 | 59 | public MockVault(final int mockStatus, final String mockResponse) { 60 | this.mockStatus = mockStatus; 61 | this.mockResponse = mockResponse; 62 | } 63 | 64 | @Override 65 | public void handle( 66 | final String target, 67 | final Request baseRequest, 68 | final HttpServletRequest request, 69 | final HttpServletResponse response 70 | ) throws IOException, ServletException { 71 | requestBody = readRequestBody(request).orElse(null); 72 | requestHeaders = readRequestHeaders(request); 73 | requestUrl = request.getRequestURL().toString(); 74 | response.setContentType("application/json"); 75 | baseRequest.setHandled(true); 76 | System.out.println("MockVault is sending an HTTP " + mockStatus + " code, with expected success payload..."); 77 | response.setStatus(mockStatus); 78 | if (mockResponse != null) { 79 | response.getWriter().println(mockResponse); 80 | } 81 | } 82 | 83 | public Optional getRequestBody() { 84 | return Optional.ofNullable(requestBody); 85 | } 86 | 87 | public Map getRequestHeaders() { 88 | return requestHeaders; 89 | } 90 | 91 | public String getRequestUrl() { 92 | return requestUrl; 93 | } 94 | 95 | } 96 | -------------------------------------------------------------------------------- /secret-provider/src/main/scala/io/lenses/connect/secrets/providers/AzureHelper.scala: -------------------------------------------------------------------------------- 1 | /* 2 | * 3 | * * Copyright 2017-2020 Lenses.io Ltd 4 | * 5 | */ 6 | 7 | package io.lenses.connect.secrets.providers 8 | 9 | import com.azure.core.credential.TokenCredential 10 | import com.azure.identity.ClientSecretCredentialBuilder 11 | import com.azure.identity.DefaultAzureCredentialBuilder 12 | import com.azure.security.keyvault.secrets.SecretClient 13 | import com.azure.security.keyvault.secrets.models.SecretProperties 14 | import com.typesafe.scalalogging.StrictLogging 15 | import io.lenses.connect.secrets.config.AzureProviderSettings 16 | import io.lenses.connect.secrets.connect 17 | import io.lenses.connect.secrets.connect.Encoding.Encoding 18 | import io.lenses.connect.secrets.connect._ 19 | import org.apache.kafka.connect.errors.ConnectException 20 | 21 | import java.nio.file.FileSystems 22 | import java.time.OffsetDateTime 23 | import scala.jdk.CollectionConverters.MapHasAsScala 24 | import scala.util.Failure 25 | import scala.util.Success 26 | import scala.util.Try 27 | 28 | trait AzureHelper extends StrictLogging { 29 | 30 | private val separator: String = FileSystems.getDefault.getSeparator 31 | 32 | // look up secret in Azure 33 | def getSecretValue( 34 | rootDir: String, 35 | path: String, 36 | client: SecretClient, 37 | key: String, 38 | ): (String, Option[OffsetDateTime]) = 39 | Try(client.getSecret(key)) match { 40 | case Success(secret) => 41 | val value = secret.getValue 42 | val props = secret.getProperties 43 | 44 | val content = retrieveEncodingFromTags(props) match { 45 | case Encoding.UTF8 => 46 | value 47 | 48 | case Encoding.UTF8_FILE => 49 | val fileName = 50 | getFileName(rootDir, path, key.toLowerCase, separator) 51 | fileWriter( 52 | fileName, 53 | value.getBytes, 54 | key.toLowerCase, 55 | ) 56 | fileName 57 | 58 | case Encoding.BASE64 => 59 | decode(key, value) 60 | 61 | // write to file and set the file name as the value 62 | case Encoding.BASE64_FILE | Encoding.UTF8_FILE => 63 | val fileName = 64 | getFileName(rootDir, path, key.toLowerCase, separator) 65 | val decoded = decodeToBytes(key, value) 66 | fileWriter( 67 | fileName, 68 | decoded, 69 | key.toLowerCase, 70 | ) 71 | fileName 72 | } 73 | 74 | val expiry = Option(props.getExpiresOn) 75 | (content, expiry) 76 | 77 | case Failure(e) => 78 | throw new ConnectException( 79 | s"Failed to look up secret [$key] at [${client.getVaultUrl}]", 80 | e, 81 | ) 82 | } 83 | 84 | private def retrieveEncodingFromTags(props: SecretProperties): connect.Encoding.Value = 85 | // check the file-encoding 86 | { 87 | for { 88 | propsMap <- Option(props.getTags.asScala) 89 | fileEncodingFromPropsMap <- propsMap.get(FILE_ENCODING) 90 | enc <- Encoding.withoutHyphensInsensitiveOpt(fileEncodingFromPropsMap) 91 | } yield enc 92 | }.getOrElse(Encoding.UTF8) 93 | 94 | // setup azure credentials 95 | def createCredentials(settings: AzureProviderSettings): TokenCredential = { 96 | 97 | logger.info( 98 | s"Initializing client with mode [${settings.authMode.toString}]", 99 | ) 100 | 101 | settings.authMode match { 102 | case AuthMode.CREDENTIALS => 103 | new ClientSecretCredentialBuilder() 104 | .clientId(settings.clientId) 105 | .clientSecret(settings.secretId.value()) 106 | .tenantId(settings.tenantId) 107 | .build() 108 | 109 | case _ => 110 | new DefaultAzureCredentialBuilder().build() 111 | 112 | } 113 | } 114 | } 115 | -------------------------------------------------------------------------------- /secret-provider/src/it/scala/io/lenses/connect/secrets/testcontainers/VaultContainer.scala: -------------------------------------------------------------------------------- 1 | package io.lenses.connect.secrets.testcontainers 2 | 3 | import cats.effect.IO 4 | import cats.effect.Resource 5 | import io.github.jopenlibs.vault.Vault 6 | import io.github.jopenlibs.vault.VaultConfig 7 | import io.github.jopenlibs.vault.api.sys.mounts.MountPayload 8 | import io.github.jopenlibs.vault.api.sys.mounts.MountType 9 | import io.github.jopenlibs.vault.api.sys.mounts.TimeToLive 10 | import io.github.jopenlibs.vault.response.MountResponse 11 | import com.typesafe.scalalogging.LazyLogging 12 | import io.lenses.connect.secrets.testcontainers.VaultContainer._ 13 | import org.scalatest.matchers.should.Matchers 14 | import org.testcontainers.utility.DockerImageName 15 | import org.testcontainers.vault.{ VaultContainer => JavaVaultContainer } 16 | 17 | import java.util.UUID 18 | import java.util.concurrent.TimeUnit 19 | import scala.jdk.CollectionConverters.MapHasAsJava 20 | 21 | case class LeaseInfo(defaultLeaseTimeSeconds: Int, maxLeaseTimeSeconds: Int) { 22 | def toDurString(): String = 23 | s""""default_lease_ttl": "${defaultLeaseTimeSeconds}s", "max_lease_ttl": "${maxLeaseTimeSeconds}s",""" 24 | } 25 | 26 | class VaultContainer(maybeLeaseInfo: Option[LeaseInfo] = Option.empty) 27 | extends SingleContainer[JavaVaultContainer[_]] 28 | with Matchers 29 | with LazyLogging { 30 | 31 | private val token: String = UUID.randomUUID().toString 32 | 33 | override val container: JavaVaultContainer[_] = new JavaVaultContainer( 34 | VaultDockerImageName, 35 | ) 36 | .withNetworkAliases(defaultNetworkAlias) 37 | 38 | container.withEnv( 39 | "VAULT_LOCAL_CONFIG", //"listener": [{"tcp": { "address": "0.0.0.0:8200", "tls_disable": true}}], 40 | s""" 41 | |{${maybeLeaseInfo.fold("")(_.toDurString())} "ui": true}""".stripMargin, 42 | ) 43 | 44 | container.withVaultToken(token) 45 | 46 | def vaultClientResource: Resource[IO, Vault] = 47 | Resource.make( 48 | IO( 49 | new Vault( 50 | new VaultConfig() 51 | .address(container.getHttpHostAddress) 52 | .token(token) 53 | .engineVersion(2) 54 | .build(), 55 | ), 56 | ), 57 | )(_ => IO.unit) 58 | 59 | def configureMount(secretTtlSeconds: Long): IO[MountResponse] = 60 | vaultClientResource.use(client => 61 | IO { 62 | logger.info("Configuring mount") 63 | val mountPayload = new MountPayload() 64 | mountPayload.defaultLeaseTtl(TimeToLive.of(secretTtlSeconds.intValue(), TimeUnit.SECONDS)) 65 | client.mounts().enable(vaultRootPath, MountType.KEY_VALUE, mountPayload) 66 | }, 67 | ) 68 | 69 | def rotateSecret( 70 | secretTtlSeconds: Long, 71 | ): IO[String] = 72 | vaultClientResource.use(client => 73 | IO { 74 | val time = System.currentTimeMillis() 75 | val newSecretValue = s"${vaultSecretPrefix}_$time" 76 | logger.info(s"Rotating secret to {}", newSecretValue) 77 | client 78 | .logical() 79 | .write( 80 | vaultSecretPath, 81 | Map[String, Object]( 82 | vaultSecretKey -> s"${vaultSecretPrefix}_$time", 83 | "ttl" -> s"${secretTtlSeconds}s", 84 | "created" -> Long.box(time), 85 | "expires" -> Long.box(time + (secretTtlSeconds * 1000)), 86 | ).asJava, 87 | ) 88 | newSecretValue 89 | }, 90 | ) 91 | 92 | def vaultToken = token 93 | 94 | def vaultAddress = container.getHttpHostAddress 95 | 96 | def networkVaultAddress: String = 97 | String.format("http://%s:%s", defaultNetworkAlias, vaultPort) 98 | 99 | } 100 | 101 | object VaultContainer { 102 | val VaultDockerImageName: DockerImageName = 103 | DockerImageName.parse("vault").withTag("1.12.3") 104 | val vaultRootPath = "rotate-test" // was: secret/ 105 | val vaultSecretPath = s"$vaultRootPath/myVaultSecretPath" 106 | val vaultSecretKey = "myVaultSecretKey" 107 | val vaultSecretPrefix = "myVaultSecretValue" 108 | 109 | private val defaultNetworkAlias = "vault" 110 | private val vaultPort: Int = 8200 111 | } 112 | -------------------------------------------------------------------------------- /secret-provider/src/main/scala/io/lenses/connect/secrets/providers/AzureSecretProvider.scala: -------------------------------------------------------------------------------- 1 | /* 2 | * 3 | * * Copyright 2017-2020 Lenses.io Ltd 4 | * 5 | */ 6 | 7 | package io.lenses.connect.secrets.providers 8 | 9 | import java.time.Duration 10 | 11 | import com.azure.core.credential.TokenCredential 12 | import com.azure.security.keyvault.secrets.SecretClient 13 | import com.azure.security.keyvault.secrets.SecretClientBuilder 14 | import io.lenses.connect.secrets.config.AzureProviderConfig 15 | import io.lenses.connect.secrets.config.AzureProviderSettings 16 | import io.lenses.connect.secrets.connect.getSecretsAndExpiry 17 | import org.apache.kafka.common.config.ConfigData 18 | import org.apache.kafka.common.config.provider.ConfigProvider 19 | 20 | import java.time.OffsetDateTime 21 | import java.util 22 | import scala.collection.mutable 23 | import scala.jdk.CollectionConverters._ 24 | 25 | class AzureSecretProvider() extends ConfigProvider with AzureHelper { 26 | 27 | private var rootDir: String = _ 28 | private var credentials: Option[TokenCredential] = None 29 | val clientMap: mutable.Map[String, SecretClient] = mutable.Map.empty 30 | val cache = mutable.Map.empty[String, (Option[OffsetDateTime], Map[String, String])] 31 | 32 | // configure the vault client 33 | override def configure(configs: util.Map[String, _]): Unit = { 34 | val settings = AzureProviderSettings(AzureProviderConfig(configs)) 35 | rootDir = settings.fileDir 36 | credentials = Some(createCredentials(settings)) 37 | } 38 | 39 | // lookup secrets at a path 40 | // returns and empty map since Azure is flat 41 | // and we need to know the secret to lookup 42 | override def get(path: String): ConfigData = 43 | new ConfigData(Map.empty[String, String].asJava) 44 | 45 | // get secret keys at a path 46 | // paths is expected to be the url of the azure keyvault without the protocol (https://) 47 | // since the connect work will not parse it correctly do to the : 48 | // including the azure environment. 49 | override def get(path: String, keys: util.Set[String]): ConfigData = { 50 | 51 | val keyVaultUrl = 52 | if (path.startsWith("https://")) path else s"https://$path" 53 | 54 | // don't need to cache but allows for testing 55 | // this way we don't require a keyvault set in the 56 | // worker properties and we take the path as the target keyvault 57 | val client = clientMap.getOrElse( 58 | keyVaultUrl, 59 | new SecretClientBuilder() 60 | .vaultUrl(keyVaultUrl) 61 | .credential(credentials.get) 62 | .buildClient, 63 | ) 64 | 65 | clientMap += (keyVaultUrl -> client) 66 | 67 | val now = OffsetDateTime.now() 68 | 69 | val (expiry, data) = cache.get(keyVaultUrl) match { 70 | case Some((expiresAt, data)) => 71 | // we have all the keys and are before the expiry 72 | 73 | if ( 74 | keys.asScala.subsetOf(data.keySet) && expiresAt 75 | .getOrElse(now.plusSeconds(1)) 76 | .isAfter(now) 77 | ) { 78 | logger.info("Fetching secrets from cache") 79 | (expiresAt, data.view.filterKeys(k => keys.contains(k)).toMap) 80 | } else { 81 | // missing some or expired so reload 82 | getSecretsAndExpiry(getSecrets(client, keys.asScala.toSet)) 83 | } 84 | 85 | case None => 86 | getSecretsAndExpiry(getSecrets(client, keys.asScala.toSet)) 87 | } 88 | 89 | var ttl = 0L 90 | expiry.foreach { exp => 91 | ttl = Duration.between(now, exp).toMillis 92 | logger.info(s"Min expiry for TTL set to [${exp.toString}]") 93 | } 94 | cache.put(keyVaultUrl, (expiry, data)) 95 | new ConfigData(data.asJava, ttl) 96 | } 97 | 98 | override def close(): Unit = {} 99 | 100 | private def getSecrets( 101 | client: SecretClient, 102 | keys: Set[String], 103 | ): Map[String, (String, Option[OffsetDateTime])] = { 104 | val path = client.getVaultUrl.stripPrefix("https://") 105 | keys.map { key => 106 | logger.info(s"Looking up value at [$path] for key [$key]") 107 | val (value, expiry) = getSecretValue(rootDir, path, client, key) 108 | (key, (value, expiry)) 109 | }.toMap 110 | } 111 | } 112 | -------------------------------------------------------------------------------- /secret-provider/src/it/scala/io/lenses/connect/secrets/testcontainers/KafkaConnectContainer.scala: -------------------------------------------------------------------------------- 1 | package io.lenses.connect.secrets.testcontainers 2 | 3 | import com.github.dockerjava.api.model.Ulimit 4 | import io.lenses.connect.secrets.testcontainers.KafkaConnectContainer.defaultNetworkAlias 5 | import io.lenses.connect.secrets.testcontainers.KafkaConnectContainer.defaultRestPort 6 | import io.lenses.connect.secrets.testcontainers.connect.ConfigProviders 7 | import org.testcontainers.containers.GenericContainer 8 | import org.testcontainers.containers.KafkaContainer 9 | import org.testcontainers.containers.wait.strategy.Wait 10 | import org.testcontainers.utility.DockerImageName 11 | 12 | import java.time.Duration 13 | import scala.jdk.CollectionConverters.MapHasAsJava 14 | 15 | class KafkaConnectContainer( 16 | dockerImage: String, 17 | networkAlias: String = defaultNetworkAlias, 18 | restPort: Int = defaultRestPort, 19 | connectPluginPath: Map[String, String] = Map.empty, 20 | kafkaContainer: KafkaContainer, 21 | maybeConfigProviders: Option[ConfigProviders], 22 | ) extends GenericContainer[KafkaConnectContainer](DockerImageName.parse(dockerImage)) { 23 | require(kafkaContainer != null, "You must define the kafka container") 24 | 25 | withNetwork(kafkaContainer.getNetwork) 26 | withNetworkAliases(networkAlias) 27 | withExposedPorts(restPort) 28 | waitingFor( 29 | Wait 30 | .forHttp("/connectors") 31 | .forPort(restPort) 32 | .forStatusCode(200), 33 | ).withStartupTimeout(Duration.ofSeconds(120)) 34 | withCreateContainerCmdModifier { cmd => 35 | val _ = 36 | cmd.getHostConfig.withUlimits(Array(new Ulimit("nofile", 65536L, 65536L))) 37 | } 38 | 39 | connectPluginPath.foreach { 40 | case (k, v) => withFileSystemBind(v, s"/usr/share/plugins/$k") 41 | } 42 | 43 | withEnv("CONNECT_KEY_CONVERTER_SCHEMAS_ENABLE", "false") 44 | withEnv("CONNECT_VALUE_CONVERTER_SCHEMAS_ENABLE", "false") 45 | withEnv( 46 | "CONNECT_KEY_CONVERTER", 47 | "org.apache.kafka.connect.storage.StringConverter", 48 | ) 49 | withEnv( 50 | "CONNECT_VALUE_CONVERTER", 51 | "org.apache.kafka.connect.storage.StringConverter", 52 | ) 53 | 54 | withEnv("CONNECT_REST_ADVERTISED_HOST_NAME", networkAlias) 55 | withEnv("CONNECT_REST_PORT", restPort.toString) 56 | withEnv("CONNECT_BOOTSTRAP_SERVERS", kafkaContainer.bootstrapServers) 57 | withEnv("CONNECT_GROUP_ID", "io/lenses/connect/secrets/integration/connect") 58 | withEnv("CONNECT_CONFIG_STORAGE_TOPIC", "connect_config") 59 | withEnv("CONNECT_OFFSET_STORAGE_TOPIC", "connect_offset") 60 | withEnv("CONNECT_STATUS_STORAGE_TOPIC", "connect_status") 61 | withEnv("CONNECT_CONFIG_STORAGE_REPLICATION_FACTOR", "1") 62 | withEnv("CONNECT_OFFSET_STORAGE_REPLICATION_FACTOR", "1") 63 | withEnv("CONNECT_STATUS_STORAGE_REPLICATION_FACTOR", "1") 64 | withEnv( 65 | "CONNECT_PLUGIN_PATH", 66 | "/usr/share/java,/usr/share/confluent-hub-components,/usr/share/plugins", 67 | ) 68 | 69 | maybeConfigProviders.map(cpc => withEnv(cpc.toEnvMap.asJava)) 70 | 71 | lazy val hostNetwork = new HostNetwork() 72 | 73 | class HostNetwork { 74 | def restEndpointUrl: String = s"http://$getHost:${getMappedPort(restPort)}" 75 | } 76 | 77 | } 78 | object KafkaConnectContainer { 79 | private val dockerImage = 80 | DockerImageName.parse("confluentinc/cp-kafka-connect") 81 | private val defaultConfluentPlatformVersion: String = 82 | sys.env.getOrElse("CONFLUENT_VERSION", "7.3.1") 83 | private val defaultNetworkAlias = "connect" 84 | private val defaultRestPort = 8083 85 | 86 | def apply( 87 | confluentPlatformVersion: String = defaultConfluentPlatformVersion, 88 | networkAlias: String = defaultNetworkAlias, 89 | restPort: Int = defaultRestPort, 90 | connectPluginPathMap: Map[String, String] = Map.empty, 91 | kafkaContainer: KafkaContainer, 92 | maybeConfigProviders: Option[ConfigProviders], 93 | ): KafkaConnectContainer = 94 | new KafkaConnectContainer( 95 | dockerImage.withTag(confluentPlatformVersion).toString, 96 | networkAlias, 97 | restPort, 98 | connectPluginPathMap, 99 | kafkaContainer, 100 | maybeConfigProviders, 101 | ) 102 | } 103 | -------------------------------------------------------------------------------- /secret-provider/src/test/scala/io/lenses/connect/secrets/providers/VaultHelperTest.scala: -------------------------------------------------------------------------------- 1 | package io.lenses.connect.secrets.providers 2 | import io.github.jopenlibs.vault.Vault 3 | import io.github.jopenlibs.vault.response.DatabaseResponse 4 | import io.github.jopenlibs.vault.response.LogicalResponse 5 | import io.lenses.connect.secrets.cache.Ttl 6 | import io.lenses.connect.secrets.cache.ValueWithTtl 7 | import io.lenses.connect.secrets.io.FileWriter 8 | import org.mockito.Answers 9 | import org.mockito.Mockito.when 10 | import org.scalatest.funsuite.AnyFunSuite 11 | import org.scalatest.matchers.should.Matchers 12 | import org.scalatest.BeforeAndAfterAll 13 | import org.scalatest.EitherValues 14 | import org.scalatest.OptionValues 15 | import org.scalatestplus.mockito.MockitoSugar 16 | import java.time._ 17 | import java.util.{ Map => JavaMap } 18 | import scala.jdk.CollectionConverters._ 19 | 20 | class VaultHelperTest 21 | extends AnyFunSuite 22 | with Matchers 23 | with BeforeAndAfterAll 24 | with MockitoSugar 25 | with OptionValues 26 | with EitherValues { 27 | 28 | private val notFoundResponse = { 29 | val emptyResponse = mock[LogicalResponse](Answers.RETURNS_DEEP_STUBS) 30 | when(emptyResponse.getRestResponse.getStatus).thenReturn(404) 31 | when(emptyResponse.getRestResponse.getBody).thenReturn("Not found".getBytes) 32 | emptyResponse 33 | } 34 | 35 | private val validResponse: LogicalResponse = { 36 | val sampleResponseData: JavaMap[String, String] = Map("key1" -> "value1", "key2" -> "value2").asJava 37 | val sampleResponse: LogicalResponse = mock[LogicalResponse](Answers.RETURNS_DEEP_STUBS) 38 | when(sampleResponse.getData).thenReturn(sampleResponseData) 39 | when(sampleResponse.getRestResponse.getStatus).thenReturn(200) 40 | sampleResponse 41 | } 42 | 43 | private val validDatabaseResponse: DatabaseResponse = { 44 | val sampleResponseData: JavaMap[String, String] = Map("key1" -> "value1", "key2" -> "value2").asJava 45 | val sampleResponse: DatabaseResponse = mock[DatabaseResponse](Answers.RETURNS_DEEP_STUBS) 46 | when(sampleResponse.getData).thenReturn(sampleResponseData) 47 | when(sampleResponse.getRestResponse.getStatus).thenReturn(200) 48 | sampleResponse 49 | } 50 | 51 | private val clock: Clock = Clock.fixed(Instant.now(), ZoneId.systemDefault()) 52 | private val defaultTtl: Duration = Duration.ofMinutes(10) 53 | private val expiry: OffsetDateTime = clock.instant().plus(defaultTtl).atZone(clock.getZone).toOffsetDateTime 54 | 55 | private val fileWriterCreateFn: () => Option[FileWriter] = () => None 56 | 57 | test("VaultHelper.lookup should fetch secrets from Vault and return them as a ValueWithTtl") { 58 | 59 | val vaultClient: Vault = mock[Vault](Answers.RETURNS_DEEP_STUBS) 60 | when(vaultClient.logical().read("secret/path")).thenReturn(validResponse) 61 | 62 | val vaultHelper: VaultHelper = new VaultHelper(vaultClient, Some(defaultTtl), fileWriterCreateFn)(clock) 63 | 64 | val result = vaultHelper.lookup("secret/path") 65 | 66 | result.value shouldBe ValueWithTtl[Map[String, String]](Some(Ttl(defaultTtl, expiry)), 67 | Map("key1" -> "value1", "key2" -> "value2"), 68 | ) 69 | } 70 | 71 | test( 72 | "VaultHelper.lookup should fetch secrets from Vault Database Credentials Engine and return them as a ValueWithTtl", 73 | ) { 74 | 75 | val vaultClient: Vault = mock[Vault](Answers.RETURNS_DEEP_STUBS) 76 | when(vaultClient.database().creds("sausages")).thenReturn(validDatabaseResponse) 77 | 78 | val vaultHelper: VaultHelper = new VaultHelper(vaultClient, Some(defaultTtl), fileWriterCreateFn)(clock) 79 | 80 | val result = vaultHelper.lookup("database/creds/sausages") 81 | 82 | result.value shouldBe ValueWithTtl[Map[String, String]](Some(Ttl(defaultTtl, expiry)), 83 | Map("key1" -> "value1", "key2" -> "value2"), 84 | ) 85 | } 86 | 87 | test("VaultHelper.lookup should handle secrets not found at the specified path") { 88 | val vaultClient: Vault = mock[Vault](Answers.RETURNS_DEEP_STUBS) 89 | when(vaultClient.logical().read("empty/path")).thenReturn(notFoundResponse) 90 | 91 | val vaultHelper: VaultHelper = new VaultHelper(vaultClient, Some(defaultTtl), fileWriterCreateFn)(clock) 92 | 93 | val result = vaultHelper.lookup("empty/path") 94 | 95 | result.left.value.getMessage should include("No secrets found at path [empty/path]") 96 | } 97 | 98 | } 99 | -------------------------------------------------------------------------------- /project/Dependencies.scala: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (c) 2017-2020 Lenses.io Ltd 3 | */ 4 | 5 | import sbt._ 6 | 7 | trait Dependencies { 8 | 9 | object Versions { 10 | 11 | val scalaLoggingVersion = "3.9.5" 12 | val kafkaVersion = "3.4.0" 13 | val vaultVersion = "5.3.0" 14 | val azureKeyVaultVersion = "4.5.2" 15 | val azureIdentityVersion = "1.8.1" 16 | 17 | val awsSdkV2Version = "2.20.26" 18 | 19 | //test 20 | val scalaTestVersion = "3.2.15" 21 | val mockitoVersion = "3.2.15.0" 22 | val byteBuddyVersion = "1.14.2" 23 | val slf4jVersion = "2.0.5" 24 | val commonsIOVersion = "1.3.2" 25 | val jettyVersion = "11.0.14" 26 | val flexmarkVersion = "0.64.0" 27 | val jsonSmartVersion = "2.5.0" 28 | 29 | val scalaCollectionCompatVersion = "2.8.1" 30 | val jakartaServletVersion = "6.0.0" 31 | val testContainersVersion = "1.17.6" 32 | val json4sVersion = "4.0.6" 33 | val catsEffectVersion = "3.4.8" 34 | 35 | } 36 | 37 | object Dependencies { 38 | 39 | import Versions._ 40 | 41 | val `scala-logging` = 42 | "com.typesafe.scala-logging" %% "scala-logging" % scalaLoggingVersion 43 | val `kafka-connect-api` = "org.apache.kafka" % "connect-api" % kafkaVersion 44 | val `vault-java-driver` = 45 | "io.github.jopenlibs" % "vault-java-driver" % vaultVersion 46 | val `azure-key-vault` = 47 | "com.azure" % "azure-security-keyvault-secrets" % azureKeyVaultVersion 48 | val `azure-identity` = "com.azure" % "azure-identity" % azureIdentityVersion 49 | 50 | lazy val awsSecretsManagerSdkV2 = "software.amazon.awssdk" % "secretsmanager" % awsSdkV2Version 51 | lazy val awsIamSdkV2 = "software.amazon.awssdk" % "iam" % awsSdkV2Version 52 | lazy val awsStsSdkV2 = "software.amazon.awssdk" % "sts" % awsSdkV2Version 53 | 54 | val `mockito` = "org.scalatestplus" %% "mockito-4-6" % mockitoVersion 55 | val `scalatest` = "org.scalatest" %% "scalatest" % scalaTestVersion 56 | val `jetty` = "org.eclipse.jetty" % "jetty-server" % jettyVersion 57 | val `commons-io` = "org.apache.commons" % "commons-io" % commonsIOVersion 58 | val `flexmark` = "com.vladsch.flexmark" % "flexmark-all" % flexmarkVersion 59 | val `slf4j-api` = "org.slf4j" % "slf4j-api" % slf4jVersion 60 | val `slf4j-simple` = "org.slf4j" % "slf4j-simple" % slf4jVersion 61 | 62 | val `byteBuddy` = "net.bytebuddy" % "byte-buddy" % byteBuddyVersion 63 | val `scalaCollectionCompat` = 64 | "org.scala-lang.modules" %% "scala-collection-compat" % scalaCollectionCompatVersion 65 | val `jakartaServlet` = 66 | "jakarta.servlet" % "jakarta.servlet-api" % jakartaServletVersion 67 | 68 | val `testContainersCore` = 69 | "org.testcontainers" % "testcontainers" % testContainersVersion 70 | val `testContainersKafka` = 71 | "org.testcontainers" % "kafka" % testContainersVersion 72 | val `testContainersVault` = 73 | "org.testcontainers" % "vault" % testContainersVersion 74 | 75 | val `json4sNative` = "org.json4s" %% "json4s-native" % json4sVersion 76 | val `json4sJackson` = "org.json4s" %% "json4s-jackson" % json4sVersion 77 | val `cats` = "org.typelevel" %% "cats-effect" % catsEffectVersion 78 | 79 | val `jsonSmart` = "net.minidev" % "json-smart" % jsonSmartVersion 80 | 81 | } 82 | 83 | import Dependencies._ 84 | val secretProviderDeps = Seq( 85 | `scala-logging`, 86 | `jsonSmart`, 87 | `kafka-connect-api` % Provided, 88 | `vault-java-driver`, 89 | `azure-key-vault` exclude ("net.minidev", "json-smart"), 90 | `azure-identity` exclude ("javax.activation", "activation") exclude ("net.minidev", "json-smart"), 91 | `awsSecretsManagerSdkV2`, 92 | `awsStsSdkV2`, 93 | `awsIamSdkV2` % IntegrationTest, 94 | `jakartaServlet` % Test, 95 | `mockito` % Test, 96 | `byteBuddy` % Test, 97 | `scalatest` % Test, 98 | `jetty` % Test, 99 | `commons-io` % Test, 100 | `flexmark` % Test, 101 | `slf4j-api` % Test, 102 | `slf4j-simple` % Test, 103 | `cats` % IntegrationTest, 104 | `testContainersCore` % IntegrationTest, 105 | `testContainersKafka` % IntegrationTest, 106 | `testContainersVault` % IntegrationTest, 107 | `json4sNative` % IntegrationTest, 108 | ) 109 | 110 | val testSinkDeps = Seq( 111 | `scala-logging`, 112 | `kafka-connect-api` % Provided, 113 | `vault-java-driver`, 114 | `awsSecretsManagerSdkV2`, 115 | `json4sNative`, 116 | `scalatest` % Test, 117 | ) 118 | 119 | } 120 | -------------------------------------------------------------------------------- /secret-provider/src/it/scala/io/lenses/connect/secrets/testcontainers/connect/KafkaConnectClient.scala: -------------------------------------------------------------------------------- 1 | package io.lenses.connect.secrets.testcontainers.connect 2 | 3 | import com.typesafe.scalalogging.StrictLogging 4 | import io.lenses.connect.secrets.testcontainers.KafkaConnectContainer 5 | import io.lenses.connect.secrets.testcontainers.connect.KafkaConnectClient.ConnectorStatus 6 | import org.apache.http.HttpHeaders 7 | import org.apache.http.HttpResponse 8 | import org.apache.http.client.HttpClient 9 | import org.apache.http.client.methods.HttpDelete 10 | import org.apache.http.client.methods.HttpGet 11 | import org.apache.http.client.methods.HttpPost 12 | import org.apache.http.client.methods.HttpPut 13 | import org.apache.http.entity.StringEntity 14 | import org.apache.http.impl.client.HttpClientBuilder 15 | import org.apache.http.message.BasicHeader 16 | import org.apache.http.util.EntityUtils 17 | import org.json4s.DefaultFormats 18 | import org.json4s._ 19 | import org.json4s.native.JsonMethods._ 20 | import org.scalatest.concurrent.Eventually 21 | import org.testcontainers.shaded.org.awaitility.Awaitility.await 22 | 23 | import java.util.concurrent.TimeUnit 24 | import scala.jdk.CollectionConverters._ 25 | 26 | class KafkaConnectClient(kafkaConnectContainer: KafkaConnectContainer) extends StrictLogging with Eventually { 27 | 28 | implicit val formats: DefaultFormats.type = DefaultFormats 29 | 30 | val httpClient: HttpClient = { 31 | val acceptHeader = new BasicHeader(HttpHeaders.ACCEPT, "application/json") 32 | val contentHeader = 33 | new BasicHeader(HttpHeaders.CONTENT_TYPE, "application/json") 34 | HttpClientBuilder.create 35 | .setDefaultHeaders(List(acceptHeader, contentHeader).asJava) 36 | .build() 37 | } 38 | 39 | def configureLogging(loggerFQCN: String, loggerLevel: String): Unit = { 40 | val entity = new StringEntity(s"""{ "level": "${loggerLevel.toUpperCase}" }""") 41 | entity.setContentType("application/json") 42 | val httpPut = new HttpPut( 43 | s"${kafkaConnectContainer.hostNetwork.restEndpointUrl}/admin/loggers/$loggerFQCN", 44 | ) 45 | httpPut.setEntity(entity) 46 | val response = httpClient.execute(httpPut) 47 | checkRequestSuccessful(response) 48 | } 49 | 50 | def registerConnector( 51 | connector: ConnectorConfiguration, 52 | timeoutSeconds: Long = 10L, 53 | ): Unit = { 54 | val httpPost = new HttpPost( 55 | s"${kafkaConnectContainer.hostNetwork.restEndpointUrl}/connectors", 56 | ) 57 | val entity = new StringEntity(connector.toJson()) 58 | httpPost.setEntity(entity) 59 | val response = httpClient.execute(httpPost) 60 | checkRequestSuccessful(response) 61 | EntityUtils.consume(response.getEntity) 62 | await 63 | .atMost(timeoutSeconds, TimeUnit.SECONDS) 64 | .until(() => isConnectorConfigured(connector.name)) 65 | } 66 | 67 | def deleteConnector( 68 | connectorName: String, 69 | timeoutSeconds: Long = 10L, 70 | ): Unit = { 71 | val httpDelete = 72 | new HttpDelete( 73 | s"${kafkaConnectContainer.hostNetwork.restEndpointUrl}/connectors/$connectorName", 74 | ) 75 | val response = httpClient.execute(httpDelete) 76 | checkRequestSuccessful(response) 77 | EntityUtils.consume(response.getEntity) 78 | await 79 | .atMost(timeoutSeconds, TimeUnit.SECONDS) 80 | .until(() => !this.isConnectorConfigured(connectorName)) 81 | } 82 | 83 | def getConnectorStatus(connectorName: String): ConnectorStatus = { 84 | val httpGet = new HttpGet( 85 | s"${kafkaConnectContainer.hostNetwork.restEndpointUrl}/connectors/$connectorName/status", 86 | ) 87 | val response = httpClient.execute(httpGet) 88 | checkRequestSuccessful(response) 89 | val strResponse = EntityUtils.toString(response.getEntity) 90 | logger.info(s"Connector status: $strResponse") 91 | parse(strResponse).extract[ConnectorStatus] 92 | } 93 | 94 | def isConnectorConfigured(connectorName: String): Boolean = { 95 | val httpGet = new HttpGet( 96 | s"${kafkaConnectContainer.hostNetwork.restEndpointUrl}/connectors/$connectorName", 97 | ) 98 | val response = httpClient.execute(httpGet) 99 | EntityUtils.consume(response.getEntity) 100 | response.getStatusLine.getStatusCode == 200 101 | } 102 | 103 | def waitConnectorInRunningState( 104 | connectorName: String, 105 | timeoutSeconds: Long = 10L, 106 | ): Unit = 107 | await 108 | .atMost(timeoutSeconds, TimeUnit.SECONDS) 109 | .until { () => 110 | try { 111 | val connectorState: String = 112 | getConnectorStatus(connectorName).connector.state 113 | logger.info("Connector State: {}", connectorState) 114 | connectorState.equals("RUNNING") 115 | } catch { 116 | case e: Throwable => 117 | logger.error("Connector Throwable: {}", e) 118 | false 119 | } 120 | } 121 | 122 | def checkRequestSuccessful(response: HttpResponse): Unit = 123 | if (!isSuccess(response.getStatusLine.getStatusCode)) { 124 | throw new IllegalStateException( 125 | s"Http request failed with response: ${EntityUtils.toString(response.getEntity)}", 126 | ) 127 | } 128 | 129 | def isSuccess(code: Int): Boolean = code / 100 == 2 130 | } 131 | 132 | object KafkaConnectClient { 133 | case class ConnectorStatus( 134 | name: String, 135 | connector: Connector, 136 | tasks: Seq[Tasks], 137 | `type`: String, 138 | ) 139 | case class Connector(state: String, worker_id: String) 140 | case class Tasks( 141 | id: Int, 142 | state: String, 143 | worker_id: String, 144 | trace: Option[String], 145 | ) 146 | } 147 | -------------------------------------------------------------------------------- /secret-provider/src/main/scala/io/lenses/connect/secrets/package.scala: -------------------------------------------------------------------------------- 1 | /* 2 | * 3 | * * Copyright 2017-2020 Lenses.io Ltd 4 | * 5 | */ 6 | 7 | package io.lenses.connect.secrets 8 | 9 | import com.typesafe.scalalogging.StrictLogging 10 | import org.apache.kafka.connect.errors.ConnectException 11 | 12 | import java.io.File 13 | import java.io.FileOutputStream 14 | import java.time.OffsetDateTime 15 | import java.util.Base64 16 | import scala.collection.mutable 17 | import scala.util.Failure 18 | import scala.util.Success 19 | import scala.util.Try 20 | 21 | package object connect extends StrictLogging { 22 | 23 | val FILE_ENCODING: String = "file-encoding" 24 | val FILE_DIR: String = "file.dir" 25 | val FILE_DIR_DESC: String = 26 | """ 27 | | Location to write any files for any secrets that need to 28 | | be written to disk. For example java keystores. 29 | | Files will be written under this directory following the 30 | | pattern /file.dir/[path|keyvault]/key 31 | |""".stripMargin 32 | 33 | val WRITE_FILES: String = "file.write" 34 | val WRITE_FILES_DESC: String = 35 | """ 36 | | Boolean flag whether to write secrets to disk. Defaults 37 | | to false. Should be set to true when writing Java 38 | | keystores etc. 39 | |""".stripMargin 40 | 41 | val SECRET_DEFAULT_TTL = "secret.default.ttl" 42 | val SECRET_DEFAULT_TTL_DEFAULT = 0L 43 | 44 | object AuthMode extends Enumeration { 45 | type AuthMode = Value 46 | val DEFAULT, CREDENTIALS = Value 47 | def withNameOpt(s: String): Option[Value] = values.find(_.toString == s) 48 | } 49 | 50 | object Encoding extends Enumeration { 51 | type Encoding = Value 52 | val BASE64, BASE64_FILE, UTF8, UTF8_FILE = Value 53 | def withNameOpt(s: String): Option[Value] = values.find(_.toString == s) 54 | 55 | def withoutHyphensInsensitiveOpt(s: String): Option[Value] = withNameOpt(s.replace("-", "").toUpperCase) 56 | } 57 | 58 | // get the authmethod 59 | def getAuthenticationMethod(method: String): AuthMode.Value = 60 | AuthMode.withNameOpt(method.toUpperCase) match { 61 | case Some(auth) => auth 62 | case None => 63 | throw new ConnectException( 64 | s"Unsupported authentication method", 65 | ) 66 | } 67 | 68 | // base64 decode secret 69 | def decode(key: String, value: String): String = 70 | Try(Base64.getDecoder.decode(value)) match { 71 | case Success(decoded) => decoded.map(_.toChar).mkString 72 | case Failure(exception) => 73 | throw new ConnectException( 74 | s"Failed to decode value for key [$key]", 75 | exception, 76 | ) 77 | } 78 | 79 | def decodeToBytes(key: String, value: String): Array[Byte] = 80 | Try(Base64.getDecoder.decode(value)) match { 81 | case Success(decoded) => decoded 82 | case Failure(exception) => 83 | throw new ConnectException( 84 | s"Failed to decode value for key [$key]", 85 | exception, 86 | ) 87 | } 88 | 89 | // decode a key bases on the prefix encoding 90 | def decodeKey( 91 | encoding: Option[Encoding.Value], 92 | key: String, 93 | value: String, 94 | writeFileFn: Array[Byte] => String, 95 | ): String = 96 | encoding.fold(value) { 97 | case Encoding.BASE64 => decode(key, value) 98 | case Encoding.BASE64_FILE => 99 | val decoded = decodeToBytes(key, value) 100 | writeFileFn(decoded) 101 | case Encoding.UTF8 => value 102 | case Encoding.UTF8_FILE => writeFileFn(value.getBytes()) 103 | } 104 | 105 | // write secrets to file 106 | private def writer(file: File, payload: Array[Byte], key: String): Unit = 107 | Try(file.createNewFile()) match { 108 | case Success(_) => 109 | Try(new FileOutputStream(file)) match { 110 | case Success(fos) => 111 | fos.write(payload) 112 | logger.info( 113 | s"Payload written to [${file.getAbsolutePath}] for key [$key]", 114 | ) 115 | 116 | case Failure(exception) => 117 | throw new ConnectException( 118 | s"Failed to write payload to file [${file.getAbsolutePath}] for key [$key]", 119 | exception, 120 | ) 121 | } 122 | 123 | case Failure(exception) => 124 | throw new ConnectException( 125 | s"Failed to create file [${file.getAbsolutePath}] for key [$key]", 126 | exception, 127 | ) 128 | } 129 | 130 | // write secrets to a file 131 | def fileWriter( 132 | fileName: String, 133 | payload: Array[Byte], 134 | key: String, 135 | overwrite: Boolean = false, 136 | ): Unit = { 137 | val file = new File(fileName) 138 | file.getParentFile.mkdirs() 139 | 140 | if (file.exists()) { 141 | logger.warn(s"File [$fileName] already exists") 142 | if (overwrite) { 143 | writer(file, payload, key) 144 | } 145 | } else { 146 | writer(file, payload, key) 147 | } 148 | } 149 | 150 | //calculate the min expiry for secrets and return the configData and expiry 151 | def getSecretsAndExpiry( 152 | secrets: Map[String, (String, Option[OffsetDateTime])], 153 | ): (Option[OffsetDateTime], Map[String, String]) = { 154 | val expiryList = mutable.ListBuffer.empty[OffsetDateTime] 155 | 156 | val data = secrets 157 | .map({ 158 | case (key, (value, expiry)) => 159 | expiry.foreach(e => expiryList.append(e)) 160 | (key, value) 161 | }) 162 | 163 | if (expiryList.isEmpty) { 164 | (None, data) 165 | } else { 166 | val minExpiry = expiryList.min 167 | (Some(minExpiry), data) 168 | } 169 | } 170 | 171 | def getFileName( 172 | rootDir: String, 173 | path: String, 174 | key: String, 175 | separator: String, 176 | ): String = 177 | s"${rootDir.stripSuffix(separator)}$separator$path$separator${key.toLowerCase}" 178 | } 179 | -------------------------------------------------------------------------------- /secret-provider/src/it/scala/io/lenses/connect/secrets/integration/VaultSecretProviderIT.scala: -------------------------------------------------------------------------------- 1 | package io.lenses.connect.secrets.integration 2 | 3 | import cats.effect.FiberIO 4 | import cats.effect.IO 5 | import cats.effect.Ref 6 | import cats.effect.Resource 7 | import cats.effect.unsafe.implicits.global 8 | import cats.implicits.catsSyntaxOptionId 9 | import io.lenses.connect.secrets.testcontainers.LeaseInfo 10 | import io.lenses.connect.secrets.testcontainers.VaultContainer 11 | import io.lenses.connect.secrets.testcontainers.VaultContainer.vaultSecretKey 12 | import io.lenses.connect.secrets.testcontainers.VaultContainer.vaultSecretPath 13 | import io.lenses.connect.secrets.testcontainers.connect.ConfigProvider 14 | import io.lenses.connect.secrets.testcontainers.connect.ConfigProviders 15 | import io.lenses.connect.secrets.testcontainers.connect.ConnectorConfiguration 16 | import io.lenses.connect.secrets.testcontainers.connect.KafkaConnectClient 17 | import io.lenses.connect.secrets.testcontainers.connect.StringCnfVal 18 | import io.lenses.connect.secrets.testcontainers.scalatest.SecretProviderContainerPerSuite 19 | import org.apache.kafka.clients.producer.KafkaProducer 20 | import org.apache.kafka.clients.producer.ProducerRecord 21 | import org.scalatest.BeforeAndAfterAll 22 | import org.scalatest.funsuite.AnyFunSuite 23 | import org.scalatest.matchers.should.Matchers 24 | 25 | import java.util.UUID 26 | import java.util.concurrent.TimeUnit 27 | import scala.concurrent.duration.FiniteDuration 28 | 29 | class VaultSecretProviderIT 30 | extends AnyFunSuite 31 | with SecretProviderContainerPerSuite 32 | with Matchers 33 | with BeforeAndAfterAll { 34 | 35 | lazy val secretTtlSeconds = 5 36 | 37 | lazy val container: VaultContainer = 38 | new VaultContainer( 39 | LeaseInfo(secretTtlSeconds, secretTtlSeconds).some, 40 | ).withNetwork(network) 41 | 42 | override def modules: Seq[String] = Seq("secret-provider", "test-sink") 43 | 44 | val topicName = "vaultVandal" 45 | val connectorName = "testSink" 46 | override def beforeAll(): Unit = { 47 | container.start() 48 | super.beforeAll() 49 | } 50 | 51 | override def afterAll(): Unit = { 52 | super.afterAll() 53 | container.stop() 54 | } 55 | 56 | test("does not fail with secret TTLs") { 57 | (for { 58 | _ <- container.configureMount(secretTtlSeconds) 59 | initialSecret <- container.rotateSecret(secretTtlSeconds) 60 | latestSecretRef <- Ref.of[IO, String](initialSecret) 61 | _ <- secretUpdater(latestSecretRef) 62 | _ <- startConnector() 63 | } yield ()).unsafeRunSync() 64 | } 65 | 66 | private def startConnector(): IO[Unit] = 67 | makeStringStringProducerResource().use { prod => 68 | withConnectorResource(sinkConfig()).use(_ => writeRecords(prod)) 69 | } 70 | 71 | private def writeRecords(prod: KafkaProducer[String, String]): IO[Unit] = 72 | IO { 73 | (1 to 100).foreach { i => 74 | writeRecord(prod, i) 75 | Thread.sleep(250) 76 | } 77 | } 78 | 79 | private def writeRecord( 80 | producer: KafkaProducer[String, String], 81 | i: Int, 82 | ): Unit = { 83 | val record: ProducerRecord[String, String] = 84 | new ProducerRecord[String, String]( 85 | topicName, 86 | s"${i}_${UUID.randomUUID().toString}", 87 | ) 88 | producer.send(record).get 89 | producer.flush() 90 | } 91 | 92 | private def withConnectorResource( 93 | connectorConfig: ConnectorConfiguration, 94 | timeoutSeconds: Long = 10L, 95 | )( 96 | implicit 97 | kafkaConnectClient: KafkaConnectClient, 98 | ): Resource[IO, String] = 99 | Resource.make( 100 | IO { 101 | val connectorName = connectorConfig.name 102 | kafkaConnectClient.registerConnector(connectorConfig) 103 | kafkaConnectClient.configureLogging("org.apache.kafka.connect.runtime.WorkerConfigTransformer", "DEBUG") 104 | kafkaConnectClient.waitConnectorInRunningState( 105 | connectorName, 106 | timeoutSeconds, 107 | ) 108 | connectorName 109 | }, 110 | )(connectorName => IO(kafkaConnectClient.deleteConnector(connectorName))) 111 | 112 | private def secretUpdater(ref: Ref[IO, String]): IO[FiberIO[Unit]] = 113 | (for { 114 | _ <- IO.sleep(FiniteDuration(secretTtlSeconds, TimeUnit.SECONDS)) 115 | rotated <- container.rotateSecret(secretTtlSeconds) 116 | _ <- ref.set(rotated) 117 | updateAgain <- secretUpdater(ref) 118 | _ <- updateAgain.join 119 | } yield ()).start 120 | 121 | private def sinkConfig(): ConnectorConfiguration = 122 | ConnectorConfiguration( 123 | connectorName, 124 | Map( 125 | "name" -> StringCnfVal(connectorName), 126 | "connector.class" -> StringCnfVal( 127 | "io.lenses.connect.secrets.test.vault.TestSinkConnector", 128 | ), 129 | "topics" -> StringCnfVal(topicName), 130 | "key.converter" -> StringCnfVal( 131 | "org.apache.kafka.connect.storage.StringConverter", 132 | ), 133 | "value.converter" -> StringCnfVal( 134 | "org.apache.kafka.connect.storage.StringConverter", 135 | ), 136 | "test.sink.vault.host" -> StringCnfVal(container.networkVaultAddress), 137 | "test.sink.vault.token" -> StringCnfVal(container.vaultToken), 138 | "test.sink.secret.path" -> StringCnfVal(vaultSecretPath), 139 | "test.sink.secret.key" -> StringCnfVal(vaultSecretKey), 140 | "test.sink.secret.value" -> StringCnfVal( 141 | s"$${vault:$vaultSecretPath:$vaultSecretKey}", 142 | ), 143 | ), 144 | ) 145 | 146 | override def getConfigProviders(): Option[ConfigProviders] = 147 | ConfigProviders( 148 | Seq( 149 | ConfigProvider( 150 | "vault", 151 | "io.lenses.connect.secrets.providers.VaultSecretProvider", 152 | Map( 153 | "vault.addr" -> container.networkVaultAddress, 154 | "vault.engine.version" -> "2", 155 | "vault.auth.method" -> "token", 156 | "vault.token" -> container.vaultToken, 157 | "default.ttl" -> "30000", 158 | "file.write" -> "false", 159 | ), 160 | ), 161 | ), 162 | ).some 163 | } 164 | -------------------------------------------------------------------------------- /secret-provider/src/test/scala/io/lenses/connect/secrets/providers/Aes256DecodingProviderTest.scala: -------------------------------------------------------------------------------- 1 | package io.lenses.connect.secrets.providers 2 | 3 | import io.lenses.connect.secrets.config.Aes256ProviderConfig 4 | import io.lenses.connect.secrets.connect.Encoding 5 | import io.lenses.connect.secrets.connect.FILE_DIR 6 | import io.lenses.connect.secrets.utils.EncodingAndId 7 | import org.apache.kafka.common.config.provider.ConfigProvider 8 | import org.apache.kafka.common.config.ConfigException 9 | import org.apache.kafka.common.config.ConfigTransformer 10 | import org.apache.kafka.connect.errors.ConnectException 11 | import org.scalatest.matchers.should.Matchers 12 | import org.scalatest.prop.TableDrivenPropertyChecks 13 | import org.scalatest.wordspec.AnyWordSpec 14 | 15 | import java.io.FileInputStream 16 | import java.nio.file.Files 17 | import java.util 18 | import java.util.Base64 19 | import java.util.UUID.randomUUID 20 | import scala.io.Source 21 | import scala.jdk.CollectionConverters._ 22 | import scala.util.Random 23 | import scala.util.Success 24 | import scala.util.Using 25 | 26 | class Aes256DecodingProviderTest extends AnyWordSpec with TableDrivenPropertyChecks with Matchers { 27 | import AesDecodingTestHelper.encrypt 28 | 29 | "aes256 provider" should { 30 | "decrypt aes 256 utf-8 encoded value" in new TestContext with ConfiguredProvider { 31 | val encrypted = encrypt(value, key) 32 | 33 | forAll(Table("encoding", "", "utf-8")) { encoding => 34 | val decrypted = 35 | provider.get(encoding, Set(encrypted).asJava).data().asScala 36 | 37 | decrypted.get(encrypted) shouldBe Some(value) 38 | } 39 | } 40 | 41 | "decrypt aes 256 base64 encoded value" in new TestContext with ConfiguredProvider { 42 | val encrypted = 43 | encrypt(Base64.getEncoder.encodeToString(value.getBytes()), key) 44 | 45 | val decrypted = 46 | provider.get("base64", Set(encrypted).asJava).data().asScala 47 | 48 | decrypted.get(encrypted) shouldBe Some(value) 49 | } 50 | 51 | "decrypt aes 256 encoded value stored in file with utf-8 encoding" in new TestContext with ConfiguredProvider { 52 | val encrypted = encrypt(value, key) 53 | 54 | val providerData = provider 55 | .get(s"utf8_file${EncodingAndId.Separator}id1", Set(encrypted).asJava) 56 | .data() 57 | .asScala 58 | val decryptedPath = providerData(encrypted) 59 | 60 | decryptedPath should startWith(s"$tmpDir/secrets/") 61 | decryptedPath.toLowerCase.contains(encrypted.toLowerCase) shouldBe false 62 | Using(Source.fromFile(decryptedPath))(_.getLines().mkString) shouldBe Success( 63 | value, 64 | ) 65 | } 66 | 67 | "decrypt aes 256 encoded value stored in file with base64 encoding" in new TestContext with ConfiguredProvider { 68 | val bytesAmount = 100 69 | val bytesInput = Array.fill[Byte](bytesAmount)(0) 70 | Random.nextBytes(bytesInput) 71 | val encrypted = encrypt(Base64.getEncoder.encodeToString(bytesInput), key) 72 | 73 | val providerData = provider 74 | .get( 75 | s"${Encoding.BASE64_FILE}${EncodingAndId.Separator}fileId1", 76 | Set(encrypted).asJava, 77 | ) 78 | .data() 79 | .asScala 80 | val decryptedPath = providerData(encrypted) 81 | 82 | decryptedPath should startWith(s"$tmpDir/secrets/") 83 | decryptedPath.toLowerCase.contains(encrypted.toLowerCase) shouldBe false 84 | val bytesConsumed = Array.fill[Byte](bytesAmount)(0) 85 | new FileInputStream(decryptedPath).read(bytesConsumed) 86 | 87 | bytesConsumed.toList shouldBe bytesInput.toList 88 | } 89 | 90 | "transform value referencing to the provider" in new TestContext { 91 | val value = "hi!" 92 | val encrypted = encrypt(value, key) 93 | provider.configure(config) 94 | 95 | val transformer = new ConfigTransformer( 96 | Map[String, ConfigProvider]("aes256" -> provider).asJava, 97 | ) 98 | val props = Map("mykey" -> ("$" + s"{aes256::$encrypted}")).asJava 99 | val data = transformer.transform(props) 100 | data.data().containsKey(encrypted) 101 | data.data().get("mykey") shouldBe value 102 | } 103 | } 104 | 105 | forAll(encodings) { encoding => 106 | s"aes256 provider for encoding $encoding" should { 107 | "fail decoding when unable to decode" in new TestContext { 108 | provider.configure(config) 109 | 110 | assertThrows[ConnectException] { 111 | provider.get(encoding, Set("abc").asJava) 112 | } 113 | } 114 | 115 | "fail decoding when secret key is not configured" in new TestContext { 116 | assertThrows[ConnectException] { 117 | provider.get(encoding, Set(encrypt("hi!", key)).asJava) 118 | } 119 | } 120 | 121 | "fail decoding when file dir is not configured" in new TestContext { 122 | assertThrows[ConnectException] { 123 | provider.get(encoding, Set(encrypt("hi!", key)).asJava) 124 | } 125 | } 126 | } 127 | } 128 | 129 | "configuring" should { 130 | "fail for invalid key" in new TestContext { 131 | assertThrows[ConfigException] { 132 | provider.configure( 133 | Map( 134 | Aes256ProviderConfig.SECRET_KEY -> "too-short", 135 | FILE_DIR -> "/tmp", 136 | ).asJava, 137 | ) 138 | } 139 | } 140 | 141 | "fail for missing secret key" in new TestContext { 142 | assertThrows[ConfigException] { 143 | provider.configure(Map(FILE_DIR -> "/tmp").asJava) 144 | } 145 | } 146 | } 147 | 148 | trait ConfiguredProvider { 149 | self: TestContext => 150 | 151 | val value: String = randomUUID().toString 152 | 153 | provider.configure(config) 154 | } 155 | 156 | trait TestContext { 157 | val key: String = randomUUID.toString.take(32) 158 | val tmpDir: String = Files 159 | .createTempDirectory(randomUUID().toString) 160 | .toFile 161 | .getAbsolutePath 162 | val provider = new Aes256DecodingProvider() 163 | val config: util.Map[String, String] = Map( 164 | Aes256ProviderConfig.SECRET_KEY -> key, 165 | FILE_DIR -> tmpDir, 166 | ).asJava 167 | } 168 | 169 | private def encodings = Table( 170 | "encoding", 171 | "", 172 | "utf8", 173 | "utf_file", 174 | "base64", 175 | "base64_file", 176 | ) 177 | } 178 | -------------------------------------------------------------------------------- /secret-provider/src/it/scala/io/lenses/connect/secrets/testcontainers/scalatest/SecretProviderContainerPerSuite.scala: -------------------------------------------------------------------------------- 1 | package io.lenses.connect.secrets.testcontainers.scalatest 2 | 3 | import cats.effect.IO 4 | import cats.effect.Resource 5 | import com.typesafe.scalalogging.LazyLogging 6 | import io.lenses.connect.secrets.testcontainers.connect.ConfigProviders 7 | import io.lenses.connect.secrets.testcontainers.connect.KafkaConnectClient 8 | import io.lenses.connect.secrets.testcontainers.KafkaConnectContainer 9 | import org.apache.kafka.clients.consumer.ConsumerConfig 10 | import org.apache.kafka.clients.consumer.ConsumerRecord 11 | import org.apache.kafka.clients.consumer.KafkaConsumer 12 | import org.apache.kafka.clients.producer.KafkaProducer 13 | import org.apache.kafka.clients.producer.ProducerConfig 14 | import org.apache.kafka.common.serialization.StringDeserializer 15 | import org.apache.kafka.common.serialization.StringSerializer 16 | import org.scalatest.BeforeAndAfterAll 17 | import org.scalatest.TestSuite 18 | import org.scalatest.concurrent.Eventually 19 | import org.scalatest.time.Minute 20 | import org.scalatest.time.Span 21 | import org.testcontainers.containers.KafkaContainer 22 | import org.testcontainers.containers.Network 23 | import org.testcontainers.containers.output.Slf4jLogConsumer 24 | import org.testcontainers.utility.DockerImageName 25 | 26 | import java.io.File 27 | import java.nio.file.Files 28 | import java.nio.file.Path 29 | import java.nio.file.Paths 30 | import java.time.Duration 31 | import java.util 32 | import java.util.Properties 33 | import java.util.UUID 34 | import java.util.stream.Collectors 35 | import scala.collection.mutable.ListBuffer 36 | import scala.util.Try 37 | 38 | trait SecretProviderContainerPerSuite extends BeforeAndAfterAll with Eventually with LazyLogging { this: TestSuite => 39 | 40 | def getConfigProviders(): Option[ConfigProviders] = Option.empty 41 | 42 | override implicit def patienceConfig: PatienceConfig = 43 | PatienceConfig(timeout = Span(1, Minute)) 44 | 45 | private val confluentPlatformVersion: String = { 46 | val (vers, from) = sys.env.get("CONFLUENT_VERSION") match { 47 | case Some(value) => (value, "env") 48 | case None => ("7.3.1", "default") 49 | } 50 | logger.info("Selected confluent version {} from {}", vers, from) 51 | vers 52 | } 53 | 54 | val network: Network = Network.SHARED 55 | 56 | def modules: Seq[String] 57 | 58 | lazy val kafkaContainer: KafkaContainer = 59 | new KafkaContainer( 60 | DockerImageName.parse(s"confluentinc/cp-kafka:$confluentPlatformVersion"), 61 | ).withNetwork(network) 62 | .withNetworkAliases("kafka") 63 | .withLogConsumer(new Slf4jLogConsumer(logger.underlying)) 64 | 65 | lazy val kafkaConnectContainer: KafkaConnectContainer = { 66 | KafkaConnectContainer( 67 | kafkaContainer = kafkaContainer, 68 | connectPluginPathMap = connectPluginPath(), 69 | maybeConfigProviders = getConfigProviders(), 70 | ).withNetwork(network) 71 | .withLogConsumer(new Slf4jLogConsumer(logger.underlying)) 72 | } 73 | 74 | implicit lazy val kafkaConnectClient: KafkaConnectClient = 75 | new KafkaConnectClient(kafkaConnectContainer) 76 | 77 | override def beforeAll(): Unit = { 78 | kafkaContainer.start() 79 | kafkaConnectContainer.start() 80 | super.beforeAll() 81 | } 82 | 83 | override def afterAll(): Unit = 84 | try super.afterAll() 85 | finally { 86 | kafkaConnectContainer.stop() 87 | kafkaContainer.stop() 88 | } 89 | 90 | def connectPluginPath(): Map[String, String] = { 91 | 92 | val dir: String = detectUserOrGithubDir 93 | 94 | modules.map { module: String => 95 | val regex = s".*$module.*.jar" 96 | val files: util.List[Path] = Files 97 | .find( 98 | Paths.get( 99 | String.join( 100 | File.separator, 101 | dir, 102 | module, 103 | "target", 104 | ), 105 | ), 106 | 3, 107 | (p, _) => p.toFile.getName.matches(regex), 108 | ) 109 | .collect(Collectors.toList()) 110 | if (files.isEmpty) 111 | throw new RuntimeException( 112 | s"""Please run `sbt "project $module" assembly""", 113 | ) 114 | module -> files.get(0).getParent.toString 115 | }.toMap 116 | 117 | } 118 | 119 | private def detectUserOrGithubDir = { 120 | val userDir = sys.props("user.dir") 121 | val githubWs = Try(Option(sys.env("GITHUB_WORKSPACE"))).toOption.flatten 122 | 123 | logger.info("userdir: {} githubWs: {}", userDir, githubWs) 124 | 125 | githubWs.getOrElse(userDir) 126 | } 127 | 128 | def makeStringStringProducerResource(): Resource[IO, KafkaProducer[String, String]] = 129 | makeProducerResource(classOf[StringSerializer], classOf[StringSerializer]) 130 | 131 | def makeProducerResource[K, V]( 132 | keySer: Class[_], 133 | valueSer: Class[_], 134 | ): Resource[IO, KafkaProducer[K, V]] = { 135 | val props = new Properties 136 | props.put( 137 | ProducerConfig.BOOTSTRAP_SERVERS_CONFIG, 138 | kafkaContainer.getBootstrapServers, 139 | ) 140 | props.put(ProducerConfig.ACKS_CONFIG, "all") 141 | props.put(ProducerConfig.RETRIES_CONFIG, 0) 142 | props.put(ProducerConfig.KEY_SERIALIZER_CLASS_CONFIG, keySer) 143 | props.put(ProducerConfig.VALUE_SERIALIZER_CLASS_CONFIG, valueSer) 144 | Resource.make(IO(new KafkaProducer[K, V](props)))(r => IO(r.close())) 145 | } 146 | 147 | def createConsumer(): KafkaConsumer[String, String] = { 148 | val props = new Properties 149 | props.put( 150 | ConsumerConfig.BOOTSTRAP_SERVERS_CONFIG, 151 | kafkaContainer.getBootstrapServers, 152 | ) 153 | props.put(ConsumerConfig.GROUP_ID_CONFIG, "cg-" + UUID.randomUUID()) 154 | props.put(ConsumerConfig.AUTO_OFFSET_RESET_CONFIG, "earliest") 155 | new KafkaConsumer[String, String]( 156 | props, 157 | new StringDeserializer(), 158 | new StringDeserializer(), 159 | ) 160 | } 161 | 162 | /** 163 | * Drain a kafka topic. 164 | * 165 | * @param consumer the kafka consumer 166 | * @param expectedRecordCount the expected record count 167 | * @tparam K the key type 168 | * @tparam V the value type 169 | * @return the records 170 | */ 171 | def drain[K, V]( 172 | consumer: KafkaConsumer[K, V], 173 | expectedRecordCount: Int, 174 | ): List[ConsumerRecord[K, V]] = { 175 | val allRecords = ListBuffer[ConsumerRecord[K, V]]() 176 | 177 | eventually { 178 | consumer 179 | .poll(Duration.ofMillis(50)) 180 | .iterator() 181 | .forEachRemaining(e => allRecords :+ e) 182 | assert(allRecords.size == expectedRecordCount) 183 | } 184 | allRecords.toList 185 | } 186 | } 187 | -------------------------------------------------------------------------------- /secret-provider/src/main/scala/io/lenses/connect/secrets/providers/AWSHelper.scala: -------------------------------------------------------------------------------- 1 | /* 2 | * 3 | * * Copyright 2017-2020 Lenses.io Ltd 4 | * 5 | */ 6 | 7 | package io.lenses.connect.secrets.providers 8 | import com.fasterxml.jackson.core.JsonParseException 9 | import com.fasterxml.jackson.databind.ObjectMapper 10 | import com.typesafe.scalalogging.LazyLogging 11 | import com.typesafe.scalalogging.StrictLogging 12 | import io.lenses.connect.secrets.cache.Ttl 13 | import io.lenses.connect.secrets.cache.ValueWithTtl 14 | import io.lenses.connect.secrets.config.AWSProviderSettings 15 | import io.lenses.connect.secrets.config.SecretType 16 | import io.lenses.connect.secrets.config.SecretType.SecretType 17 | import io.lenses.connect.secrets.connect.AuthMode 18 | import io.lenses.connect.secrets.connect.decodeKey 19 | import io.lenses.connect.secrets.io.FileWriter 20 | import io.lenses.connect.secrets.utils.EncodingAndId 21 | import org.apache.kafka.connect.errors.ConnectException 22 | import software.amazon.awssdk.auth.credentials.AwsBasicCredentials 23 | import software.amazon.awssdk.auth.credentials.AwsCredentialsProvider 24 | import software.amazon.awssdk.auth.credentials.DefaultCredentialsProvider 25 | import software.amazon.awssdk.auth.credentials.StaticCredentialsProvider 26 | import software.amazon.awssdk.regions.Region 27 | import software.amazon.awssdk.services.secretsmanager.SecretsManagerClient 28 | import software.amazon.awssdk.services.secretsmanager.model.DescribeSecretRequest 29 | import software.amazon.awssdk.services.secretsmanager.model.GetSecretValueRequest 30 | 31 | import java.net.URI 32 | import java.time.Clock 33 | import java.time.Duration 34 | import java.time.OffsetDateTime 35 | import java.time.ZoneId 36 | import java.time.temporal.ChronoUnit 37 | import java.util 38 | import scala.jdk.CollectionConverters.MapHasAsScala 39 | import scala.util.Failure 40 | import scala.util.Success 41 | import scala.util.Try 42 | 43 | class AWSHelper( 44 | client: SecretsManagerClient, 45 | defaultTtl: Option[Duration], 46 | fileWriterCreateFn: () => Option[FileWriter], 47 | secretType: SecretType, 48 | )( 49 | implicit 50 | clock: Clock, 51 | ) extends SecretHelper 52 | with LazyLogging { 53 | 54 | private val objectMapper = new ObjectMapper() 55 | 56 | // get the key value and ttl in the specified secret 57 | override def lookup(secretId: String): Either[Throwable, ValueWithTtl[Map[String, String]]] = 58 | for { 59 | secretTtl <- getTTL(secretId) 60 | stringSecretValue <- getStringSecretValue(secretId) 61 | secretValue <- secretType match { 62 | case SecretType.STRING => 63 | Try(ValueWithTtl[Map[String, String]](secretTtl, Map("value" -> stringSecretValue))).toEither 64 | case _ => 65 | for { 66 | mapSecretValue <- secretValueToMap(secretId, stringSecretValue) 67 | parsedSecretValue <- parseSecretValue(mapSecretValue) 68 | } yield ValueWithTtl[Map[String, String]](secretTtl, parsedSecretValue) 69 | } 70 | } yield secretValue 71 | 72 | // determine the ttl for the secret 73 | private def getTTL( 74 | secretId: String, 75 | )( 76 | implicit 77 | clock: Clock, 78 | ): Either[Throwable, Option[Ttl]] = { 79 | 80 | // describe to get the ttl 81 | val descRequest: DescribeSecretRequest = 82 | DescribeSecretRequest.builder().secretId(secretId).build() 83 | 84 | for { 85 | secretResponse <- Try(client.describeSecret(descRequest)).toEither 86 | } yield { 87 | if (secretResponse.rotationEnabled()) { 88 | //val lastRotation = secretResponse.lastRotatedDate() 89 | val ttlExpires = secretResponse.nextRotationDate() 90 | val rotationDuration = Duration.of(secretResponse.rotationRules().automaticallyAfterDays(), ChronoUnit.DAYS) 91 | Some(Ttl( 92 | rotationDuration, 93 | OffsetDateTime.ofInstant(ttlExpires, ZoneId.systemDefault()), 94 | )) 95 | } else { 96 | Ttl(Option.empty, defaultTtl) 97 | } 98 | } 99 | 100 | } 101 | 102 | private def parseSecretValue( 103 | secretValues: Map[String, String], 104 | ): Either[Throwable, Map[String, String]] = { 105 | val fileWriterMaybe = fileWriterCreateFn() 106 | Try( 107 | secretValues.map { 108 | case (k, v) => 109 | (k, 110 | decodeKey( 111 | key = k, 112 | value = v, 113 | encoding = EncodingAndId.from(k).encoding, 114 | writeFileFn = content => { 115 | fileWriterMaybe.fold("nofile")(_.write(k.toLowerCase, content, k).toString) 116 | }, 117 | ), 118 | ) 119 | }, 120 | ).toEither 121 | } 122 | 123 | private def getStringSecretValue( 124 | secretId: String, 125 | ): Either[Throwable, String] = { 126 | for { 127 | secretValueResult <- Try( 128 | client.getSecretValue(GetSecretValueRequest.builder().secretId(secretId).build()), 129 | ) 130 | secretString <- Try(secretValueResult.secretString()) 131 | } yield secretString 132 | }.toEither 133 | 134 | private def secretValueToMap( 135 | secretId: String, 136 | secretValueString: String, 137 | ): Either[Throwable, Map[String, String]] = { 138 | val a = for { 139 | mapFromResponse <- 140 | Try( 141 | objectMapper 142 | .readValue( 143 | secretValueString, 144 | classOf[util.HashMap[String, String]], 145 | ) 146 | .asScala.toMap, 147 | ) 148 | } yield mapFromResponse 149 | a match { 150 | case Failure(_: JsonParseException) => 151 | Left(new IllegalStateException(s"Unable to parse JSON in secret [$secretId}]")) 152 | case Failure(exception) => Left(exception) 153 | case Success(value) => Right(value) 154 | } 155 | 156 | } 157 | } 158 | 159 | object AWSHelper extends StrictLogging { 160 | 161 | // initialize the AWS client based on the auth mode 162 | def createClient(settings: AWSProviderSettings): SecretsManagerClient = { 163 | 164 | logger.info( 165 | s"Initializing client with mode [${settings.authMode}]", 166 | ) 167 | 168 | val credentialProvider: AwsCredentialsProvider = settings.authMode match { 169 | case AuthMode.CREDENTIALS => 170 | settings.credentials.map { 171 | creds => 172 | StaticCredentialsProvider.create( 173 | AwsBasicCredentials.create( 174 | creds.accessKey, 175 | creds.secretKey.value(), 176 | ), 177 | ) 178 | }.getOrElse(throw new ConnectException( 179 | "No access key and secret key credentials available for CREDENTIALS mode", 180 | )) 181 | 182 | case _ => 183 | DefaultCredentialsProvider.create() 184 | } 185 | 186 | val builder = SecretsManagerClient.builder() 187 | .credentialsProvider(credentialProvider) 188 | .region(Region.of(settings.region)) 189 | 190 | settings.endpointOverride.foreach(eo => builder.endpointOverride(URI.create(eo))) 191 | 192 | builder.build() 193 | } 194 | } 195 | -------------------------------------------------------------------------------- /secret-provider/src/main/scala/io/lenses/connect/secrets/providers/VaultHelper.scala: -------------------------------------------------------------------------------- 1 | /* 2 | * 3 | * * Copyright 2017-2020 Lenses.io Ltd 4 | * 5 | */ 6 | 7 | package io.lenses.connect.secrets.providers 8 | 9 | import io.github.jopenlibs.vault.SslConfig 10 | import io.github.jopenlibs.vault.Vault 11 | import io.github.jopenlibs.vault.VaultConfig 12 | import io.github.jopenlibs.vault.response.LogicalResponse 13 | import com.typesafe.scalalogging.LazyLogging 14 | import com.typesafe.scalalogging.StrictLogging 15 | import io.lenses.connect.secrets.cache.ValueWithTtl 16 | import io.lenses.connect.secrets.config.VaultAuthMethod 17 | import io.lenses.connect.secrets.config.VaultSettings 18 | import io.lenses.connect.secrets.connect.decodeKey 19 | import io.lenses.connect.secrets.io.FileWriter 20 | import io.lenses.connect.secrets.utils.EncodingAndId 21 | import io.lenses.connect.secrets.utils.ExceptionUtils.failWithEx 22 | import org.apache.kafka.connect.errors.ConnectException 23 | 24 | import java.io.File 25 | import java.time.Clock 26 | import java.time.Duration 27 | import java.time.temporal.ChronoUnit 28 | import scala.jdk.CollectionConverters.MapHasAsScala 29 | import scala.util.Try 30 | 31 | class VaultHelper( 32 | vaultClient: Vault, 33 | defaultTtl: Option[Duration], 34 | fileWriterCreateFn: () => Option[FileWriter], 35 | )( 36 | implicit 37 | clock: Clock, 38 | ) extends SecretHelper 39 | with LazyLogging { 40 | 41 | override def lookup(path: String): Either[Throwable, ValueWithTtl[Map[String, String]]] = { 42 | logger.debug(s"Looking up value from Vault at [$path]") 43 | val lookupFn = if (path.startsWith("database/creds/")) lookupFromDatabaseEngine _ else lookupFromVault _ 44 | lookupFn(path) match { 45 | case Left(ex) => 46 | failWithEx(s"Failed to fetch secrets from path [$path]", ex) 47 | case Right(response) if response.getRestResponse.getStatus != 200 => 48 | failWithEx( 49 | s"No secrets found at path [$path]. Vault response: ${new String(response.getRestResponse.getBody)}", 50 | ) 51 | case Right(response) if response.getData.isEmpty => 52 | failWithEx(s"No secrets found at path [$path]") 53 | case Right(response) => 54 | val ttl = 55 | Option(response.getLeaseDuration).filterNot(_ == 0L).map(Duration.of(_, ChronoUnit.SECONDS)) 56 | Right( 57 | ValueWithTtl(ttl, defaultTtl, parseSuccessfulResponse(response)), 58 | ) 59 | } 60 | } 61 | 62 | private def lookupFromVault(path: String): Either[Throwable, LogicalResponse] = 63 | Try(vaultClient.logical().read(path)).toEither 64 | 65 | private def lookupFromDatabaseEngine(path: String): Either[Throwable, LogicalResponse] = { 66 | val parts = path.split("/") 67 | Either.cond( 68 | parts.length == 3, 69 | vaultClient.database().creds(parts(2)), 70 | new ConnectException("Database path is invalid. Path must be in the form 'database/creds/'"), 71 | ) 72 | } 73 | 74 | private def parseSuccessfulResponse( 75 | response: LogicalResponse, 76 | ) = { 77 | val secretValues = response.getData.asScala 78 | val fileWriterMaybe = fileWriterCreateFn() 79 | secretValues.map { 80 | case (k, v) => 81 | (k, 82 | decodeKey( 83 | encoding = EncodingAndId.from(k).encoding, 84 | key = k, 85 | value = v, 86 | writeFileFn = { content => 87 | fileWriterMaybe.fold("nofile")(_.write(k.toLowerCase, content, k).toString) 88 | }, 89 | ), 90 | ) 91 | }.toMap 92 | } 93 | } 94 | 95 | object VaultHelper extends StrictLogging { 96 | 97 | // initialize the vault client 98 | def createClient(settings: VaultSettings): Vault = { 99 | val config = 100 | new VaultConfig().address(settings.addr) 101 | 102 | // set ssl if configured 103 | config.sslConfig(configureSSL(settings)) 104 | 105 | if (settings.namespace.nonEmpty) { 106 | logger.info(s"Setting namespace to ${settings.namespace}") 107 | config.nameSpace(settings.namespace) 108 | } 109 | 110 | logger.info(s"Setting engine version to ${settings.engineVersion}") 111 | config.engineVersion(settings.engineVersion) 112 | 113 | logger.info(s"Setting prefix path depth to ${settings.prefixDepth}") 114 | config.prefixPathDepth(settings.prefixDepth) 115 | 116 | val vault = new Vault(config.build()) 117 | 118 | logger.info( 119 | s"Initializing client with mode [${settings.authMode.toString}]", 120 | ) 121 | 122 | val token = settings.authMode match { 123 | case VaultAuthMethod.USERPASS => 124 | settings.userPass 125 | .map(up => 126 | vault 127 | .auth() 128 | .loginByUserPass(up.username, up.password.value(), up.mount) 129 | .getAuthClientToken, 130 | ) 131 | 132 | case VaultAuthMethod.APPROLE => 133 | settings.appRole 134 | .map(ar => 135 | vault 136 | .auth() 137 | .loginByAppRole(ar.path, ar.role, ar.secretId.value()) 138 | .getAuthClientToken, 139 | ) 140 | 141 | case VaultAuthMethod.CERT => 142 | settings.cert 143 | .map(c => vault.auth().loginByCert(c.mount).getAuthClientToken) 144 | 145 | case VaultAuthMethod.AWSIAM => 146 | settings.awsIam 147 | .map(aws => 148 | vault 149 | .auth() 150 | .loginByAwsIam( 151 | aws.role, 152 | aws.url, 153 | aws.body.value(), 154 | aws.headers.value(), 155 | aws.mount, 156 | ) 157 | .getAuthClientToken, 158 | ) 159 | 160 | case VaultAuthMethod.KUBERNETES => 161 | settings.k8s 162 | .map(k8s => 163 | vault 164 | .auth() 165 | .loginByJwt("kubernetes", k8s.role, k8s.jwt.value(), k8s.authPath) 166 | .getAuthClientToken, 167 | ) 168 | case VaultAuthMethod.GCP => 169 | settings.gcp 170 | .map(gcp => 171 | vault 172 | .auth() 173 | .loginByGCP(gcp.role, gcp.jwt.value()) 174 | .getAuthClientToken, 175 | ) 176 | 177 | case VaultAuthMethod.LDAP => 178 | settings.ldap 179 | .map(l => 180 | vault 181 | .auth() 182 | .loginByLDAP(l.username, l.password.value(), l.mount) 183 | .getAuthClientToken, 184 | ) 185 | 186 | case VaultAuthMethod.JWT => 187 | settings.jwt 188 | .map(j => 189 | vault 190 | .auth() 191 | .loginByJwt(j.provider, j.role, j.jwt.value()) 192 | .getAuthClientToken, 193 | ) 194 | 195 | case VaultAuthMethod.TOKEN => 196 | Some(settings.token.value()) 197 | 198 | case VaultAuthMethod.GITHUB => 199 | settings.github 200 | .map(gh => 201 | vault 202 | .auth() 203 | .loginByGithub(gh.token.value(), gh.mount) 204 | .getAuthClientToken, 205 | ) 206 | 207 | case _ => 208 | throw new ConnectException( 209 | s"Unsupported auth method [${settings.authMode.toString}]", 210 | ) 211 | } 212 | 213 | config.token(token.get) 214 | config.build() 215 | new Vault(config) 216 | } 217 | 218 | // set up tls 219 | private def configureSSL(settings: VaultSettings): SslConfig = { 220 | val ssl = new SslConfig() 221 | 222 | if (settings.keystoreLoc != "") { 223 | logger.info(s"Configuring keystore at [${settings.keystoreLoc}]") 224 | ssl.keyStoreFile( 225 | new File(settings.keystoreLoc), 226 | settings.keystorePass.value(), 227 | ) 228 | } 229 | 230 | if (settings.truststoreLoc != "") { 231 | logger.info(s"Configuring keystore at [${settings.truststoreLoc}]") 232 | ssl.trustStoreFile(new File(settings.truststoreLoc)) 233 | } 234 | 235 | if (settings.clientPem != "") { 236 | logger.info(s"Configuring client PEM. Ignored if JKS set.") 237 | ssl.clientKeyPemFile(new File(settings.clientPem)) 238 | } 239 | 240 | if (settings.pem != "") { 241 | logger.info(s"Configuring Vault Server PEM. Ignored if JKS set.") 242 | ssl.pemFile(new File(settings.pem)) 243 | } 244 | 245 | ssl.build() 246 | } 247 | } 248 | -------------------------------------------------------------------------------- /project/Settings.scala: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (c) 2017-2020 Lenses.io Ltd 3 | */ 4 | 5 | import com.eed3si9n.jarjarabrams.ShadeRule 6 | import sbt.* 7 | import sbt.Keys.* 8 | import sbt.internal.util.ManagedLogger 9 | import sbtassembly.Assembly.JarEntry 10 | import sbtassembly.AssemblyKeys.* 11 | import sbtassembly.CustomMergeStrategy 12 | import sbtassembly.MergeStrategy 13 | import sbtassembly.PathList 14 | 15 | import java.io.* 16 | import java.net.* 17 | import scala.util.Try 18 | import java.nio.file.Files 19 | import java.nio.file.Paths 20 | 21 | object Settings extends Dependencies { 22 | 23 | val scala213 = "2.13.10" 24 | val scala3 = "3.2.2" 25 | 26 | val nextVersion = "2.3.1" 27 | val artifactVersion: String = { 28 | sys.env.get("LENSES_TAG_NAME") match { 29 | case Some(tag) => tag 30 | case _ => s"$nextVersion-SNAPSHOT" 31 | } 32 | } 33 | 34 | object ScalacFlags { 35 | val FatalWarnings212 = "-Xfatal-warnings" 36 | val FatalWarnings213 = "-Werror" 37 | val WarnUnusedImports212 = "-Ywarn-unused-import" 38 | val WarnUnusedImports213 = "-Ywarn-unused:imports" 39 | } 40 | 41 | val modulesSettings: Seq[Setting[_]] = Seq( 42 | organization := "io.lenses", 43 | version := artifactVersion, 44 | scalaOrganization := "org.scala-lang", 45 | resolvers ++= Seq( 46 | Resolver.mavenLocal, 47 | Resolver.mavenCentral, 48 | ), 49 | crossScalaVersions := List( /*scala3, */ scala213), 50 | Compile / scalacOptions ++= Seq( 51 | "-release:11", 52 | "-encoding", 53 | "utf8", 54 | "-deprecation", 55 | "-unchecked", 56 | "-feature", 57 | "11", 58 | ), 59 | Compile / scalacOptions ++= { 60 | Seq( 61 | ScalacFlags.WarnUnusedImports213, 62 | ) 63 | }, 64 | Compile / console / scalacOptions --= Seq( 65 | ScalacFlags.FatalWarnings212, 66 | ScalacFlags.FatalWarnings213, 67 | ScalacFlags.WarnUnusedImports212, 68 | ScalacFlags.WarnUnusedImports213, 69 | ), 70 | // TODO remove for cross build 71 | scalaVersion := scala213, 72 | Test / fork := true, 73 | ) 74 | 75 | implicit final class AssemblyConfigurator(project: Project) { 76 | 77 | /* 78 | Exclude the jar signing files from dependencies. They come from other jars, 79 | and including these would break assembly (due to duplicates) or classloading 80 | (due to mismatching jar checksum/signature). 81 | */ 82 | val excludeFilePatterns = Set(".MF", ".RSA", ".DSA", ".SF") 83 | 84 | def excludeFileFilter(p: String): Boolean = 85 | excludeFilePatterns.exists(p.endsWith) 86 | 87 | val excludePatterns = Set( 88 | "kafka-client", 89 | "kafka-connect-json", 90 | "hadoop-yarn", 91 | "org.apache.kafka", 92 | "zookeeper", 93 | "log4j", 94 | "junit", 95 | ) 96 | 97 | def configureAssembly(): Project = 98 | project.settings( 99 | Seq( 100 | assembly / test := {}, 101 | assembly / assemblyOutputPath := { 102 | createLibsDir((Compile / target).value) 103 | file( 104 | target.value + "/libs/" + (assembly / assemblyJarName).value, 105 | ) 106 | }, 107 | assembly / assemblyExcludedJars := { 108 | val cp: Classpath = (assembly / fullClasspath).value 109 | cp filter { f => 110 | excludePatterns 111 | .exists(f.data.getName.contains) && (!f.data.getName 112 | .contains("slf4j")) 113 | } 114 | }, 115 | assembly / assemblyMergeStrategy := { 116 | case PathList("META-INF", "maven", _ @_*) => 117 | CustomMergeStrategy("keep-only-fresh-maven-descriptors", 1) { 118 | assemblyDependency => 119 | val keepDeps = assemblyDependency.collect { 120 | case dependency @ (_: sbtassembly.Assembly.Project) => 121 | JarEntry(dependency.target, dependency.stream) 122 | } 123 | Right(keepDeps) 124 | } 125 | case PathList("META-INF", "maven", _ @_*) => 126 | MergeStrategy.discard 127 | case PathList("META-INF", "MANIFEST.MF") => 128 | MergeStrategy.discard 129 | case p if excludeFileFilter(p) => 130 | MergeStrategy.discard 131 | case PathList(ps @ _*) if ps.last == "module-info.class" => 132 | MergeStrategy.discard 133 | case _ => MergeStrategy.first 134 | }, 135 | assembly / assemblyShadeRules ++= Seq( 136 | ShadeRule.rename("io.confluent.**" -> "lshaded.confluent.@1").inAll, 137 | ShadeRule.rename("com.fasterxml.**" -> "lshaded.fasterxml.@1").inAll, 138 | ), 139 | ), 140 | ) 141 | 142 | def createLibsDir(targetDir: File): Unit = { 143 | 144 | val newDirectory = targetDir / "libs" 145 | 146 | if (!Files.exists(Paths.get(newDirectory.toString))) { 147 | Files.createDirectories(Paths.get(newDirectory.toString)) 148 | } 149 | } 150 | 151 | } 152 | 153 | lazy val IntegrationTest = config("it") extend Test 154 | 155 | implicit final class MavenDescriptorConfigurator(project: Project) { 156 | 157 | val generateMetaInfMaven = taskKey[Unit]("Generate META-INF/maven directory") 158 | 159 | def configureMavenDescriptor(): Project = 160 | project 161 | .settings( 162 | generateMetaInfMaven := { 163 | val log = streams.value.log 164 | 165 | val targetDirBase = (Compile / crossTarget).value / "classes" / "META-INF" / "maven" 166 | 167 | val allModuleIds: Map[ModuleID, String] = update 168 | .value 169 | .configuration(Compile) 170 | .toVector 171 | .flatMap(_.modules) 172 | .map { 173 | e: ModuleReport => e.module -> e.artifacts.headOption 174 | } 175 | .collect { 176 | case (moduleId, Some((moduleJar, moduleFile))) => 177 | moduleId -> 178 | moduleJar.url.get.toString 179 | .reverse 180 | .replaceFirst(".jar".reverse, ".pom".reverse) 181 | .reverse 182 | }.toMap 183 | 184 | for ((moduleId, pomUrl) <- allModuleIds) { 185 | 186 | log.info(s"Processing ${moduleId.name}") 187 | 188 | val groupId = moduleId.organization 189 | val artifactId = moduleId.name 190 | val version = moduleId.revision 191 | val targetDir = targetDirBase / groupId / artifactId 192 | targetDir.mkdirs() 193 | 194 | val propertiesFileChanged = createPomPropertiesIfChanged(groupId, artifactId, version, targetDir) 195 | if (propertiesFileChanged) { 196 | createPomXml(log, targetDir, pomUrl) 197 | } 198 | } 199 | 200 | }, 201 | (Compile / compile) := ((Compile / compile) dependsOn generateMetaInfMaven).value, 202 | ) 203 | 204 | private def createPomXml(log: ManagedLogger, targetDir: File, pomUrl: String): Option[File] = { 205 | val pomFile = targetDir / "pom.xml" 206 | 207 | try { 208 | val url = new URL(pomUrl) 209 | val connection = url.openConnection().asInstanceOf[HttpURLConnection] 210 | connection.setRequestMethod("GET") 211 | 212 | if (connection.getResponseCode == HttpURLConnection.HTTP_OK && connection.getContentType == "text/xml") { 213 | val inputStream = connection.getInputStream 214 | try { 215 | val pomContent = new String(inputStream.readAllBytes()) 216 | IO.write(pomFile, pomContent) 217 | log.info(s"Successfully retrieved and saved POM from $pomUrl to $pomFile") 218 | Some(pomFile) 219 | 220 | } finally { 221 | inputStream.close() 222 | } 223 | } else { 224 | log.error( 225 | s"Failed to retrieve POM from $pomUrl. HTTP Status: ${connection.getResponseCode}, Content Type: ${connection.getContentType}", 226 | ) 227 | Option.empty 228 | } 229 | } catch { 230 | case e: MalformedURLException => 231 | log.error(s"Invalid URL: $pomUrl") 232 | Option.empty 233 | case e: IOException => 234 | log.error(s"Error while retrieving POM from $pomUrl: ${e.getMessage}") 235 | Option.empty 236 | } 237 | 238 | } 239 | 240 | private def createPomPropertiesIfChanged( 241 | groupId: String, 242 | artifactId: String, 243 | version: String, 244 | targetDir: File, 245 | ): Boolean = { 246 | val propertiesFile = targetDir / "pom.properties" 247 | val propertiesContent = 248 | s"""version=$version 249 | |groupId=$groupId 250 | |artifactId=$artifactId 251 | """.stripMargin 252 | 253 | val alreadyExists = Try(IO.read(propertiesFile)) 254 | .toOption 255 | .contains(propertiesContent) 256 | 257 | if (!alreadyExists) { 258 | IO.write(propertiesFile, propertiesContent) 259 | } 260 | 261 | !alreadyExists 262 | 263 | } 264 | } 265 | } 266 | -------------------------------------------------------------------------------- /secret-provider/src/main/scala/io/lenses/connect/secrets/config/VaultProviderSettings.scala: -------------------------------------------------------------------------------- 1 | /* 2 | * 3 | * * Copyright 2017-2020 Lenses.io Ltd 4 | * 5 | */ 6 | 7 | package io.lenses.connect.secrets.config 8 | 9 | import com.typesafe.scalalogging.StrictLogging 10 | import io.lenses.connect.secrets.config.AbstractConfigExtensions._ 11 | import io.lenses.connect.secrets.config.VaultAuthMethod.VaultAuthMethod 12 | import io.lenses.connect.secrets.config.VaultProviderConfig.TOKEN_RENEWAL 13 | import io.lenses.connect.secrets.connect._ 14 | import org.apache.kafka.common.config.types.Password 15 | import org.apache.kafka.connect.errors.ConnectException 16 | 17 | import java.time.Duration 18 | import java.time.temporal.ChronoUnit 19 | import scala.concurrent.duration.DurationInt 20 | import scala.concurrent.duration.FiniteDuration 21 | import scala.io.Source 22 | import scala.util.Failure 23 | import scala.util.Success 24 | import scala.util.Using 25 | 26 | case class AwsIam( 27 | role: String, 28 | url: String, 29 | headers: Password, 30 | body: Password, 31 | mount: String, 32 | ) 33 | case class Gcp(role: String, jwt: Password) 34 | case class Jwt(role: String, provider: String, jwt: Password) 35 | case class UserPass(username: String, password: Password, mount: String) 36 | case class Ldap(username: String, password: Password, mount: String) 37 | case class AppRole(path: String, role: String, secretId: Password) 38 | case class K8s(role: String, jwt: Password, authPath: String) 39 | case class Cert(mount: String) 40 | case class Github(token: Password, mount: String) 41 | 42 | case class VaultSettings( 43 | addr: String, 44 | namespace: String, 45 | token: Password, 46 | authMode: VaultAuthMethod, 47 | keystoreLoc: String, 48 | keystorePass: Password, 49 | truststoreLoc: String, 50 | pem: String, 51 | clientPem: String, 52 | engineVersion: Int = 2, 53 | prefixDepth: Int = 1, 54 | appRole: Option[AppRole], 55 | awsIam: Option[AwsIam], 56 | gcp: Option[Gcp], 57 | jwt: Option[Jwt], 58 | userPass: Option[UserPass], 59 | ldap: Option[Ldap], 60 | k8s: Option[K8s], 61 | cert: Option[Cert], 62 | github: Option[Github], 63 | tokenRenewal: FiniteDuration, 64 | fileWriterOpts: Option[FileWriterOptions], 65 | defaultTtl: Option[Duration], 66 | ) 67 | 68 | object VaultSettings extends StrictLogging { 69 | def apply(config: VaultProviderConfig): VaultSettings = { 70 | val addr = config.getString(VaultProviderConfig.VAULT_ADDR) 71 | val token = config.getPassword(VaultProviderConfig.VAULT_TOKEN) 72 | val namespace = config.getString(VaultProviderConfig.VAULT_NAMESPACE) 73 | val keystoreLoc = config.getString(VaultProviderConfig.VAULT_KEYSTORE_LOC) 74 | val keystorePass = 75 | config.getPassword(VaultProviderConfig.VAULT_KEYSTORE_PASS) 76 | val truststoreLoc = 77 | config.getString(VaultProviderConfig.VAULT_TRUSTSTORE_LOC) 78 | val pem = config.getString(VaultProviderConfig.VAULT_PEM) 79 | val clientPem = config.getString(VaultProviderConfig.VAULT_CLIENT_PEM) 80 | val engineVersion = config.getInt(VaultProviderConfig.VAULT_ENGINE_VERSION) 81 | val prefixDepth = config.getInt(VaultProviderConfig.VAULT_PREFIX_DEPTH) 82 | 83 | val authMode = VaultAuthMethod.withNameOpt( 84 | config.getString(VaultProviderConfig.AUTH_METHOD).toUpperCase, 85 | ) match { 86 | case Some(auth) => auth 87 | case None => 88 | throw new ConnectException( 89 | s"Unsupported ${VaultProviderConfig.AUTH_METHOD}", 90 | ) 91 | } 92 | 93 | val awsIam = 94 | if (authMode.equals(VaultAuthMethod.AWSIAM)) Some(getAWS(config)) 95 | else None 96 | val gcp = 97 | if (authMode.equals(VaultAuthMethod.GCP)) Some(getGCP(config)) else None 98 | val appRole = 99 | if (authMode.equals(VaultAuthMethod.APPROLE)) Some(getAppRole(config)) 100 | else None 101 | val jwt = 102 | if (authMode.equals(VaultAuthMethod.JWT)) Some(getJWT(config)) else None 103 | val k8s = 104 | if (authMode.equals(VaultAuthMethod.KUBERNETES)) Some(getK8s(config)) 105 | else None 106 | val userpass = 107 | if (authMode.equals(VaultAuthMethod.USERPASS)) Some(getUserPass(config)) 108 | else None 109 | val ldap = 110 | if (authMode.equals(VaultAuthMethod.LDAP)) Some(getLDAP(config)) else None 111 | val cert = 112 | if (authMode.equals(VaultAuthMethod.CERT)) Some(getCert(config)) else None 113 | val github = 114 | if (authMode.equals(VaultAuthMethod.GITHUB)) Some(getGitHub(config)) 115 | else None 116 | 117 | val tokenRenewal = config.getInt(TOKEN_RENEWAL).toInt.milliseconds 118 | VaultSettings( 119 | addr = addr, 120 | namespace = namespace, 121 | token = token, 122 | authMode = authMode, 123 | keystoreLoc = keystoreLoc, 124 | keystorePass = keystorePass, 125 | truststoreLoc = truststoreLoc, 126 | pem = pem, 127 | clientPem = clientPem, 128 | engineVersion = engineVersion, 129 | prefixDepth = prefixDepth, 130 | appRole = appRole, 131 | awsIam = awsIam, 132 | gcp = gcp, 133 | jwt = jwt, 134 | userPass = userpass, 135 | ldap = ldap, 136 | k8s = k8s, 137 | cert = cert, 138 | github = github, 139 | tokenRenewal = tokenRenewal, 140 | fileWriterOpts = FileWriterOptions(config), 141 | defaultTtl = 142 | Option(config.getLong(SECRET_DEFAULT_TTL).toLong).filterNot(_ == 0L).map(Duration.of(_, ChronoUnit.MILLIS)), 143 | ) 144 | } 145 | 146 | def getCert(config: VaultProviderConfig): Cert = 147 | Cert(config.getString(VaultProviderConfig.CERT_MOUNT)) 148 | 149 | def getGitHub(config: VaultProviderConfig): Github = { 150 | val token = 151 | config.getPasswordOrThrowOnNull(VaultProviderConfig.GITHUB_TOKEN) 152 | val mount = config.getStringOrThrowOnNull(VaultProviderConfig.GITHUB_MOUNT) 153 | Github(token = token, mount = mount) 154 | } 155 | 156 | def getAWS(config: VaultProviderConfig): AwsIam = { 157 | val role = config.getStringOrThrowOnNull(VaultProviderConfig.AWS_ROLE) 158 | val url = config.getStringOrThrowOnNull(VaultProviderConfig.AWS_REQUEST_URL) 159 | val headers = 160 | config.getPasswordOrThrowOnNull(VaultProviderConfig.AWS_REQUEST_HEADERS) 161 | val body = 162 | config.getPasswordOrThrowOnNull(VaultProviderConfig.AWS_REQUEST_BODY) 163 | val mount = config.getStringOrThrowOnNull(VaultProviderConfig.AWS_MOUNT) 164 | AwsIam( 165 | role = role, 166 | url = url, 167 | headers = headers, 168 | body = body, 169 | mount = mount, 170 | ) 171 | } 172 | 173 | def getAppRole(config: VaultProviderConfig): AppRole = { 174 | val path = config.getStringOrThrowOnNull(VaultProviderConfig.APP_ROLE_PATH) 175 | val role = config.getStringOrThrowOnNull(VaultProviderConfig.APP_ROLE) 176 | val secretId = 177 | config.getPasswordOrThrowOnNull(VaultProviderConfig.APP_ROLE_SECRET_ID) 178 | AppRole(path = path, role = role, secretId = secretId) 179 | } 180 | 181 | def getK8s(config: VaultProviderConfig): K8s = { 182 | val role = 183 | config.getStringOrThrowOnNull(VaultProviderConfig.KUBERNETES_ROLE) 184 | val path = 185 | config.getStringOrThrowOnNull(VaultProviderConfig.KUBERNETES_TOKEN_PATH) 186 | val authPath = config.getStringOrThrowOnNull(VaultProviderConfig.KUBERNETES_AUTH_PATH) 187 | Using(Source.fromFile(path))(_.getLines().mkString) match { 188 | case Failure(exception) => 189 | throw new ConnectException( 190 | s"Failed to load kubernetes token file [$path]", 191 | exception, 192 | ) 193 | case Success(fileContents) => 194 | K8s(role = role, jwt = new Password(fileContents), authPath) 195 | } 196 | } 197 | 198 | def getUserPass(config: VaultProviderConfig): UserPass = { 199 | val user = config.getStringOrThrowOnNull(VaultProviderConfig.USERNAME) 200 | val pass = config.getPasswordOrThrowOnNull(VaultProviderConfig.PASSWORD) 201 | val mount = config.getStringOrThrowOnNull(VaultProviderConfig.UP_MOUNT) 202 | UserPass(username = user, password = pass, mount = mount) 203 | } 204 | 205 | def getLDAP(config: VaultProviderConfig): Ldap = { 206 | val user = config.getStringOrThrowOnNull(VaultProviderConfig.LDAP_USERNAME) 207 | val pass = 208 | config.getPasswordOrThrowOnNull(VaultProviderConfig.LDAP_PASSWORD) 209 | val mount = config.getStringOrThrowOnNull(VaultProviderConfig.LDAP_MOUNT) 210 | Ldap(username = user, password = pass, mount = mount) 211 | } 212 | 213 | def getGCP(config: VaultProviderConfig): Gcp = { 214 | val role = config.getStringOrThrowOnNull(VaultProviderConfig.GCP_ROLE) 215 | val jwt = config.getPasswordOrThrowOnNull(VaultProviderConfig.GCP_JWT) 216 | Gcp(role = role, jwt = jwt) 217 | } 218 | 219 | def getJWT(config: VaultProviderConfig): Jwt = { 220 | val role = config.getStringOrThrowOnNull(VaultProviderConfig.JWT_ROLE) 221 | val provider = 222 | config.getStringOrThrowOnNull(VaultProviderConfig.JWT_PROVIDER) 223 | val jwt = config.getPasswordOrThrowOnNull(VaultProviderConfig.JWT) 224 | Jwt(role = role, provider = provider, jwt = jwt) 225 | } 226 | } 227 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | 2 | Apache License 3 | Version 2.0, January 2004 4 | http://www.apache.org/licenses/ 5 | 6 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 7 | 8 | 1. Definitions. 9 | 10 | "License" shall mean the terms and conditions for use, reproduction, 11 | and distribution as defined by Sections 1 through 9 of this document. 12 | 13 | "Licensor" shall mean the copyright owner or entity authorized by 14 | the copyright owner that is granting the License. 15 | 16 | "Legal Entity" shall mean the union of the acting entity and all 17 | other entities that control, are controlled by, or are under common 18 | control with that entity. For the purposes of this definition, 19 | "control" means (i) the power, direct or indirect, to cause the 20 | direction or management of such entity, whether by contract or 21 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 22 | outstanding shares, or (iii) beneficial ownership of such entity. 23 | 24 | "You" (or "Your") shall mean an individual or Legal Entity 25 | exercising permissions granted by this License. 26 | 27 | "Source" form shall mean the preferred form for making modifications, 28 | including but not limited to software source code, documentation 29 | source, and configuration files. 30 | 31 | "Object" form shall mean any form resulting from mechanical 32 | transformation or translation of a Source form, including but 33 | not limited to compiled object code, generated documentation, 34 | and conversions to other media types. 35 | 36 | "Work" shall mean the work of authorship, whether in Source or 37 | Object form, made available under the License, as indicated by a 38 | copyright notice that is included in or attached to the work 39 | (an example is provided in the Appendix below). 40 | 41 | "Derivative Works" shall mean any work, whether in Source or Object 42 | form, that is based on (or derived from) the Work and for which the 43 | editorial revisions, annotations, elaborations, or other modifications 44 | represent, as a whole, an original work of authorship. For the purposes 45 | of this License, Derivative Works shall not include works that remain 46 | separable from, or merely link (or bind by name) to the interfaces of, 47 | the Work and Derivative Works thereof. 48 | 49 | "Contribution" shall mean any work of authorship, including 50 | the original version of the Work and any modifications or additions 51 | to that Work or Derivative Works thereof, that is intentionally 52 | submitted to Licensor for inclusion in the Work by the copyright owner 53 | or by an individual or Legal Entity authorized to submit on behalf of 54 | the copyright owner. For the purposes of this definition, "submitted" 55 | means any form of electronic, verbal, or written communication sent 56 | to the Licensor or its representatives, including but not limited to 57 | communication on electronic mailing lists, source code control systems, 58 | and issue tracking systems that are managed by, or on behalf of, the 59 | Licensor for the purpose of discussing and improving the Work, but 60 | excluding communication that is conspicuously marked or otherwise 61 | designated in writing by the copyright owner as "Not a Contribution." 62 | 63 | "Contributor" shall mean Licensor and any individual or Legal Entity 64 | on behalf of whom a Contribution has been received by Licensor and 65 | subsequently incorporated within the Work. 66 | 67 | 2. Grant of Copyright License. Subject to the terms and conditions of 68 | this License, each Contributor hereby grants to You a perpetual, 69 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 70 | copyright license to reproduce, prepare Derivative Works of, 71 | publicly display, publicly perform, sublicense, and distribute the 72 | Work and such Derivative Works in Source or Object form. 73 | 74 | 3. Grant of Patent License. Subject to the terms and conditions of 75 | this License, each Contributor hereby grants to You a perpetual, 76 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 77 | (except as stated in this section) patent license to make, have made, 78 | use, offer to sell, sell, import, and otherwise transfer the Work, 79 | where such license applies only to those patent claims licensable 80 | by such Contributor that are necessarily infringed by their 81 | Contribution(s) alone or by combination of their Contribution(s) 82 | with the Work to which such Contribution(s) was submitted. If You 83 | institute patent litigation against any entity (including a 84 | cross-claim or counterclaim in a lawsuit) alleging that the Work 85 | or a Contribution incorporated within the Work constitutes direct 86 | or contributory patent infringement, then any patent licenses 87 | granted to You under this License for that Work shall terminate 88 | as of the date such litigation is filed. 89 | 90 | 4. Redistribution. You may reproduce and distribute copies of the 91 | Work or Derivative Works thereof in any medium, with or without 92 | modifications, and in Source or Object form, provided that You 93 | meet the following conditions: 94 | 95 | (a) You must give any other recipients of the Work or 96 | Derivative Works a copy of this License; and 97 | 98 | (b) You must cause any modified files to carry prominent notices 99 | stating that You changed the files; and 100 | 101 | (c) You must retain, in the Source form of any Derivative Works 102 | that You distribute, all copyright, patent, trademark, and 103 | attribution notices from the Source form of the Work, 104 | excluding those notices that do not pertain to any part of 105 | the Derivative Works; and 106 | 107 | (d) If the Work includes a "NOTICE" text file as part of its 108 | distribution, then any Derivative Works that You distribute must 109 | include a readable copy of the attribution notices contained 110 | within such NOTICE file, excluding those notices that do not 111 | pertain to any part of the Derivative Works, in at least one 112 | of the following places: within a NOTICE text file distributed 113 | as part of the Derivative Works; within the Source form or 114 | documentation, if provided along with the Derivative Works; or, 115 | within a display generated by the Derivative Works, if and 116 | wherever such third-party notices normally appear. The contents 117 | of the NOTICE file are for informational purposes only and 118 | do not modify the License. You may add Your own attribution 119 | notices within Derivative Works that You distribute, alongside 120 | or as an addendum to the NOTICE text from the Work, provided 121 | that such additional attribution notices cannot be construed 122 | as modifying the License. 123 | 124 | You may add Your own copyright statement to Your modifications and 125 | may provide additional or different license terms and conditions 126 | for use, reproduction, or distribution of Your modifications, or 127 | for any such Derivative Works as a whole, provided Your use, 128 | reproduction, and distribution of the Work otherwise complies with 129 | the conditions stated in this License. 130 | 131 | 5. Submission of Contributions. Unless You explicitly state otherwise, 132 | any Contribution intentionally submitted for inclusion in the Work 133 | by You to the Licensor shall be under the terms and conditions of 134 | this License, without any additional terms or conditions. 135 | Notwithstanding the above, nothing herein shall supersede or modify 136 | the terms of any separate license agreement you may have executed 137 | with Licensor regarding such Contributions. 138 | 139 | 6. Trademarks. This License does not grant permission to use the trade 140 | names, trademarks, service marks, or product names of the Licensor, 141 | except as required for reasonable and customary use in describing the 142 | origin of the Work and reproducing the content of the NOTICE file. 143 | 144 | 7. Disclaimer of Warranty. Unless required by applicable law or 145 | agreed to in writing, Licensor provides the Work (and each 146 | Contributor provides its Contributions) on an "AS IS" BASIS, 147 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 148 | implied, including, without limitation, any warranties or conditions 149 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 150 | PARTICULAR PURPOSE. You are solely responsible for determining the 151 | appropriateness of using or redistributing the Work and assume any 152 | risks associated with Your exercise of permissions under this License. 153 | 154 | 8. Limitation of Liability. In no event and under no legal theory, 155 | whether in tort (including negligence), contract, or otherwise, 156 | unless required by applicable law (such as deliberate and grossly 157 | negligent acts) or agreed to in writing, shall any Contributor be 158 | liable to You for damages, including any direct, indirect, special, 159 | incidental, or consequential damages of any character arising as a 160 | result of this License or out of the use or inability to use the 161 | Work (including but not limited to damages for loss of goodwill, 162 | work stoppage, computer failure or malfunction, or any and all 163 | other commercial damages or losses), even if such Contributor 164 | has been advised of the possibility of such damages. 165 | 166 | 9. Accepting Warranty or Additional Liability. While redistributing 167 | the Work or Derivative Works thereof, You may choose to offer, 168 | and charge a fee for, acceptance of support, warranty, indemnity, 169 | or other liability obligations and/or rights consistent with this 170 | License. However, in accepting such obligations, You may act only 171 | on Your own behalf and on Your sole responsibility, not on behalf 172 | of any other Contributor, and only if You agree to indemnify, 173 | defend, and hold each Contributor harmless for any liability 174 | incurred by, or claims asserted against, such Contributor by reason 175 | of your accepting any such warranty or additional liability. 176 | 177 | END OF TERMS AND CONDITIONS 178 | 179 | APPENDIX: How to apply the Apache License to your work. 180 | 181 | To apply the Apache License to your work, attach the following 182 | boilerplate notice, with the fields enclosed by brackets "[]" 183 | replaced with your own identifying information. (Don't include 184 | the brackets!) The text should be enclosed in the appropriate 185 | comment syntax for the file format. We also recommend that a 186 | file or class name and description of purpose be included on the 187 | same "printed page" as the copyright notice for easier 188 | identification within third-party archives. 189 | 190 | Copyright [yyyy] [name of copyright owner] 191 | 192 | Licensed under the Apache License, Version 2.0 (the "License"); 193 | you may not use this file except in compliance with the License. 194 | You may obtain a copy of the License at 195 | 196 | http://www.apache.org/licenses/LICENSE-2.0 197 | 198 | Unless required by applicable law or agreed to in writing, software 199 | distributed under the License is distributed on an "AS IS" BASIS, 200 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 201 | See the License for the specific language governing permissions and 202 | limitations under the License. -------------------------------------------------------------------------------- /secret-provider/src/main/scala/io/lenses/connect/secrets/config/VaultProviderConfig.scala: -------------------------------------------------------------------------------- 1 | /* 2 | * 3 | * * Copyright 2017-2020 Lenses.io Ltd 4 | * 5 | */ 6 | 7 | package io.lenses.connect.secrets.config 8 | 9 | import io.lenses.connect.secrets.connect.FILE_DIR 10 | import io.lenses.connect.secrets.connect.FILE_DIR_DESC 11 | import io.lenses.connect.secrets.connect.SECRET_DEFAULT_TTL 12 | import io.lenses.connect.secrets.connect.SECRET_DEFAULT_TTL_DEFAULT 13 | import io.lenses.connect.secrets.connect.WRITE_FILES 14 | import io.lenses.connect.secrets.connect.WRITE_FILES_DESC 15 | import org.apache.kafka.common.config.ConfigDef.Importance 16 | import org.apache.kafka.common.config.ConfigDef.Type 17 | import org.apache.kafka.common.config.AbstractConfig 18 | import org.apache.kafka.common.config.ConfigDef 19 | import org.apache.kafka.common.config.SslConfigs 20 | 21 | import java.util 22 | 23 | object VaultAuthMethod extends Enumeration { 24 | type VaultAuthMethod = Value 25 | val KUBERNETES, AWSIAM, GCP, USERPASS, LDAP, JWT, CERT, APPROLE, TOKEN, GITHUB = Value 26 | 27 | def withNameOpt(s: String): Option[Value] = values.find(_.toString == s) 28 | } 29 | 30 | object VaultProviderConfig { 31 | val VAULT_ADDR: String = "vault.addr" 32 | val VAULT_TOKEN: String = "vault.token" 33 | val VAULT_NAMESPACE: String = "vault.namespace" 34 | val VAULT_CLIENT_PEM: String = "vault.client.pem" 35 | val VAULT_PEM: String = "vault.pem" 36 | val VAULT_ENGINE_VERSION = "vault.engine.version" 37 | val VAULT_PREFIX_DEPTH = "vault.prefix.depth" 38 | val AUTH_METHOD: String = "vault.auth.method" 39 | 40 | val VAULT_TRUSTSTORE_LOC: String = 41 | s"vault.${SslConfigs.SSL_TRUSTSTORE_LOCATION_CONFIG}" 42 | val VAULT_KEYSTORE_LOC: String = 43 | s"vault.${SslConfigs.SSL_KEYSTORE_LOCATION_CONFIG}" 44 | val VAULT_KEYSTORE_PASS: String = 45 | s"vault.${SslConfigs.SSL_KEYSTORE_PASSWORD_CONFIG}" 46 | 47 | val KUBERNETES_ROLE: String = "kubernetes.role" 48 | val KUBERNETES_TOKEN_PATH: String = "kubernetes.token.path" 49 | val KUBERNETES_DEFAULT_TOKEN_PATH: String = 50 | "/var/run/secrets/kubernetes.io/serviceaccount/token" 51 | val KUBERNETES_AUTH_PATH: String = "kubernetes.auth.path" 52 | val KUBERNETES_AUTH_PATH_DEFAULT: String = "auth/kubernetes" 53 | 54 | val APP_ROLE_PATH: String = "app.role.path" 55 | val APP_ROLE: String = "app.role.id" 56 | val APP_ROLE_SECRET_ID: String = "app.role.secret.id" 57 | 58 | val AWS_ROLE: String = "aws.role" 59 | val AWS_REQUEST_URL: String = "aws.request.url" 60 | val AWS_REQUEST_HEADERS: String = "aws.request.headers" 61 | val AWS_REQUEST_BODY: String = "aws.request.body" 62 | val AWS_MOUNT: String = "aws.mount" 63 | 64 | val GCP_ROLE: String = "gcp.role" 65 | val GCP_JWT: String = "gcp.jwt" 66 | 67 | val LDAP_USERNAME: String = "ldap.username" 68 | val LDAP_PASSWORD: String = "ldap.password" 69 | val LDAP_MOUNT: String = "ldap.mount" 70 | 71 | val USERNAME: String = "username" 72 | val PASSWORD: String = "password" 73 | val UP_MOUNT: String = "mount" 74 | 75 | val JWT_ROLE: String = "jwt.role" 76 | val JWT_PROVIDER: String = "jwt.provider" 77 | val JWT: String = "jwt" 78 | 79 | val CERT_MOUNT: String = "cert.mount" 80 | 81 | val GITHUB_TOKEN: String = "github.token" 82 | val GITHUB_MOUNT: String = "github.mount" 83 | 84 | val TOKEN_RENEWAL: String = "token.renewal.ms" 85 | val TOKEN_RENEWAL_DEFAULT: Int = 600000 86 | 87 | val config: ConfigDef = new ConfigDef() 88 | .define( 89 | VAULT_ADDR, 90 | ConfigDef.Type.STRING, 91 | "http://localhost:8200", 92 | Importance.HIGH, 93 | "Address of the Vault server", 94 | ) 95 | .define( 96 | VAULT_TOKEN, 97 | ConfigDef.Type.PASSWORD, 98 | null, 99 | Importance.HIGH, 100 | s"Vault app role token. $AUTH_METHOD must be 'token'", 101 | ) 102 | .define( 103 | VAULT_NAMESPACE, 104 | Type.STRING, 105 | "", 106 | Importance.MEDIUM, 107 | "Sets a global namespace to the Vault server instance. Required Vault Enterprize Pro", 108 | ) 109 | .define( 110 | VAULT_KEYSTORE_LOC, 111 | ConfigDef.Type.STRING, 112 | "", 113 | ConfigDef.Importance.HIGH, 114 | SslConfigs.SSL_KEYSTORE_LOCATION_DOC, 115 | ) 116 | .define( 117 | VAULT_KEYSTORE_PASS, 118 | ConfigDef.Type.PASSWORD, 119 | "", 120 | ConfigDef.Importance.HIGH, 121 | SslConfigs.SSL_KEYSTORE_PASSWORD_DOC, 122 | ) 123 | .define( 124 | VAULT_TRUSTSTORE_LOC, 125 | ConfigDef.Type.STRING, 126 | "", 127 | ConfigDef.Importance.HIGH, 128 | SslConfigs.SSL_TRUSTSTORE_LOCATION_DOC, 129 | ) 130 | .define( 131 | VAULT_PEM, 132 | Type.STRING, 133 | "", 134 | Importance.HIGH, 135 | "File containing the Vault server certificate string contents", 136 | ) 137 | .define( 138 | VAULT_CLIENT_PEM, 139 | Type.STRING, 140 | "", 141 | Importance.HIGH, 142 | "File containing the Client certificate string contents", 143 | ) 144 | .define( 145 | VAULT_ENGINE_VERSION, 146 | Type.INT, 147 | 2, 148 | Importance.HIGH, 149 | "KV Secrets Engine version of the Vault server instance. Defaults to 2", 150 | ) 151 | .define( 152 | VAULT_PREFIX_DEPTH, 153 | Type.INT, 154 | 1, 155 | Importance.HIGH, 156 | """ 157 | |Set the path depth of the KV Secrets Engine prefix path. 158 | |Normally this is just 1, to correspond to one path element in the prefix path. 159 | |To use a longer prefix path, set this value 160 | | 161 | |""".stripMargin, 162 | ) 163 | // auth mode 164 | .define( 165 | AUTH_METHOD, 166 | Type.STRING, 167 | "token", 168 | Importance.HIGH, 169 | """ 170 | |The authentication mode for Vault. 171 | |Available values are approle, userpass, kubernetes, cert, token, ldap, gcp, awsiam, jwt, github 172 | | 173 | |""".stripMargin, 174 | ) 175 | // app role auth mode 176 | .define( 177 | APP_ROLE_PATH, 178 | Type.STRING, 179 | "approle", 180 | Importance.HIGH, 181 | s"Vault App role path. $AUTH_METHOD must be 'approle'", 182 | ) 183 | .define( 184 | APP_ROLE, 185 | Type.STRING, 186 | null, 187 | Importance.HIGH, 188 | s"Vault App role id. $AUTH_METHOD must be 'approle'", 189 | ) 190 | .define( 191 | APP_ROLE_SECRET_ID, 192 | Type.PASSWORD, 193 | null, 194 | Importance.HIGH, 195 | s"Vault App role name secret id. $AUTH_METHOD must be 'approle'", 196 | ) 197 | // userpass 198 | .define( 199 | USERNAME, 200 | Type.STRING, 201 | null, 202 | Importance.HIGH, 203 | s"Username to connect to Vault with. $AUTH_METHOD must be 'userpass'", 204 | ) 205 | .define( 206 | PASSWORD, 207 | Type.PASSWORD, 208 | null, 209 | Importance.HIGH, 210 | s"Password for the username. $AUTH_METHOD must be 'uerspass'", 211 | ) 212 | .define( 213 | UP_MOUNT, 214 | Type.STRING, 215 | "userpass", 216 | Importance.HIGH, 217 | s"The mount name of the userpass authentication back end. Defaults to 'userpass'. $AUTH_METHOD must be 'userpass'", 218 | ) 219 | // kubernetes 220 | .define( 221 | KUBERNETES_ROLE, 222 | Type.STRING, 223 | null, 224 | Importance.HIGH, 225 | s"The kubernetes role used for authentication. $AUTH_METHOD must be 'kubernetes'", 226 | ) 227 | .define( 228 | KUBERNETES_TOKEN_PATH, 229 | Type.STRING, 230 | KUBERNETES_DEFAULT_TOKEN_PATH, 231 | Importance.HIGH, 232 | s""" 233 | | s"Path to the service account token. $AUTH_METHOD must be 'kubernetes'. 234 | | Defaults to $KUBERNETES_DEFAULT_TOKEN_PATH 235 | 236 | | $AUTH_METHOD must be ' 237 | 238 | |""".stripMargin, 239 | ) 240 | // awsiam 241 | .define( 242 | AWS_ROLE, 243 | Type.STRING, 244 | null, 245 | Importance.HIGH, 246 | s""" 247 | |Name of the role against which the login is being attempted. If role is not specified, t 248 | in endpoint 249 | |looks for a role bearing the name of the AMI ID of the EC2 instance that is trying to 250 | ing the ec2 251 | |auth method, or the "friendly name" (i.e., role name or username) of the IAM pr 252 | henticated. 253 | |If a matching role is not found, login fails. $AUTH_METHOD 254 | must be 'awsiam' 255 | |""".stripMargin, 256 | ) 257 | .define( 258 | AWS_REQUEST_URL, 259 | Type.STRING, 260 | null, 261 | Importance.HIGH, 262 | s""" 263 | |PKCS7 signature of the identity document with all \n characters removed.Base64-encoded Hsed in the signed request. 264 | |Most likely just aHR0cHM6Ly9zdHMuYW1hem9uYXdzLmNvbS8= (base64-encoding of https://sts.amom/) as most requests will 265 | |probably use POST with an empty URI. $AUTH_METHOD must be 'awsiam' 266 | |""".stripMargin, 267 | ) 268 | .define( 269 | AWS_REQUEST_HEADERS, 270 | Type.PASSWORD, 271 | null, 272 | Importance.HIGH, 273 | s"Request headers. $AUTH_METHOD must be 'awsiam'", 274 | ) 275 | .define( 276 | AWS_REQUEST_BODY, 277 | Type.PASSWORD, 278 | null, 279 | Importance.HIGH, 280 | s""" 281 | 282 | ded body of the signed request. 283 | |Most likely QWN0aW9uPUdldENhbGxlcklkZ 284 | nNpb249MjAxMS0wNi0xNQ== which is 285 | |the base64 encoding of Action=GetCallerIdentity&Versi 286 | 5. $AUTH_METHOD must be 'awsiam' 287 | |""".stripMargin, 288 | ) 289 | .define( 290 | AWS_MOUNT, 291 | Type.STRING, 292 | "aws", 293 | Importance.HIGH, 294 | s"AWS auth mount. $AUTH_METHOD must be 'awsiam'. Default 'aws'", 295 | ) 296 | //ldap 297 | .define( 298 | LDAP_USERNAME, 299 | Type.STRING, 300 | null, 301 | Importance.HIGH, 302 | s"LDAP username to connect to Vault with. $AUTH_METHOD must be 'ldap'", 303 | ) 304 | .define( 305 | LDAP_PASSWORD, 306 | Type.PASSWORD, 307 | null, 308 | Importance.HIGH, 309 | s"LDAP Password for the username. $AUTH_METHOD must be 'ldap'", 310 | ) 311 | .define( 312 | LDAP_MOUNT, 313 | Type.STRING, 314 | "ldap", 315 | Importance.HIGH, 316 | s"The mount name of the ldap authentication back end. Defaults to 'ldap'. $AUTH_METHOD must be 'ldap'", 317 | ) 318 | //jwt 319 | .define( 320 | JWT_ROLE, 321 | Type.STRING, 322 | null, 323 | Importance.HIGH, 324 | s"Role the JWT token belongs to. $AUTH_METHOD must be 'jwt'", 325 | ) 326 | .define( 327 | JWT_PROVIDER, 328 | Type.STRING, 329 | null, 330 | Importance.HIGH, 331 | s"Provider of JWT token. $AUTH_METHOD must be 'jwt'", 332 | ) 333 | .define( 334 | JWT, 335 | Type.PASSWORD, 336 | null, 337 | Importance.HIGH, 338 | s"JWT token. $AUTH_METHOD must be 'jwt'", 339 | ) 340 | //gcp 341 | .define( 342 | GCP_ROLE, 343 | Type.STRING, 344 | null, 345 | Importance.HIGH, 346 | s"The gcp role used for authentication. $AUTH_METHOD must be 'gcp'", 347 | ) 348 | .define( 349 | GCP_JWT, 350 | Type.PASSWORD, 351 | null, 352 | Importance.HIGH, 353 | s"JWT token. $AUTH_METHOD must be 'gcp'", 354 | ) 355 | // cert mount 356 | .define( 357 | CERT_MOUNT, 358 | Type.STRING, 359 | "cert", 360 | Importance.HIGH, 361 | s"The mount name of the cert authentication back end. Defaults to 'cert'. $AUTH_METHOD must be 'cert'", 362 | ) 363 | .define( 364 | GITHUB_TOKEN, 365 | Type.PASSWORD, 366 | null, 367 | Importance.HIGH, 368 | s"The github app-id used for authentication. $AUTH_METHOD must be 'github'", 369 | ) 370 | .define( 371 | GITHUB_MOUNT, 372 | Type.STRING, 373 | "github", 374 | Importance.HIGH, 375 | s"The mount name of the github authentication back end. Defaults to 'cert'. $AUTH_METHOD must be 'github'", 376 | ) 377 | .define( 378 | FILE_DIR, 379 | Type.STRING, 380 | "", 381 | Importance.MEDIUM, 382 | FILE_DIR_DESC, 383 | ) 384 | .define( 385 | WRITE_FILES, 386 | Type.BOOLEAN, 387 | false, 388 | Importance.MEDIUM, 389 | WRITE_FILES_DESC, 390 | ) 391 | .define( 392 | TOKEN_RENEWAL, 393 | Type.INT, 394 | TOKEN_RENEWAL_DEFAULT, 395 | Importance.MEDIUM, 396 | "The time in milliseconds to renew the Vault token", 397 | ).define( 398 | KUBERNETES_AUTH_PATH, 399 | Type.STRING, 400 | KUBERNETES_AUTH_PATH_DEFAULT, 401 | Importance.MEDIUM, 402 | "The mount path of the Vault Kubernetes Auth Method", 403 | ) 404 | .define( 405 | SECRET_DEFAULT_TTL, 406 | Type.LONG, 407 | SECRET_DEFAULT_TTL_DEFAULT, 408 | Importance.MEDIUM, 409 | "Default TTL to apply in case a secret has no TTL", 410 | ) 411 | 412 | } 413 | case class VaultProviderConfig(props: util.Map[String, _]) extends AbstractConfig(VaultProviderConfig.config, props) 414 | --------------------------------------------------------------------------------